diff --git a/web/src/api.ts b/web/src/api.ts index a5b63a8..64c9bca 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -11,6 +11,8 @@ import type { Projects, RecentDirs, Session, + StructuredApprovalDecision, + StructuredPromptRequest, Uuid, } from "./types.js"; @@ -152,4 +154,44 @@ export async function fetchBranches(path: string): Promise { return jsonOrError(await fetch(url, { credentials: "same-origin" })); } -export { eventsWsUrl } from "./routes.js"; +/** Send a prompt to a structured session's ACP turn (#138). `409 + * turn_in_flight` when one is already active — the caller reflects that from + * `ReducedState.turn` instead of racing this call. */ +export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise { + await jsonOrError( + await fetch(routes.structuredPrompt(id), { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt } satisfies StructuredPromptRequest), + }), + ); +} + +/** Cancel a structured session's active ACP turn (#138). */ +export async function cancelStructuredTurn(id: Uuid): Promise { + await jsonOrError( + await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }), + ); +} + +/** Answer a pending ACP permission request (#139). Always resolves once the + * session itself is a live structured session — a nonce that is unknown or + * already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two + * clients racing the same approval never see this reject. */ +export async function resolveStructuredApproval( + id: Uuid, + nonce: string, + decision: StructuredApprovalDecision, +): Promise { + await jsonOrError( + await fetch(routes.structuredApproval(id, nonce), { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(decision), + }), + ); +} + +export { eventsWsUrl, structuredWsUrl } from "./routes.js"; diff --git a/web/src/attach.ts b/web/src/attach.ts index 948d633..820914c 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -4,14 +4,16 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; -import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { requestToggleDrawer } from "./drawer.js"; -import { splitCommand, toolIconSvg } from "./toolIcon.js"; +import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js"; +import type { PaneConnection } from "./pane.js"; import type { ClientControl, Uuid } from "./types.js"; import { WebglPolicy } from "./webglRenderer.js"; +export type { AttachInfo } from "./paneTitle.js"; + /** One budget for the whole page — see webglRenderer.ts for why contexts are * rationed rather than handed to every connection. */ const webglPolicy = new WebglPolicy(); @@ -27,25 +29,7 @@ const webglPolicy = new WebglPolicy(); * session ending) tears the socket down — so switching away and back is * instant, with scrollback already in place. */ -export interface AttachConnection { - readonly sessionId: Uuid; - /** The `.attach` element (header + terminal body) to mount into a pane. */ - readonly el: HTMLElement; - /** Becomes the visible view: fit to the pane, focus, sync size. - * `focus: false` mounts without taking the keyboard — what a sidebar - * preview needs, so j/k keeps working while the pane is on screen. */ - onMount(opts?: { focus?: boolean }): void; - /** Parked into the cache: keep streaming, just stop driving focus/resize. */ - onUnmount(): void; - /** Put the keyboard back into this terminal — what leaving the sidebar does. */ - focusTerminal(): void; - /** Type raw bytes straight into the PTY, bypassing the terminal's own key - * handling — the leader's literal Ctrl-K escape hatch - * (docs/COMMAND-BAR.md). */ - sendInput(data: string): void; - /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ - dispose(): void; -} +export interface AttachConnection extends PaneConnection {} export interface AttachOptions { /** Invoked when the user clicks the view's explicit "disconnect" control. */ @@ -59,23 +43,6 @@ export interface AttachOptions { shouldReconnect?: (sessionId: Uuid, code: number) => boolean; } -/** What the header shows about the session (name / command). Optional — falls - * back to a short session id when the session isn't known yet. */ -export interface AttachInfo { - name: string; - command: string; -} - -/** Header echoes the sidebar: the program becomes its tool icon when we have a - * mark for it and stays as its bare name when we do not. Arguments are - * dropped, as on the card. */ -function commandFragment(command: string): string { - const { tool, program } = splitCommand(command); - const icon = tool ? toolIconSvg(tool) : null; - const text = icon ? "" : program; - return `${icon ?? ""}${text ? escapeHtml(text) : ""}`; -} - export function createAttachConnection( sessionId: Uuid, opts: AttachOptions = {}, @@ -85,15 +52,12 @@ export function createAttachConnection( el.className = "attach"; // Useful identity over a bare UUID: name · command. The full id stays in the // tooltip for when you really need it. - const titleHtml = info - ? `${escapeHtml(info.name)}` + - (info.command && info.command !== info.name ? commandFragment(info.command) : "") - : `session ${escapeHtml(sessionId.slice(0, 8))}`; - const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; + const titleHtml = paneTitleHtml(sessionId, info); + const titleAttr = paneTitleAttr(sessionId, info); el.innerHTML = `
- ${titleHtml} + ${titleHtml} connecting…
diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts index 9464504..5d3aff8 100644 --- a/web/src/connections.test.ts +++ b/web/src/connections.test.ts @@ -2,28 +2,83 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectionPool } from "./connections.js"; +import { CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; // The pane's own bytes-out path, stubbed: the escape hatch only needs to know // the pool reaches the focused connection with them. const sent: { id: string; data: string }[] = []; -vi.mock("./attach.js", () => ({ - createAttachConnection: (id: string) => ({ +const disposed: string[] = []; +const mounts: { id: string; focus: boolean | undefined }[] = []; +function fakeConn(kind: string, id: string) { + const el = document.createElement("div"); + el.tabIndex = -1; // focusable via .focus(), like a real input/terminal + el.dataset.kind = kind; + el.dataset.session = id; + return { sessionId: id, - el: document.createElement("div"), - onMount() {}, + kind, + el, + onMount(opts?: { focus?: boolean }) { + mounts.push({ id, focus: opts?.focus }); + }, onUnmount() {}, focusTerminal() {}, sendInput(data: string) { sent.push({ id, data }); }, - dispose() {}, - }), + dispose() { + disposed.push(`${kind}:${id}`); + }, + }; +} +vi.mock("./attach.js", () => ({ + createAttachConnection: (id: string) => fakeConn("attach", id), +})); +vi.mock("./structured.js", () => ({ + createStructuredConnection: (id: string) => fakeConn("structured", id), })); afterEach(() => { vi.unstubAllGlobals(); + sent.length = 0; + disposed.length = 0; + mounts.length = 0; }); +/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */ +function session(id: string, view?: "Terminal" | "Structured"): Session { + return { + id, + cwd: "/repo", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + view, + }; +} + +/** A `CatalogStore` an id can be pushed into on demand, mirroring + * events.test.ts's own injectable-socket pattern. */ +class FakeCatalogSocket { + onmessage: ((ev: { data: string }) => void) | null = null; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + close(): void {} + push(sessions: Session[]): void { + this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) }); + } +} +function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } { + const socket = new FakeCatalogSocket(); + const store = new CatalogStore({ socket: () => socket as unknown as WebSocket }); + store.start(); + return { store, socket }; +} + const A = "aaaaaaaa-0000-0000-0000-0000000000ff"; const B = "bbbbbbbb-0000-0000-0000-0000000000ff"; @@ -125,3 +180,203 @@ describe("ConnectionPool.sendToFocused", () => { expect(sent).toEqual([]); }); }); + +// A deep link or restored layout can `mount()` a session before the catalog +// snapshot names its view — the daemon defaults an absent `view` to +// `"Terminal"`, but so does an *unknown* session, and only one of those is +// actually resolved (#140 review). +describe("ConnectionPool: view resolution races", () => { + it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + + pool.mount(A, pane); // no info: deep-link-before-snapshot race + expect(disposed).toEqual([]); + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + // The fresh connection landed in the same pane nothing re-mounted it into. + expect(pane.children).toHaveLength(1); + }); + + it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache + pool.mount(A, document.createElement("div")); + pool.mount(B, document.createElement("div")); + pool.unmount(A); + pool.unmount(B); + disposed.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + // B's own element is still parked — A's repair didn't wipe its siblings + // out of the shared hidden cache. + expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull(); + expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured"); + }); + + it("does not replace a connection whose resolved view already matches", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" }); + disposed.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual([]); + }); + + it("does not downgrade an already-resolved connection when a later mount() call has no info", () => { + mockFetch(); + const pool = new ConnectionPool(); + const pane = document.createElement("div"); + pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" }); + disposed.length = 0; + + pool.mount(A, pane); // e.g. a re-render before the catalog answers again + + expect(disposed).toEqual([]); + }); + + it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" }); + disposed.length = 0; + + // `A` is absent from this snapshot — main.ts's own subscription handles + // closing the pane; the pool must not race it by "fixing" the view first. + socket.push([session(B, "Terminal")]); + + expect(disposed).toEqual([]); + }); + + it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + document.body.appendChild(pane); + pool.mount(A, pane); + pool.setFocus(A); + (pane.firstElementChild as HTMLElement).focus(); + expect(document.activeElement).toBe(pane.firstElementChild); + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + expect(mounts).toEqual([{ id: A, focus: true }]); + pane.remove(); + }); + + it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => { + // docs/PREVIEW.md: previewing a session already open in the current tab + // moves the keyboard into the sidebar without ever calling setFocus, so + // `focusedId` keeps naming the pane the operator was in before — but the + // DOM disagrees, and the DOM is what must decide. + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + document.body.appendChild(pane); + pool.mount(A, pane); + pool.setFocus(A); + (pane.firstElementChild as HTMLElement).focus(); + + const sidebarInput = document.createElement("input"); + document.body.appendChild(sidebarInput); + sidebarInput.focus(); + expect(document.activeElement).toBe(sidebarInput); + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + expect(mounts).toEqual([{ id: A, focus: false }]); + pane.remove(); + sidebarInput.remove(); + }); + + it("counts focus on a descendant of the pane's element, not just the element itself", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + document.body.appendChild(pane); + pool.mount(A, pane); + pool.setFocus(A); + const textarea = document.createElement("textarea"); + (pane.firstElementChild as HTMLElement).appendChild(textarea); + textarea.focus(); + expect(document.activeElement).toBe(textarea); + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(mounts).toEqual([{ id: A, focus: true }]); + pane.remove(); + }); + + it("treats document.body (nothing meaningfully focused) as no active descendant", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + document.body.appendChild(pane); + pool.mount(A, pane); + pool.setFocus(A); + (document.activeElement as HTMLElement | null)?.blur(); + expect(document.activeElement).toBe(document.body); + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(mounts).toEqual([{ id: A, focus: false }]); + pane.remove(); + }); + + it("does not steal focus for a merely-visible pane that was never actually focused", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const paneA = document.createElement("div"); + const paneB = document.createElement("div"); + pool.mount(A, paneA); + pool.mount(B, paneB); + pool.setFocus(B); // B is the real keyboard focus; A is only visible + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + expect(mounts).toEqual([{ id: A, focus: false }]); + }); + + it("does not focus a parked (unmounted) connection it repairs", () => { + mockFetch(); + const { store, socket } = fakeStore(); + const pool = new ConnectionPool({ store }); + const pane = document.createElement("div"); + pool.mount(A, pane); + pool.setFocus(A); + pool.unmount(A); // parked — no longer visible, `focusedId` still names it + mounts.length = 0; + + socket.push([session(A, "Structured")]); + + expect(disposed).toEqual(["attach:" + A]); + expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index 1f17afe..6ca8870 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -1,10 +1,10 @@ -import { - createAttachConnection, - type AttachConnection, - type AttachInfo, -} from "./attach.js"; +import { createAttachConnection } from "./attach.js"; +import type { AttachInfo } from "./paneTitle.js"; +import type { PaneConnection } from "./pane.js"; +import { createStructuredConnection } from "./structured.js"; import { setSessionFocus } from "./api.js"; -import type { Uuid } from "./types.js"; +import type { CatalogState, CatalogStore } from "./events.js"; +import { sessionName, type SessionView, type Uuid } from "./types.js"; /** * How many *parked* (off-screen but still-connected) sessions to keep warm @@ -16,6 +16,15 @@ import type { Uuid } from "./types.js"; */ const MAX_PARKED = 6; +/** A session's view defaults to `"Terminal"` wherever it isn't carried — + * matching the wire default (`SessionView`'s `#[default]`, types.ts). Only + * meaningful once the session itself is known; an altogether-unknown + * session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved + * state callers must check for separately, not "Terminal". */ +function resolveView(view: SessionView | undefined): SessionView { + return view ?? "Terminal"; +} + export interface PoolOptions { /** Fired when the focused session changes (drives sidebar highlight + HUD). */ onFocusChange?: (id: Uuid | null) => void; @@ -27,6 +36,9 @@ export interface PoolOptions { onClosed?: (id: Uuid) => void; /** Forwarded to each connection: should a dropped socket be retried? */ shouldReconnect?: (id: Uuid, code: number) => boolean; + /** Threaded through to a structured connection so its rail can read live + * worktree/git-status data (#140). Terminal connections don't need it. */ + store?: CatalogStore; } /** @@ -40,12 +52,19 @@ export interface PoolOptions { */ export class ConnectionPool { /** All live connections. Insertion order doubles as LRU: oldest first. */ - private readonly cache = new Map(); + private readonly cache = new Map(); + /** The view each cached connection was actually created for — a deep link + * or a restored layout can `mount()` a session before the catalog + * snapshot names its view, defaulting it to `"Terminal"`; this is what + * lets a later reconciliation notice the resolved view disagrees (#140 + * review). */ + private readonly connViews = new Map(); /** Sessions currently mounted into a visible pane (never evicted). */ private readonly mounted = new Set(); private readonly hidden: HTMLElement; private focusedId: Uuid | null = null; private readonly opts: PoolOptions; + private readonly unsubscribeStore?: () => void; constructor(opts: PoolOptions = {}) { this.opts = opts; @@ -54,6 +73,11 @@ export class ConnectionPool { this.hidden.className = "conn-cache"; this.hidden.setAttribute("aria-hidden", "true"); document.body.appendChild(this.hidden); + // A session `mount()`ed before its catalog entry exists gets a + // `"Terminal"` connection by default; once the snapshot actually names + // the view, fix any connection that guessed wrong — nothing else + // re-`mount()`s an already-open pane just because the catalog updated. + this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state)); } /** The focused session, or null when nothing is focused (welcome pane). */ @@ -77,18 +101,18 @@ export class ConnectionPool { mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void { let conn = this.cache.get(id); if (conn) { - this.cache.delete(id); - this.cache.set(id, conn); + // `info === undefined` means "session not known yet", not "Terminal" — + // never downgrade a resolved connection just because a later caller + // couldn't resolve the session. Only a *resolved* view that disagrees + // with what this connection was built for forces a replace. + if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) { + conn = this.replace(id, info); + } else { + this.cache.delete(id); + this.cache.set(id, conn); // LRU refresh + } } else { - conn = createAttachConnection( - id, - { - onRequestClose: (sid) => this.opts.onRequestClose?.(sid), - onClosed: (sid) => this.opts.onClosed?.(sid), - shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false, - }, - info, - ); + conn = this.create(id, info); this.cache.set(id, conn); } if (conn.el.parentElement !== pane) { @@ -101,6 +125,71 @@ export class ConnectionPool { this.evict(); } + private create(id: Uuid, info?: AttachInfo): PaneConnection { + const view = resolveView(info?.view); + this.connViews.set(id, view); + const connOpts = { + onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid), + onClosed: (sid: Uuid) => this.opts.onClosed?.(sid), + shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false, + }; + return view === "Structured" + ? createStructuredConnection(id, connOpts, info, this.opts.store) + : createAttachConnection(id, connOpts, info); + } + + /** Dispose whatever is cached for `id` and cache a freshly created + * connection in its place. Does not touch the DOM or `mounted` — callers + * place the new element themselves. */ + private replace(id: Uuid, info?: AttachInfo): PaneConnection { + this.cache.get(id)?.dispose(); + const fresh = this.create(id, info); + this.cache.set(id, fresh); + return fresh; + } + + /** Proactively fix any cached connection whose view no longer matches the + * catalog — the only path that repairs a pane nothing will `mount()` + * again on its own (parked, or mounted but not currently being + * re-rendered by anything else). A session absent from `state` (not yet + * known, or removed) is left alone — that's `main.ts`'s + * `closePane`/eviction concern, not a view mismatch. */ + private reconcileViews(state: CatalogState): void { + for (const id of [...this.cache.keys()]) { + const session = state.sessions.get(id); + if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue; + const existing = this.cache.get(id)!; + const parent = existing.el.parentElement; + const wasMounted = this.mounted.has(id); + // Capture before anything below touches the DOM: dispose()/el.remove() + // detach `existing.el`, and once a focused descendant is detached the + // browser moves `document.activeElement` to `` — too late to + // tell whether the keyboard was actually in this pane. + const hadDomFocus = existing.el.contains(document.activeElement); + const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view }; + const fresh = this.replace(id, info); + existing.el.remove(); // don't rely on dispose() to clear the DOM + if (parent && parent !== this.hidden) { + parent.replaceChildren(fresh.el); + } else { + // Parked: `this.hidden` holds every parked connection's element as + // siblings — append, never replaceChildren, or they'd all vanish. + this.hidden.appendChild(fresh.el); + } + if (wasMounted) { + // `focusedId` names the session the pool was last told to focus — + // but a same-tab sidebar preview moves the keyboard into the + // sidebar without clearing it (multiview.ts skips `setFocus` while + // previewing), so `focusedId` alone can point at a pane the + // keyboard already left. Only re-focus when the DOM agrees it + // never did. + fresh.onMount({ focus: this.focusedId === id && hadDomFocus }); + } else { + fresh.onUnmount(); + } + } + } + /** Park `id` back into the hidden cache (still connected) and stop showing it. */ unmount(id: Uuid): void { if (!this.mounted.delete(id)) return; @@ -177,13 +266,16 @@ export class ConnectionPool { if (!conn) return; conn.dispose(); this.cache.delete(id); + this.connViews.delete(id); this.mounted.delete(id); if (this.focusedId === id) this.setFocus(null); } dispose(): void { + this.unsubscribeStore?.(); for (const conn of this.cache.values()) conn.dispose(); this.cache.clear(); + this.connViews.clear(); this.mounted.clear(); this.hidden.remove(); } diff --git a/web/src/main.ts b/web/src/main.ts index f8e5f74..ea3a83f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -172,6 +172,7 @@ const pool = new ConnectionPool({ onRequestClose: (id) => closePane(id), onClosed: (id) => closePane(id), shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions), + store, }); // Session death used to reach the UI on two uncoordinated channels — the row @@ -209,7 +210,7 @@ function scheduleSave(): void { function infoFor(sessionId: string) { const s = store.snapshot().sessions.get(sessionId); - return s ? { name: sessionName(s), command: s.command } : undefined; + return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined; } /** Drive the shell's responsive layout off whether any pane is open. */ diff --git a/web/src/pane.ts b/web/src/pane.ts new file mode 100644 index 0000000..c0c8ff6 --- /dev/null +++ b/web/src/pane.ts @@ -0,0 +1,26 @@ +// Shared shape a mounted pane connection satisfies, whatever session view is +// behind it — what ConnectionPool needs to mount/park/dispose a pane without +// caring whether it holds an xterm attach (attach.ts) or a structured +// transcript (structured.ts). + +import type { Uuid } from "./types.js"; + +export interface PaneConnection { + readonly sessionId: Uuid; + /** The element to mount into a pane. */ + readonly el: HTMLElement; + /** Becomes the visible view: fit/focus/sync as the connection needs. + * `focus: false` mounts without taking the keyboard (a sidebar preview). */ + onMount(opts?: { focus?: boolean }): void; + /** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */ + onUnmount(): void; + /** Put the keyboard into this pane's primary input — the terminal for an + * attach connection, the composer for a structured one. */ + focusTerminal(): void; + /** Raw bytes into a PTY, bypassing normal key handling (the leader's + * literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection + * has no PTY, so this is a no-op there. */ + sendInput(data: string): void; + /** Fully disconnect and remove the element. */ + dispose(): void; +} diff --git a/web/src/paneTitle.test.ts b/web/src/paneTitle.test.ts new file mode 100644 index 0000000..1017359 --- /dev/null +++ b/web/src/paneTitle.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js"; + +describe("paneTitleHtml", () => { + it("falls back to a short session id when there is no info yet", () => { + expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12"); + }); + + it("shows the name alone when command matches it", () => { + const html = paneTitleHtml("s1", { name: "claude", command: "claude" }); + expect(html).toContain("claude"); + expect(html).not.toContain("at-cmd"); + }); + + it("escapes a name that looks like markup", () => { + const html = paneTitleHtml("s1", { name: "", command: "claude" }); + expect(html).not.toContain(" { + it("escapes the attribute form too", () => { + const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" }); + expect(attr).not.toContain('"quoted"'); + expect(attr).toContain(""quoted""); + }); +}); diff --git a/web/src/paneTitle.ts b/web/src/paneTitle.ts new file mode 100644 index 0000000..f435b1d --- /dev/null +++ b/web/src/paneTitle.ts @@ -0,0 +1,36 @@ +// Pane header title: name/command echoing the sidebar card, shared by the +// xterm attach view (attach.ts) and the structured transcript view +// (structured.ts) so both panes read identically. + +import { escapeHtml } from "./html.js"; +import { splitCommand, toolIconSvg } from "./toolIcon.js"; +import type { SessionView, Uuid } from "./types.js"; + +/** What a pane header shows about its session. Falls back to a short session + * id when the session isn't known yet. */ +export interface AttachInfo { + name: string; + command: string; + /** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the + * xterm attach; `"Structured"` mounts the transcript view. */ + view?: SessionView; +} + +function commandFragment(command: string): string { + const { tool, program } = splitCommand(command); + const icon = tool ? toolIconSvg(tool) : null; + const text = icon ? "" : program; + return `${icon ?? ""}${text ? escapeHtml(text) : ""}`; +} + +export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string { + return info + ? `${escapeHtml(info.name)}` + + (info.command && info.command !== info.name ? commandFragment(info.command) : "") + : `session ${escapeHtml(sessionId.slice(0, 8))}`; +} + +/** Attribute-safe (`title="..."`) form of the same identity. */ +export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string { + return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId); +} diff --git a/web/src/routes.ts b/web/src/routes.ts index 090b388..ec567b7 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -37,6 +37,27 @@ export function sessionFocus(id: Uuid): string { return `${session(id)}/focus`; } +/** `WS /api/structured/:id` — transcript/state snapshots and live deltas. */ +export function structured(id: Uuid): string { + return `${API_PREFIX}/structured/${encodeURIComponent(id)}`; +} + +/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */ +export function structuredPrompt(id: Uuid): string { + return `${session(id)}/structured/prompt`; +} + +/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */ +export function structuredCancel(id: Uuid): string { + return `${session(id)}/structured/cancel`; +} + +/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission + * request. */ +export function structuredApproval(id: Uuid, nonce: string): string { + return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; @@ -55,3 +76,7 @@ export function eventsWsUrl(): string { export function attachWsUrl(id: Uuid): string { return `${wsBase()}${attach(id)}`; } + +export function structuredWsUrl(id: Uuid): string { + return `${wsBase()}${structured(id)}`; +} diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts new file mode 100644 index 0000000..e5faf7d --- /dev/null +++ b/web/src/structured.test.ts @@ -0,0 +1,488 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./api.js", async () => { + const actual = await vi.importActual("./api.js"); + return { + ...actual, + sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), + cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), + resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), + }; +}); + +import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js"; +import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; +import { CatalogStore } from "./events.js"; +import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js"; +import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js"; + +/** Just enough of a WebSocket for the connection to drive (mirrors + * events.test.ts's FakeSocket, adapted for JSON structured events). */ +class FakeSocket { + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((ev: { code: number; reason: string }) => void) | null = null; + closed = false; + + open(): void { + this.onopen?.(); + } + emit(event: StructuredEvent): void { + this.onmessage?.({ data: JSON.stringify(event) }); + } + drop(code = 1006, reason = ""): void { + this.onclose?.({ code, reason }); + } + close(): void { + this.closed = true; + } +} + +function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow { + return { id, kind, data }; +} + +const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } }; + +const allApprovalOptions = [ + { optionId: "a1", name: "Allow once", kind: "allow_once" as const }, + { optionId: "a2", name: "Allow always", kind: "allow_always" as const }, + { optionId: "r1", name: "Reject once", kind: "reject_once" as const }, +]; + +function mount(opts: { + sockets: FakeSocket[]; + shouldReconnect?: (id: string, code: number) => boolean; + store?: CatalogStore; +}) { + let i = 0; + const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket; + const conn = createStructuredConnection( + "s1", + { shouldReconnect: opts.shouldReconnect }, + { name: "s1", command: "claude", view: "Structured" }, + opts.store, + factory, + ); + document.body.appendChild(conn.el); + return conn; +} + +describe("structured connection: data flow", () => { + beforeEach(() => vi.clearAllMocks()); + + it("renders every row from the initial snapshot", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 2, + rows: [ + row("user:1", "user", { content: { type: "text", text: "hello" } }), + row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }), + ], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + expect(conn.el.textContent).toContain("hello"); + expect(conn.el.textContent).toContain("hi there"); + conn.dispose(); + }); + + it("appends and patches rows via projection_update without duplicating them", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + ws.emit({ + type: "projection_update", + transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) }, + state: idleState, + }); + ws.emit({ + type: "projection_update", + transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, + state: idleState, + }); + const rows = conn.el.querySelectorAll(".st-row-agent"); + expect(rows).toHaveLength(1); + expect(conn.el.textContent).toContain("partial done"); + expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it + conn.dispose(); + }); +}); + +describe("structured connection: turn/composer gating", () => { + beforeEach(() => vi.clearAllMocks()); + + it("disables the composer while a turn is active and re-enables when idle", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement; + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + expect(input.disabled).toBe(true); + expect(cancel.hidden).toBe(false); + ws.emit({ type: "reduced_state", state: idleState }); + expect(input.disabled).toBe(false); + expect(cancel.hidden).toBe(true); + conn.dispose(); + }); + + it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "do the thing"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"); + expect(input.value).toBe(""); + + vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); + input.value = "retry me"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + expect(input.value).toBe("retry me"); + expect(conn.el.textContent).toContain("boom"); + conn.dispose(); + }); + + it("cancels the active turn via the cancel button", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } }); + (conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click(); + expect(cancelStructuredTurn).toHaveBeenCalledWith("s1"); + conn.dispose(); + }); +}); + +describe("structured connection: approvals", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }), + }, + state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + const buttons = conn.el.querySelectorAll(".st-approval-acts button"); + expect(buttons).toHaveLength(3); + buttons[1].click(); // "Allow for session" + expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session"); + conn.dispose(); + }); + + it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] }, + }); + ws.emit({ + type: "reduced_state", + state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3); + // Resolved on another client: state clears with no transcript delta at all. + ws.emit({ type: "reduced_state", state: idleState }); + expect(conn.el.querySelector(".st-approval-acts")).toBeNull(); + expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull(); + conn.dispose(); + }); + + it("does not act on a stale click after the approval already moved on", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, + }); + ws.emit({ + type: "reduced_state", + state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + const buttons = conn.el.querySelectorAll(".st-approval-acts button"); + // Approval is superseded before the click is handled (buttons already + // disabled by the click handler is not the path under test here — this + // simulates the row having already flipped to "answered" underneath it). + ws.emit({ type: "reduced_state", state: idleState }); + buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(resolveStructuredApproval).not.toHaveBeenCalled(); + conn.dispose(); + }); + + it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => { + vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down")); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, + }); + ws.emit({ + type: "reduced_state", + state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + const buttons = conn.el.querySelectorAll(".st-approval-acts button"); + buttons[0].click(); + expect(buttons[0].disabled).toBe(true); + await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()); + expect(buttons[0].disabled).toBe(false); + expect(buttons[1].disabled).toBe(false); + expect(conn.el.textContent).toContain("network down"); + conn.dispose(); + }); + + it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => { + let reject!: (e: unknown) => void; + vi.mocked(resolveStructuredApproval).mockImplementationOnce( + () => new Promise((_, r) => { reject = r; }), + ); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, + }); + ws.emit({ + type: "reduced_state", + state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + const buttons = conn.el.querySelectorAll(".st-approval-acts button"); + buttons[0].click(); + expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once"); + + // Superseded by a new approval before the in-flight POST settles. + ws.emit({ + type: "projection_update", + transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) }, + state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, + }); + reject(new Error("too late")); + await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()); + + expect(conn.el.textContent).not.toContain("too late"); + const newButtons = conn.el.querySelectorAll(".st-approval-acts button"); + expect(newButtons).toHaveLength(3); + expect(newButtons[0].disabled).toBe(false); + conn.dispose(); + }); +}); + +describe("structured connection: reconnection/error/loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => { + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state; + expect(state()).toBe("connecting"); + + first.open(); + expect(state()).toBe("open"); + first.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] }, + }); + expect(conn.el.textContent).toContain("before"); + + first.drop(1006, ""); + expect(state()).toBe("connecting"); // "reconnecting…" + vi.advanceTimersByTime(1000); + + second.open(); + expect(state()).toBe("open"); + second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged + conn.dispose(); + }); + + it("reports a daemon rejection reason and does not schedule a retry", () => { + const only = new FakeSocket(); + const conn = mount({ sockets: [only], shouldReconnect: () => true }); + only.open(); + only.drop(4004, "session is not a live structured session"); + const stateEl = conn.el.querySelector(".attach-state") as HTMLElement; + expect(stateEl.dataset.state).toBe("error"); + expect(stateEl.textContent).toBe("session is not a live structured session"); + vi.advanceTimersByTime(60_000); + // No second socket was ever asked for — only one factory call happened. + conn.dispose(); + }); +}); + +describe("structured connection: pane lifecycle", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sendInput is a no-op — there is no PTY behind a structured session", () => { + const conn = mount({ sockets: [new FakeSocket()] }); + expect(() => conn.sendInput("\x0b")).not.toThrow(); + conn.dispose(); + }); + + it("carries the same off-canvas sessions button as the terminal attach header", () => { + const conn = mount({ sockets: [new FakeSocket()] }); + const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement; + expect(btn).not.toBeNull(); + expect(btn.getAttribute("aria-label")).toBe("Show sessions"); + expect(btn.type).toBe("button"); + + let toggled = 0; + const onToggle = () => toggled++; + document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle); + btn.click(); + document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle); + expect(toggled).toBe(1); + conn.dispose(); + }); + + it("focusTerminal focuses the composer only when it is enabled", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + conn.focusTerminal(); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(input); + ws.emit({ type: "reduced_state", state: idleState }); + conn.focusTerminal(); + expect(document.activeElement).toBe(input); + conn.dispose(); + }); + + it("dispose closes the socket and unsubscribes from the store", () => { + const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket }); + const unsubscribe = vi.spyOn(store, "subscribe"); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws], store }); + expect(unsubscribe).toHaveBeenCalled(); + conn.dispose(); + expect(ws.closed).toBe(true); + }); + + it("reflects live worktree/blocked-work data from the store in the rail", () => { + const catalogWs = new FakeSocket(); + const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket }); + store.start(); + const session: Session = { + id: "s1", + cwd: "/repo", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" }, + blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 }, + }; + catalogWs.emit({ type: "Snapshot", sessions: [session] } as never); + + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws], store }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + expect(conn.el.textContent).toContain("feat/x"); + conn.dispose(); + }); +}); + +describe("structured connection: deferred mount focus", () => { + let frames: FrameRequestCallback[]; + + beforeEach(() => { + vi.clearAllMocks(); + frames = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + frames.push(cb); + return frames.length; + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + function runFrames(): void { + const pending = frames; + frames = []; + for (const cb of pending) cb(0); + } + + it("does not focus the composer if the pane was parked before its deferred frame ran", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + + conn.onMount({ focus: true }); + conn.onUnmount(); // parked again before the frame below runs + runFrames(); + + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(input); + conn.dispose(); + }); + + it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + + conn.onMount({ focus: true }); // stale frame #1 + conn.onUnmount(); + conn.onMount({ focus: false }); // the pane that actually won focus, without it + runFrames(); // both frames fire; only the latest should have any say + + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(input); + conn.dispose(); + }); + + it("still focuses normally when nothing raced the frame", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + + conn.onMount({ focus: true }); + runFrames(); + + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + expect(document.activeElement).toBe(input); + conn.dispose(); + }); +}); diff --git a/web/src/structured.ts b/web/src/structured.ts new file mode 100644 index 0000000..994c176 --- /dev/null +++ b/web/src/structured.ts @@ -0,0 +1,399 @@ +// A single live structured session: a transcript + rail bound to the +// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract — +// decoupled from the UI, streaming whether or not mounted, torn down only by +// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect +// (fresh or reconnect) resends the *whole* transcript rather than resuming a +// cursor (docs/STRUCTURED-VIEW.md), so a snapshot always replaces state +// wholesale instead of being merged into what came before. + +import { + cancelStructuredTurn, + resolveStructuredApproval, + sendStructuredPrompt, + structuredWsUrl, +} from "./api.js"; +import { requestToggleDrawer } from "./drawer.js"; +import type { CatalogState, CatalogStore } from "./events.js"; +import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js"; +import type { PaneConnection } from "./pane.js"; +import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; +import { buildTranscriptRow, renderRail } from "./structuredRender.js"; +import type { + AcpPlan, + AcpUsage, + ReducedState, + Session, + StructuredApprovalDecision, + StructuredEvent, + TranscriptDelta, + TranscriptRow, + TranscriptRowKind, + Uuid, +} from "./types.js"; + +export interface StructuredOptions { + onRequestClose?: (sessionId: Uuid) => void; + onClosed?: (sessionId: Uuid) => void; + shouldReconnect?: (sessionId: Uuid, code: number) => boolean; +} + +/** Injectable so tests can drive the socket without a daemon — the same seam + * `CatalogStore` uses (events.ts). */ +export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket; + +const idleTurn = (): ReducedState["turn"] => ({ + responding: false, + thinking: false, + active_tool_calls: [], +}); + +export function createStructuredConnection( + sessionId: Uuid, + opts: StructuredOptions = {}, + info?: AttachInfo, + store?: CatalogStore, + socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)), +): PaneConnection { + const el = document.createElement("div"); + el.className = "attach structured"; + el.innerHTML = ` +
+ + ${paneTitleHtml(sessionId, info)} + + connecting… + +
+
+
+
+
+ + + +
+
+ +
+ `; + + const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; + const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement; + const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; + const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; + const railEl = el.querySelector(".structured-rail") as HTMLElement; + const bodyEl = el.querySelector(".structured-body") as HTMLElement; + const composerForm = el.querySelector(".structured-composer") as HTMLFormElement; + const input = el.querySelector(".structured-input") as HTMLTextAreaElement; + const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; + const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; + + disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId)); + // Mobile-only (CSS hides it on desktop, where the sidebar is always in + // view): the session list is an off-canvas drawer and this opens it — + // same control attach.ts's terminal header carries. + (el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () => + requestToggleDrawer(), + ); + + const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => { + stateEl.textContent = text; + stateEl.dataset.state = kind; + }; + + // ---- transcript state: mirrors the daemon's own Projection shape (id -> + // index, patched in place) so a Patch never needs to search the array. ---- + let rows: TranscriptRow[] = []; + const rowIndex = new Map(); + const rowEls = new Map(); + let state: ReducedState = { turn: idleTurn() }; + let pendingApprovalRowId: string | null = null; + let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId); + + function lastRowIdOfKind(kind: TranscriptRowKind): string | null { + for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; + return null; + } + + function approvalStateFor(row: TranscriptRow) { + if (row.kind !== "approval") return undefined; + return { + pending: row.id === pendingApprovalRowId, + onDecide: (decision: StructuredApprovalDecision) => decide(row.id, decision), + }; + } + + async function decide(rowId: string, decision: StructuredApprovalDecision): Promise { + const nonce = state.pending_approval?.nonce; + // Already resolved elsewhere between the click and here — the row's + // buttons should already be gone, but a stale nonce is a no-op, not an + // error (docs/STRUCTURED-VIEW.md: any nonce answers 204). Resolve + // quietly: there is nothing for the row to restore. + if (!nonce || rowId !== pendingApprovalRowId) return; + try { + await resolveStructuredApproval(sessionId, nonce, decision); + } catch (e: unknown) { + // Only propagate the failure — so the row re-enables its buttons — if + // this is still the pending approval. If it moved on while the + // request was in flight (superseded, cancelled, answered elsewhere), + // the state-driven re-render already replaced the row; reviving + // buttons on it would be reviving a decision that no longer applies. + if (rowId === pendingApprovalRowId) { + showError(String(e)); + throw e; + } + } + } + + function mountRow(row: TranscriptRow): void { + const el = buildTranscriptRow(row, approvalStateFor(row)); + rowEls.set(row.id, el); + rowsEl.appendChild(el); + } + + function remountRow(row: TranscriptRow): void { + const old = rowEls.get(row.id); + const el = buildTranscriptRow(row, approvalStateFor(row)); + rowEls.set(row.id, el); + if (old) old.replaceWith(el); + else rowsEl.appendChild(el); + } + + function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { + rows = [...snapshot.rows]; + rowIndex.clear(); + rows.forEach((row, i) => rowIndex.set(row.id, i)); + rowEls.clear(); + rowsEl.replaceChildren(); + for (const row of rows) mountRow(row); + } + + function applyDelta(delta: TranscriptDelta | null | undefined): void { + if (!delta) return; + if (delta.type === "append") { + rowIndex.set(delta.row.id, rows.length); + rows.push(delta.row); + mountRow(delta.row); + } else if (delta.type === "patch") { + const i = rowIndex.get(delta.row.id); + if (i === undefined) { + rowIndex.set(delta.row.id, rows.length); + rows.push(delta.row); + mountRow(delta.row); + } else { + rows[i] = delta.row; + remountRow(delta.row); + } + } else { + const i = rowIndex.get(delta.id); + if (i === undefined) return; + rows.splice(i, 1); + rowIndex.clear(); + rows.forEach((row, idx) => rowIndex.set(row.id, idx)); + rowEls.get(delta.id)?.remove(); + rowEls.delete(delta.id); + } + } + + function toolRowCount(): number { + return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0); + } + + function renderPills(): void { + const parts: string[] = []; + if (state.model) parts.push(state.model); + if (state.mode) parts.push(state.mode); + pillsEl.textContent = parts.join(" · "); + } + + function renderRailNow(): void { + renderRail(railEl, { + plan: (state.plan ?? null) as AcpPlan | null, + turn: state.turn, + toolRowCount: toolRowCount(), + usage: (state.usage ?? null) as AcpUsage | null, + session: latestSession, + }); + } + + function renderComposerState(): void { + const active = state.turn.responding || state.turn.thinking; + input.disabled = active; + sendBtn.disabled = active; + cancelBtn.hidden = !active; + } + + function applyState(next: ReducedState): void { + state = next; + const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; + if (newPendingId !== pendingApprovalRowId) { + const prev = pendingApprovalRowId; + pendingApprovalRowId = newPendingId; + if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]); + if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]); + } + renderPills(); + renderRailNow(); + renderComposerState(); + } + + // ---- error banner ---- + let errorEl: HTMLElement | null = null; + function showError(message: string): void { + if (!errorEl) { + errorEl = document.createElement("div"); + errorEl.className = "structured-error"; + composerForm.insertAdjacentElement("beforebegin", errorEl); + } + errorEl.textContent = message; + } + function clearError(): void { + errorEl?.remove(); + errorEl = null; + } + + // ---- composer ---- + function autoResize(): void { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + } + input.addEventListener("input", autoResize); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + composerForm.requestSubmit(); + } + }); + composerForm.addEventListener("submit", (e) => { + e.preventDefault(); + const text = input.value; + if (!text.trim() || input.disabled) return; + input.value = ""; + autoResize(); + clearError(); + sendStructuredPrompt(sessionId, text).catch((e: unknown) => { + input.value = text; + autoResize(); + showError(String(e)); + }); + }); + cancelBtn.addEventListener("click", () => { + clearError(); + cancelStructuredTurn(sessionId).catch((e: unknown) => showError(String(e))); + }); + + // ---- narrow-pane rail collapse (a split pane, not just the viewport) ---- + const NARROW_BREAKPOINT = 560; + const resizeObserver = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth; + bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT); + }); + resizeObserver.observe(bodyEl); + + // ---- live worktree/git-status rail data ---- + const unsubscribeStore = store?.subscribe((catalog: CatalogState) => { + latestSession = catalog.sessions.get(sessionId); + renderRailNow(); + }); + + // ---- socket lifecycle ---- + let ws: WebSocket; + let reconnectMs = INITIAL_RECONNECT_MS; + let reconnectTimer: number | null = null; + let disposed = false; + + function connect(): void { + ws = socketFactory(sessionId); + ws.onopen = () => { + reconnectMs = INITIAL_RECONNECT_MS; + setConnState("connected", "open"); + }; + ws.onmessage = (ev) => { + if (typeof ev.data !== "string") return; + let msg: StructuredEvent; + try { + msg = JSON.parse(ev.data) as StructuredEvent; + } catch { + return; // not a structured event we understand + } + switch (msg.type) { + case "transcript_snapshot": + applySnapshot(msg.snapshot); + break; + case "reduced_state": + applyState(msg.state); + break; + case "projection_update": + applyDelta(msg.transcript); + applyState(msg.state); + break; + } + }; + ws.onerror = () => setConnState("error", "error"); + ws.onclose = (ev) => { + const reason = ev.reason?.trim(); + if (ev.code >= 4000 && reason && !disposed) { + setConnState(reason, "error"); + showError(`cannot attach: ${reason}`); + return; + } + if (disposed) return; + if (opts.shouldReconnect?.(sessionId, ev.code)) { + const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); + setConnState("reconnecting…", "connecting"); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); + connect(); + }, delay); + return; + } + setConnState(`closed (${ev.code})`, "closed"); + opts.onClosed?.(sessionId); + }; + } + connect(); + + // A pane can be parked again before its deferred focus frame fires (a + // preview stepped past it, a split closed it) — the frame must not focus a + // hidden textarea out from under whatever the operator moved to since. + // `mountToken` is bumped on every mount/unmount so a stale frame can tell + // it is no longer the most recent one. + let mountToken = 0; + + return { + sessionId, + el, + onMount(mountOpts?: { focus?: boolean }) { + const focusOnMount = mountOpts?.focus !== false; + const token = ++mountToken; + requestAnimationFrame(() => { + if (token !== mountToken) return; // parked/remounted since this was scheduled + if (focusOnMount && !input.disabled) input.focus(); + }); + }, + onUnmount() { + mountToken++; + }, + focusTerminal() { + if (!input.disabled) input.focus(); + }, + sendInput() { + // No PTY behind a structured session — the leader's literal-byte + // escape hatch has nothing to do here. + }, + dispose() { + if (disposed) return; + disposed = true; + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + resizeObserver.disconnect(); + unsubscribeStore?.(); + try { + ws.close(); + } catch { + // ignore + } + el.remove(); + }, + }; +} diff --git a/web/src/structuredDiff.test.ts b/web/src/structuredDiff.test.ts new file mode 100644 index 0000000..2a53480 --- /dev/null +++ b/web/src/structuredDiff.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js"; + +function texts(lines: DiffLine[]): Array<[string, string]> { + return lines.map((l) => [l.kind, l.text]); +} + +describe("computeDiffLines", () => { + it("renders a brand new file (no oldText) as all additions", () => { + const lines = computeDiffLines(null, "a\nb\n"); + expect(texts(lines)).toEqual([ + ["add", "a"], + ["add", "b"], + ]); + }); + + it("renders a full deletion as all removals", () => { + const lines = computeDiffLines("a\nb\n", ""); + expect(texts(lines)).toEqual([ + ["del", "a"], + ["del", "b"], + ]); + }); + + it("shows a small replace as context around del/add", () => { + const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n"); + expect(texts(lines)).toEqual([ + ["ctx", "one"], + ["del", "two"], + ["add", "twx"], + ["ctx", "three"], + ]); + }); + + it("leaves identical text as pure context, uncollapsed when short", () => { + const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n"); + expect(texts(lines)).toEqual([ + ["ctx", "a"], + ["ctx", "b"], + ["ctx", "c"], + ]); + }); + + it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => { + const common = Array.from({ length: 20 }, (_, i) => `line${i}`); + const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n"); + const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n"); + const lines = computeDiffLines(oldText, newText); + // The leading run keeps only its last 3 lines (adjacent to the change), + // behind a marker for the rest; the trailing run mirrors that. + expect(lines[0].kind).toBe("hunk"); + expect(lines[1]).toEqual({ kind: "ctx", text: "line7" }); + expect(lines[2]).toEqual({ kind: "ctx", text: "line8" }); + expect(lines[3]).toEqual({ kind: "ctx", text: "line9" }); + expect(lines[4].kind).toBe("del"); + expect(lines[5].kind).toBe("add"); + expect(lines[6]).toEqual({ kind: "ctx", text: "line10" }); + expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2); + }); + + it("produces one hunk for the long middle run, none for the short edges", () => { + // Edge runs (2 lines) fit within their one-sided 3-line context budget; + // only the interior run (10 lines, budgeted 3+3=6) needs to collapse. + const middle = Array.from({ length: 10 }, (_, i) => `c${i}`); + const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n"); + const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n"); + const lines = computeDiffLines(oldText, newText); + const dels = lines.filter((l) => l.kind === "del").map((l) => l.text); + const adds = lines.filter((l) => l.kind === "add").map((l) => l.text); + expect(dels).toEqual(["OLD1", "OLD2"]); + expect(adds).toEqual(["NEW1", "NEW2"]); + expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1); + expect(texts(lines).slice(0, 2)).toEqual([ + ["ctx", "lead0"], + ["ctx", "lead1"], + ]); + expect(texts(lines).slice(-2)).toEqual([ + ["ctx", "trail0"], + ["ctx", "trail1"], + ]); + }); + + it("handles an old file with no trailing newline", () => { + const lines = computeDiffLines("a\nb", "a\nb\nc"); + expect(texts(lines)).toEqual([ + ["ctx", "a"], + ["ctx", "b"], + ["add", "c"], + ]); + }); +}); + +describe("diffStats", () => { + it("counts add/del lines, ignoring context and hunk markers", () => { + const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n"); + expect(diffStats(lines)).toEqual({ added: 2, removed: 1 }); + }); + + it("is zero for identical text", () => { + expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 }); + }); +}); diff --git a/web/src/structuredDiff.ts b/web/src/structuredDiff.ts new file mode 100644 index 0000000..b681c24 --- /dev/null +++ b/web/src/structuredDiff.ts @@ -0,0 +1,141 @@ +// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP +// hands over full `oldText`/`newText`, not a unified-diff patch, so the +// coloured hunks the design calls for have to be computed here. + +export type DiffLineKind = "add" | "del" | "ctx" | "hunk"; + +export interface DiffLine { + kind: DiffLineKind; + /** The line's text (a `hunk` line is a human summary, not source text). */ + text: string; +} + +/** Interior unchanged runs longer than this collapse to a summary line + * flanked by this many lines of real context on each side. */ +const CONTEXT = 3; + +/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is + * shown as one coarse del-then-add block instead of a fine-grained diff — + * still correct, just not minimal. */ +const LCS_CELL_LIMIT = 250_000; + +function splitLines(text: string): string[] { + if (text === "") return []; + const body = text.endsWith("\n") ? text.slice(0, -1) : text; + return body.split("\n"); +} + +type Op = { kind: "add" | "del" | "ctx"; text: string }; + +/** Longest-common-subsequence diff of two line arrays, already trimmed of any + * shared prefix/suffix by the caller. */ +function lcsDiff(a: string[], b: string[]): Op[] { + const n = a.length; + const m = b.length; + if (n === 0) return b.map((text) => ({ kind: "add", text })); + if (m === 0) return a.map((text) => ({ kind: "del", text })); + if (n * m > LCS_CELL_LIMIT) { + return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))]; + } + // dp[i][j] = length of the LCS of a[i:] and b[j:]. + const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = + a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const ops: Op[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ kind: "ctx", text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ kind: "del", text: a[i] }); + i++; + } else { + ops.push({ kind: "add", text: b[j] }); + j++; + } + } + while (i < n) ops.push({ kind: "del", text: a[i++] }); + while (j < m) ops.push({ kind: "add", text: b[j++] }); + return ops; +} + +function diffLines(a: string[], b: string[]): Op[] { + let pre = 0; + while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++; + let suf = 0; + while ( + suf < a.length - pre && + suf < b.length - pre && + a[a.length - 1 - suf] === b[b.length - 1 - suf] + ) { + suf++; + } + const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text })); + const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text })); + const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)); + return [...prefixCtx, ...middle, ...suffixCtx]; +} + +/** Collapse a long run of unchanged lines to a summary, keeping up to + * {@link CONTEXT} real lines adjacent to each neighbouring change — same + * shape a unified diff gives a reader, without claiming line numbers this + * module never tracked. A run touching the start/end of the whole diff has + * no change to be adjacent to on that side, so it keeps no context there + * (unless it is the *only* run, i.e. nothing changed at all). */ +function hunk(ops: Op[]): DiffLine[] { + const out: DiffLine[] = []; + let i = 0; + while (i < ops.length) { + if (ops[i].kind !== "ctx") { + out.push(ops[i]); + i++; + continue; + } + let j = i; + while (j < ops.length && ops[j].kind === "ctx") j++; + const run = ops.slice(i, j); + const wholeDiff = i === 0 && j === ops.length; + const keepHead = wholeDiff || i > 0 ? CONTEXT : 0; + const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0; + if (run.length <= keepHead + keepTail) { + out.push(...run); + } else { + if (keepHead) out.push(...run.slice(0, keepHead)); + out.push({ + kind: "hunk", + text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`, + }); + if (keepTail) out.push(...run.slice(run.length - keepTail)); + } + i = j; + } + return out; +} + +/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent + * (a new file) renders every line of `newText` as added. */ +export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] { + const newLines = splitLines(newText); + if (oldText == null) return newLines.map((text) => ({ kind: "add", text })); + const ops = diffLines(splitLines(oldText), newLines); + return hunk(ops); +} + +/** `+added -removed` counts straight off the diff — real derived data, not a + * fabricated duration or line count. */ +export function diffStats(lines: DiffLine[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const line of lines) { + if (line.kind === "add") added++; + else if (line.kind === "del") removed++; + } + return { added, removed }; +} diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts new file mode 100644 index 0000000..1aa406b --- /dev/null +++ b/web/src/structuredRender.test.ts @@ -0,0 +1,368 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { + branchFromWorktreePath, + buildTranscriptRow, + renderInlineMarkdown, + renderRail, +} from "./structuredRender.js"; +import type { Session, TranscriptRow } from "./types.js"; + +function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow { + return { id, kind, data }; +} + +describe("buildTranscriptRow: user/agent/thought", () => { + it("renders a user row with the text content and no markdown", () => { + const el = buildTranscriptRow( + row("user", { content: { type: "text", text: "hi there" } }), + ); + expect(el.className).toContain("st-row-user"); + expect(el.textContent).toContain("hi there"); + // never actual markup from user input + expect(el.querySelector("b")).toBeNull(); + }); + + it("renders agent prose as paragraphs with inline markdown", () => { + const el = buildTranscriptRow( + row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }), + ); + expect(el.className).toContain("st-row-agent"); + const ps = el.querySelectorAll("p"); + expect(ps).toHaveLength(2); + expect(ps[0].innerHTML).toContain("two"); + expect(ps[0].innerHTML).toContain("three"); + }); + + it("renders a thought row as plain text", () => { + const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } })); + expect(el.className).toContain("st-row-thought"); + expect(el.textContent).toBe("hmm"); + }); + + it("falls back to a labelled placeholder for non-text content, never crashing", () => { + const el = buildTranscriptRow(row("user", { content: { type: "image" } })); + expect(el.textContent).toContain("[image]"); + }); + + it("never clips text: a very long user message is not truncated", () => { + const long = "x".repeat(5000); + const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } })); + expect(el.textContent).toContain(long); + }); +}); + +describe("renderInlineMarkdown", () => { + it("escapes raw HTML before applying markdown", () => { + expect(renderInlineMarkdown("")).not.toContain("