diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 0ece89c..ecf07f9 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -109,6 +109,121 @@ describe("structured connection: data flow", () => { conn.dispose(); }); + it("keeps snapshot plan rows out of the transcript while rendering the plan rail", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 3, + rows: [ + row("user:1", "user", { content: { type: "text", text: "before plan" } }), + row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }), + row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }), + ], + }, + }); + ws.emit({ + type: "reduced_state", + state: { + ...idleState, + plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] }, + }, + }); + + const transcript = conn.el.querySelector(".structured-rows")!; + expect(transcript.querySelectorAll(".st-row")).toHaveLength(2); + expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull(); + expect(transcript.textContent).toContain("before plan"); + expect(transcript.textContent).toContain("after plan"); + expect(transcript.textContent).not.toContain("[plan]"); + expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("ship it"); + conn.dispose(); + }); + + it("applies plan append, patch, and remove without disturbing surrounding rows", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }), + }, + state: { + ...idleState, + plan: { entries: [{ content: "first", priority: "high", status: "pending" }] }, + }, + }); + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }), + }, + state: { + ...idleState, + plan: { entries: [{ content: "first", priority: "high", status: "pending" }] }, + }, + }); + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }), + }, + state: { + ...idleState, + plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] }, + }, + }); + + const transcript = conn.el.querySelector(".structured-rows")!; + expect([...transcript.querySelectorAll(".st-row")].map((el) => el.dataset.rowId)).toEqual([ + "user:1", + "agent:1", + ]); + expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("updated"); + expect(conn.el.querySelector(".structured-rail")!.textContent).not.toContain("first"); + + ws.emit({ + type: "projection_update", + transcript: { type: "remove", id: "plan" }, + state: idleState, + }); + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }), + }, + state: idleState, + }); + ws.emit({ + type: "projection_update", + transcript: { type: "remove", id: "user:1" }, + state: idleState, + }); + + expect(conn.el.querySelector(".structured-rail .st-plan")).toBeNull(); + expect([...transcript.querySelectorAll(".st-row")].map((el) => el.dataset.rowId)).toEqual([ + "agent:1", + ]); + expect(transcript.textContent).toContain("patched neighbor"); + conn.dispose(); + }); + it("appends and patches rows via projection_update without duplicating them", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -551,6 +666,47 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); + it("does not mount plan rows from a reconnect snapshot", () => { + 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: 1, + rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })], + }, + }); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 4, + rows: [ + row("user:new", "user", { content: { type: "text", text: "new" } }), + row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }), + ], + }, + }); + second.emit({ + type: "reduced_state", + state: { + ...idleState, + plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] }, + }, + }); + + const transcript = conn.el.querySelector(".structured-rows")!; + expect(transcript.textContent).toBe("Younew"); + expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull(); + expect(conn.el.querySelector(".structured-rail")!.textContent).toContain("reconnected"); + 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(); diff --git a/web/src/structured.ts b/web/src/structured.ts index bc0f1db..c0853bb 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -152,7 +152,7 @@ export function createStructuredConnection( } } - function buildRowElement(row: TranscriptRow): HTMLElement { + function buildRowElement(row: TranscriptRow): HTMLElement | null { const el = buildTranscriptRow( row, approvalStateFor(row), @@ -162,16 +162,27 @@ export function createStructuredConnection( else expandedRowIds.delete(row.id); }, ); + if (!el) return null; if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { expandedRowIds.delete(row.id); } return el; } + function insertRowElement(row: TranscriptRow, el: HTMLElement): void { + const index = rowIndex.get(row.id) ?? rows.length - 1; + let next: HTMLElement | null = null; + for (let i = index + 1; i < rows.length; i++) { + next = rowEls.get(rows[i].id) ?? null; + if (next) break; + } + rowEls.set(row.id, el); + rowsEl.insertBefore(el, next); + } + function mountRow(row: TranscriptRow): void { const el = buildRowElement(row); - rowEls.set(row.id, el); - rowsEl.appendChild(el); + if (el) insertRowElement(row, el); } function remountRow(row: TranscriptRow): void { @@ -179,9 +190,18 @@ export function createStructuredConnection( const oldToolHead = old?.querySelector(".st-tool-head") ?? null; const restoreFocus = document.activeElement === oldToolHead; const el = buildRowElement(row); - rowEls.set(row.id, el); - if (old) old.replaceWith(el); - else rowsEl.appendChild(el); + if (!el) { + old?.remove(); + rowEls.delete(row.id); + expandedRowIds.delete(row.id); + return; + } + if (old) { + rowEls.set(row.id, el); + old.replaceWith(el); + } else { + insertRowElement(row, el); + } const newToolHead = el.querySelector(".st-tool-head"); if (restoreFocus) newToolHead?.focus(); } diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index 1fb6c80..02a6ff0 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -9,7 +9,11 @@ import { } from "./structuredRender.js"; import type { Session, TranscriptRow } from "./types.js"; -function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow { +function row( + kind: K, + data: unknown, + id = "r1", +): TranscriptRow & { kind: K } { return { id, kind, data }; } @@ -275,9 +279,16 @@ describe("buildTranscriptRow: approval", () => { }); describe("buildTranscriptRow: notice / unknown kinds", () => { - it("renders a plan or notice row without crashing", () => { - expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow(); - expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow(); + it("renders notice and unknown row kinds safely", () => { + expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe( + "[notice]", + ); + const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow; + expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]"); + }); + + it("returns no transcript element for a plan row", () => { + expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull(); }); }); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index d43d317..326b6e9 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -367,14 +367,33 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE return wrap; } -/** Build the DOM for one transcript row. `approval` is consulted only for an - * `approval` row; tool expansion is supplied and reported explicitly. */ +/** Build the DOM for one transcript row, or `null` for a plan shown only in + * the rail. `approval` is consulted only for an `approval` row; tool + * expansion is supplied and reported explicitly. */ +export function buildTranscriptRow( + row: TranscriptRow & { kind: "plan" }, + approval?: ApprovalRowState, + toolExpanded?: boolean, + onToolExpandedChange?: (expanded: boolean) => void, +): null; +export function buildTranscriptRow( + row: TranscriptRow & { kind: Exclude }, + approval?: ApprovalRowState, + toolExpanded?: boolean, + onToolExpandedChange?: (expanded: boolean) => void, +): HTMLElement; +export function buildTranscriptRow( + row: TranscriptRow, + approval?: ApprovalRowState, + toolExpanded?: boolean, + onToolExpandedChange?: (expanded: boolean) => void, +): HTMLElement | null; export function buildTranscriptRow( row: TranscriptRow, approval?: ApprovalRowState, toolExpanded = false, onToolExpandedChange?: (expanded: boolean) => void, -): HTMLElement { +): HTMLElement | null { let built: HTMLElement; switch (row.kind) { case "user": @@ -393,6 +412,8 @@ export function buildTranscriptRow( case "approval": built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() }); break; + case "plan": + return null; default: built = buildPlainRow(row); break;