From 31b429a840b6c533c49ab3ec719634559c96009e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 10:29:40 +0100 Subject: [PATCH] fix(web): preserve expanded structured cards --- web/src/structured.test.ts | 79 +++++++++++++++++++++++++++++++++++++ web/src/structured.ts | 28 +++++++++++-- web/src/structuredRender.ts | 12 ++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 336edb8..0ece89c 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -44,6 +44,22 @@ function row(id: string, kind: TranscriptRow["kind"], data: unknown): Transcript return { id, kind, data }; } +function toolRow(id: string, text: string): TranscriptRow { + return row(id, "tool_call", { + title: `Read(${id})`, + status: "completed", + content: [{ type: "content", content: { type: "text", text } }], + }); +} + +function diffRow(id: string, newText: string): TranscriptRow { + return row(id, "tool_diff", { + title: `Edit(${id})`, + status: "completed", + content: [{ type: "diff", path: "file.ts", oldText: "before", newText }], + }); +} + const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } }; const allApprovalOptions = [ @@ -535,6 +551,69 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); + it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => { + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + first.open(); + first.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] }, + }); + for (const head of conn.el.querySelectorAll(".st-tool-head")) head.click(); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 3, + rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")], + }, + }); + + expect( + conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"), + ).toBe("true"); + expect( + conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"), + ).toBe("true"); + expect( + conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"), + ).toBe("false"); + expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after"); + conn.dispose(); + }); + + it("prunes expanded IDs removed or made non-expandable by a snapshot", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] }, + }); + for (const head of conn.el.querySelectorAll(".st-tool-head")) head.click(); + + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 3, + rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })], + }, + }); + ws.emit({ + type: "transcript_snapshot", + snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] }, + }); + + for (const head of conn.el.querySelectorAll(".st-tool-head")) { + expect(head.getAttribute("aria-expanded")).toBe("false"); + } + conn.dispose(); + }); + it("keeps a local submission gated across a reconnect until begin-turn arrives", () => { vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); const first = new FakeSocket(); diff --git a/web/src/structured.ts b/web/src/structured.ts index 56b81ce..bc0f1db 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -106,6 +106,7 @@ export function createStructuredConnection( let rows: TranscriptRow[] = []; const rowIndex = new Map(); const rowEls = new Map(); + const expandedRowIds = new Set(); let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId); @@ -151,8 +152,24 @@ export function createStructuredConnection( } } + function buildRowElement(row: TranscriptRow): HTMLElement { + const el = buildTranscriptRow( + row, + approvalStateFor(row), + expandedRowIds.has(row.id), + (expanded) => { + if (expanded) expandedRowIds.add(row.id); + else expandedRowIds.delete(row.id); + }, + ); + if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { + expandedRowIds.delete(row.id); + } + return el; + } + function mountRow(row: TranscriptRow): void { - const el = buildTranscriptRow(row, approvalStateFor(row)); + const el = buildRowElement(row); rowEls.set(row.id, el); rowsEl.appendChild(el); } @@ -160,9 +177,8 @@ export function createStructuredConnection( function remountRow(row: TranscriptRow): void { const old = rowEls.get(row.id); const oldToolHead = old?.querySelector(".st-tool-head") ?? null; - const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true"; const restoreFocus = document.activeElement === oldToolHead; - const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded); + const el = buildRowElement(row); rowEls.set(row.id, el); if (old) old.replaceWith(el); else rowsEl.appendChild(el); @@ -172,6 +188,10 @@ export function createStructuredConnection( function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { rows = [...snapshot.rows]; + const expandableIds = new Set( + rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id), + ); + for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); rowIndex.clear(); rows.forEach((row, i) => rowIndex.set(row.id, i)); rowEls.clear(); @@ -199,6 +219,7 @@ export function createStructuredConnection( const i = rowIndex.get(delta.id); if (i === undefined) return; rows.splice(i, 1); + expandedRowIds.delete(delta.id); rowIndex.clear(); rows.forEach((row, idx) => rowIndex.set(row.id, idx)); rowEls.get(delta.id)?.remove(); @@ -482,6 +503,7 @@ export function createStructuredConnection( resizeObserver.disconnect(); unsubscribeStore?.(); closeCurrentSocket(); + expandedRowIds.clear(); el.remove(); }, }; diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index ca51efc..d43d317 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -187,7 +187,11 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin return null; } -function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement { +function buildToolRow( + row: TranscriptRow, + expanded: boolean, + onExpandedChange?: (expanded: boolean) => void, +): HTMLElement { const tool = (row.data ?? {}) as AcpToolCallLike; const content = tool.content ?? []; const hasDiff = content.some((c) => c.type === "diff"); @@ -259,6 +263,7 @@ function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement { }; head.addEventListener("click", () => { setExpanded(head.getAttribute("aria-expanded") !== "true"); + onExpandedChange?.(head.getAttribute("aria-expanded") === "true"); }); setExpanded(expanded); @@ -363,11 +368,12 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE } /** Build the DOM for one transcript row. `approval` is consulted only for an - * `approval` row; pass it whenever the row might be that kind. */ + * `approval` row; tool expansion is supplied and reported explicitly. */ export function buildTranscriptRow( row: TranscriptRow, approval?: ApprovalRowState, toolExpanded = false, + onToolExpandedChange?: (expanded: boolean) => void, ): HTMLElement { let built: HTMLElement; switch (row.kind) { @@ -382,7 +388,7 @@ export function buildTranscriptRow( break; case "tool_call": case "tool_diff": - built = buildToolRow(row, toolExpanded); + built = buildToolRow(row, toolExpanded, onToolExpandedChange); break; case "approval": built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });