From 443800bd7943a125dc23f30ecb5ad9c2164e8cef Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 08:53:22 +0100 Subject: [PATCH] fix(maestro): preserve web tool patch state --- web/src/structured.test.ts | 103 +++++++++++++++++++++++++++++++++++++ web/src/structured.ts | 6 +++ 2 files changed, 109 insertions(+) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 7873a0b..870100a 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -115,6 +115,109 @@ describe("structured connection: data flow", () => { expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it conn.dispose(); }); + + it("keeps an expanded running tool open when its stable row is patched", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + row("tool:1", "tool_call", { + title: "Bash(pnpm test)", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "starting" } }], + }), + ], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + + const originalHead = conn.el.querySelector(".st-tool-head")!; + originalHead.click(); + expect(originalHead.getAttribute("aria-expanded")).toBe("true"); + + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("tool:1", "tool_call", { + title: "Bash(pnpm test)", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "still running" } }], + }), + }, + state: idleState, + }); + + const patchedHead = conn.el.querySelector(".st-tool-head")!; + expect(patchedHead).not.toBe(originalHead); + expect(patchedHead.getAttribute("aria-expanded")).toBe("true"); + expect(conn.el.querySelector(".st-tool-out")!.hidden).toBe(false); + expect(conn.el.textContent).toContain("still running"); + conn.dispose(); + }); + + it("restores focus to a patched tool header with the same stable row id", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const originalHead = conn.el.querySelector(".st-tool-head")!; + originalHead.focus(); + expect(document.activeElement).toBe(originalHead); + + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }), + }, + state: idleState, + }); + + const patchedHead = conn.el.querySelector(".st-tool-head")!; + expect(patchedHead).not.toBe(originalHead); + expect(document.activeElement).toBe(patchedHead); + conn.dispose(); + }); + + it("does not move unrelated focus when a tool row is patched", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input")!; + input.focus(); + + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }), + }, + state: idleState, + }); + + expect(document.activeElement).toBe(input); + conn.dispose(); + }); }); describe("structured connection: turn/composer gating", () => { diff --git a/web/src/structured.ts b/web/src/structured.ts index 101af49..531601d 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -159,10 +159,16 @@ 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)); rowEls.set(row.id, el); if (old) old.replaceWith(el); else rowsEl.appendChild(el); + const newToolHead = el.querySelector(".st-tool-head"); + if (wasExpanded) newToolHead?.click(); + if (restoreFocus) newToolHead?.focus(); } function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {