From 10c9db6d87d56ea0e460ca2272775934b5a514fe Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 8 Sep 2026 13:45:45 +0100 Subject: [PATCH] feat(web): show last agent text outside collapsed group during active turn Intermediate narration was hidden inside the collapsed tool group while the agent was still working. The last AgentText/AgentThought now stays visible as current status, absorbed only when the turn ends or newer text replaces it. Co-Authored-By: Claude Opus 4.6 --- docs/STRUCTURED-VIEW.md | 11 +++++++---- web/src/structured.test.ts | 34 ++++++++++++++++++++++++++++++++++ web/src/structured.ts | 27 ++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 713ec7f..c8d6467 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -193,10 +193,13 @@ choices, not daemon ones. one collapsed turn activity block. `AgentText` and `AgentThought` rows that appear *between* tool rows in the same turn are absorbed into the group (intermediate narration); the last `AgentText` with no subsequent tool stays - outside as the prominent final answer. The header still reads - "N tool calls" (counting only tool rows). Opening the group reveals tools - and intermediate text in chronological order, with the text rendered in a - subdued style. + outside as the prominent final answer. While a turn is still active + (responding or thinking), the most recent intermediate `AgentText` or + `AgentThought` in the group is kept outside as visible narration — replaced + by the next text row when one arrives, and absorbed into the group when the + turn ends. The header still reads "N tool calls" (counting only tool rows). + Opening the group reveals tools and intermediate text in chronological + order, with the text rendered in a subdued style. - The web client's user, agent, thought, tool-detail, and notice text blocks copy their complete source text when clicked or keyboard-activated. - The web client consumes `available_commands_update` notices as the live diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index fabda8b..fa406a3 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -446,6 +446,40 @@ describe("structured connection: data flow", () => { conn.dispose(); }); + it("keeps last intermediate text outside group while turn is active", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + const respondingState: ReducedState = { + turn: { responding: true, thinking: false, active_tool_calls: [] }, + }; + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 6, + rows: [ + row("user:1", "user", { content: { type: "text", text: "do it" } }), + toolRow("tool:1", "one"), + row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }), + toolRow("tool:2", "two"), + row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }), + toolRow("tool:3", "three"), + ], + }, + }); + ws.emit({ type: "reduced_state", state: respondingState }); + // While the turn is active, the last intermediate text (agent:2) stays + // outside the collapsed group so it's visible as current narration. + expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull(); + // Earlier intermediate text (agent:1) is still inside the group. + expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull(); + // When the turn ends, all intermediate text is absorbed. + ws.emit({ type: "reduced_state", state: idleState }); + expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull(); + expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull(); + conn.dispose(); + }); + it("keeps an expanded running tool open without emitting a click when patched", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 0589451..0ca7e0f 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -453,6 +453,25 @@ export function createStructuredConnection( } } // pending at end of a turn = final text/approvals, stays outside (not flushed). + + // While the turn is still active, keep the last intermediate text/thought + // row outside the collapsed group so it's visible as the agent's current + // narration. It gets absorbed on the next rebuild once newer content + // arrives or the turn ends. + const turnActive = state.turn.responding || state.turn.thinking; + if (turnActive) { + const lastGroup = groups.get(currentGid); + if (lastGroup) { + for (let i = lastGroup.rows.length - 1; i >= 0; i--) { + const kind = lastGroup.rows[i].kind; + if (kind === "agent_text" || kind === "agent_thought") { + lastGroup.rows.splice(i, 1); + break; + } + } + } + } + return groups; } @@ -735,15 +754,17 @@ export function createStructuredConnection( } function applyState(next: ReducedState, connectionGeneration: number): void { + const wasTurnActive = state.turn.responding || state.turn.thinking; state = next; stateSocketGeneration = connectionGeneration; reconcileAwaitingBegin(); + const isTurnActive = state.turn.responding || state.turn.thinking; const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; - if (newPendingId !== pendingApprovalRowId) { + const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive; + if (needsRebuild) { updateTranscriptLayout(() => { - const prev = pendingApprovalRowId; pendingApprovalRowId = newPendingId; - if (prev !== null || newPendingId !== null) rebuildTranscript(); + rebuildTranscript(); }); } renderConfig();