` +
+ `
${renderAgent(turn.text)}
` +
+ `
` +
+ `${reason}` +
+ `
` +
+ `
`
+ );
+}
+
+// The whole transcript. `renderAgent` defaults to `mdToHtml` so tests exercise the
+// real markdown path without a DOM; `main.ts` passes the sanitizing wrapper.
+export function transcriptHtml(
+ turns: ChatTurn[],
+ renderAgent: RenderAgent = mdToHtml,
+): string {
+ if (turns.length === 0) {
+ return `No messages yet — send a prompt to the connected agent.
`;
+ }
+ return turns.map((t) => turnHtml(t, renderAgent)).join("");
+}
+
+// ---- transcript reducers ----------------------------------------------------
+// Pure updates: `main.ts` holds the `ChatTurn[]` and re-renders after each. The
+// id generator is the caller's (a monotonic counter), so turns keep stable keys
+// for the copy action across re-renders.
+
+export function appendUser(
+ turns: ChatTurn[],
+ id: number,
+ text: string,
+): ChatTurn[] {
+ return [...turns, { id, role: "user", text, streaming: false }];
+}
+
+// Append a streamed `chunk`. If no agent turn is open yet (this is the turn's
+// first chunk), start one with the given id; otherwise append to the open turn.
+export function appendChunk(
+ turns: ChatTurn[],
+ id: number,
+ text: string,
+): ChatTurn[] {
+ const last = turns[turns.length - 1];
+ if (last && last.role === "agent" && last.streaming) {
+ const updated: ChatTurn = { ...last, text: last.text + text };
+ return [...turns.slice(0, -1), updated];
+ }
+ return [...turns, { id, role: "agent", text, streaming: true }];
+}
+
+// Close the open agent turn on `turn_end`. If a `turn_end` arrives with no open
+// turn (e.g. a cancel before any chunk), synthesize an empty finalized turn so
+// the stop reason is still visible. No-op shape otherwise stays pure.
+export function endTurn(
+ turns: ChatTurn[],
+ id: number,
+ stopReason: string,
+): ChatTurn[] {
+ const last = turns[turns.length - 1];
+ if (last && last.role === "agent" && last.streaming) {
+ const updated: ChatTurn = { ...last, streaming: false, stopReason };
+ return [...turns.slice(0, -1), updated];
+ }
+ return [...turns, { id, role: "agent", text: "", streaming: false, stopReason }];
+}
diff --git a/console/src/main.ts b/console/src/main.ts
index 3521cc7..2929ca2 100644
--- a/console/src/main.ts
+++ b/console/src/main.ts
@@ -8,6 +8,15 @@ import {
deploymentKey,
} from "./render";
import type { Deployment, FleetConfig, RemoteConfig } from "./types";
+import {
+ transcriptHtml,
+ mdToHtml,
+ appendUser,
+ appendChunk,
+ endTurn,
+ type ChatTurn,
+} from "./chat";
+import DOMPurify from "dompurify";
import { createPane, bindBackend, type Level } from "./log";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
@@ -44,6 +53,12 @@ const clusterLabel = document.getElementById("cluster-label");
const pollStatus = document.getElementById("poll-status");
const logEl = document.getElementById("log");
const mcpEl = document.getElementById("mcpio");
+const chatLogEl = document.getElementById("chat-log");
+const chatFormEl = document.getElementById("chat-form") as HTMLFormElement | null;
+const chatTextEl = document.getElementById("chat-text") as HTMLTextAreaElement | null;
+const chatSendEl = document.getElementById("chat-send") as HTMLButtonElement | null;
+const chatStopEl = document.getElementById("chat-stop") as HTMLButtonElement | null;
+const chatConnEl = document.getElementById("chat-conn");
const source = defaultSource();
// Two tabs, one pane each: Activity (lifecycle + failures) and MCP (the raw
@@ -345,6 +360,211 @@ if (remoteEl) {
});
}
+// ---- chat panel (Part C) -----------------------------------------------------
+// The backend serves ONE turn at a time over the live `/acp` session (Part B):
+// `agent_prompt` sends a turn, and the reply streams back as `agent-update`
+// events (`chunk` → `turn_end`). `turnActive` gates sends; a prompt typed
+// mid-turn is queued and `flushQueue` releases the next only once the current
+// turn ends (the katashiro turn model). This UI-level gate means two turns never
+// overlap — so the backend's in-flight guard is a safety net, not the norm.
+let chatTurns: ChatTurn[] = [];
+let turnActive = false;
+const promptQueue: string[] = [];
+let chatSeq = 0;
+let remoteConnected = false;
+
+// True in the browser build (no Tauri shell): there is no live agent, so we drive
+// a canned reply locally to keep the panel demonstrable.
+function isMock(): boolean {
+ return tauriInvoke() === undefined;
+}
+
+// Chat is usable once the remote connection is live (or always, in the mock).
+function chatReady(): boolean {
+ return isMock() || remoteConnected;
+}
+
+// Untrusted agent markdown → HTML: markdown-it escapes raw HTML and blocks
+// dangerous link protocols; DOMPurify is the second layer (ADR: markdown-it +
+// DOMPurify). Only the agent-markdown body takes this — user text and the
+// panel's own chrome are escaped/trusted in `chat.ts`.
+function renderAgentBody(text: string): string {
+ return DOMPurify.sanitize(mdToHtml(text));
+}
+
+function renderChat(): void {
+ if (!chatLogEl) return;
+ chatLogEl.innerHTML = transcriptHtml(chatTurns, renderAgentBody);
+ chatLogEl.scrollTop = chatLogEl.scrollHeight; // keep the latest turn in view
+}
+
+function updateChatControls(): void {
+ const ready = chatReady();
+ if (chatSendEl) chatSendEl.disabled = !ready;
+ if (chatTextEl) chatTextEl.disabled = !ready;
+ if (chatStopEl) chatStopEl.hidden = !turnActive;
+ if (chatConnEl) {
+ const label = !ready
+ ? "activate the remote connection to chat"
+ : turnActive
+ ? "agent is responding…"
+ : "connected";
+ chatConnEl.textContent = label;
+ chatConnEl.classList.toggle("is-connected", ready && !turnActive);
+ chatConnEl.classList.toggle("is-error", false);
+ }
+}
+
+// Enqueue a prompt and try to release it. `flushQueue` is the single choke point
+// that enforces one-turn-at-a-time; typing mid-turn just grows the queue.
+function submitPrompt(text: string): void {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ promptQueue.push(trimmed);
+ void flushQueue();
+}
+
+async function flushQueue(): Promise