From cb25320ea0345b703b8e8d755da7a48e353079a4 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 10 Sep 2026 21:55:06 +0100 Subject: [PATCH] feat(maestro): keep agent text visible, collapse only tool calls Agent text and thought rows stay outside the collapsed tool group as standalone transcript items instead of being absorbed into it. Co-Authored-By: Claude Opus 4.6 --- docs/STRUCTURED-VIEW.md | 20 +++------ web/src/components/structured/model.test.ts | 28 +++--------- web/src/components/structured/model.ts | 47 ++++----------------- 3 files changed, 21 insertions(+), 74 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 093bf2f..bcdb153 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -190,19 +190,13 @@ choices, not daemon ones. from the transcript; the TUI may show the current plan in its status line, while the web client deliberately has no stats or plan rail. - The web client groups two or more tool rows after the same `User` row into - 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. While a turn is still active - (responding or thinking), the most recent trailing text or thought row is - kept outside as visible narration — the operator always sees the agent's - latest reasoning without opening the group. It is replaced by the next text - row when one arrives, and absorbed into the group when the turn ends (unless - it is `AgentText`, which stays outside as the final answer). The collapsed - header reads "N tool calls · Label 'arg'" where the label and argument come - from the last tool row's `splitToolTitle` — the operator sees which command - is current at a glance. Opening the group reveals tools and intermediate - text in chronological order, with the text rendered in a subdued style. + one collapsed turn activity block. `AgentText` and `AgentThought` rows stay + outside the group as standalone transcript items — the operator sees all + reasoning without expanding. Resolved approval rows between tools are + absorbed into the group. The collapsed header reads "N tool calls · Label + 'arg'" where the label and argument come from the last tool row's + `splitToolTitle` — the operator sees which command is current at a glance. + Opening the group reveals tool rows in chronological order. - 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/components/structured/model.test.ts b/web/src/components/structured/model.test.ts index ad506df..5cc5890 100644 --- a/web/src/components/structured/model.test.ts +++ b/web/src/components/structured/model.test.ts @@ -124,7 +124,7 @@ describe("activity grouping", () => { expect(activityGroupsByTurn(rows, null, false).size).toBe(0); }); - it("absorbs intermediate narration but keeps the final answer outside", () => { + it("keeps intermediate text outside the group as standalone items", () => { const rows = [ text("user:1", "user", "go"), tool("tool:1"), @@ -133,11 +133,11 @@ describe("activity grouping", () => { text("agent:2", "agent_text", "done"), ]; const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; - expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]); + expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]); expect(group.toolCount).toBe(2); }); - it("keeps the newest thought outside while the turn is still live", () => { + it("keeps thoughts outside the group as standalone items", () => { const rows = [ text("user:1", "user", "go"), tool("tool:1"), @@ -145,26 +145,8 @@ describe("activity grouping", () => { text("thought:1", "agent_thought", "hmm"), text("thought:2", "agent_thought", "aha"), ]; - const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!; - expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]); - const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; - expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]); - }); - - it("keeps the newest agent_text outside while the turn is still live", () => { - const rows = [ - text("user:1", "user", "go"), - tool("tool:1"), - text("agent:1", "agent_text", "working on it"), - tool("tool:2"), - text("agent:2", "agent_text", "almost done"), - ]; - const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!; - // "almost done" stays outside as visible narration while the turn is live - expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]); - const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; - // Once ended, the final agent_text stays outside as the answer - expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]); + const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; + expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]); }); it("tracks the last tool's label and argument for the collapsed header", () => { diff --git a/web/src/components/structured/model.ts b/web/src/components/structured/model.ts index 1a22638..8f59baf 100644 --- a/web/src/components/structured/model.ts +++ b/web/src/components/structured/model.ts @@ -144,9 +144,10 @@ export function turnIsActive(state: ReducedState): boolean { } export interface ActivityGroup { - /** Header label count — tool rows only, never absorbed text. */ + /** Header label count — tool rows only. */ toolCount: number; - /** Tools plus absorbed intermediate text/approvals, in transcript order. */ + /** Tool rows and resolved approvals, in transcript order. Text/thought + * rows stay outside the group as standalone transcript items. */ rows: TranscriptRow[]; /** `splitToolTitle` of the last tool row — the collapsed header shows it. */ lastToolLabel: string; @@ -154,23 +155,16 @@ export interface ActivityGroup { } /** - * Group tool rows (and intermediate agent text / resolved approvals between - * them) by turn. An agent_text/agent_thought row is "intermediate" when a tool - * row follows it in the same turn — it gets absorbed into the group so the - * collapsed block preserves chronological order. Resolved approval rows (not - * the currently pending one) are absorbed the same way. The *last* agent text - * with no subsequent tool stays standalone (the prominent final answer). - * - * While a turn is still live, the most recent text or thought row trailing the - * group also stays outside as visible narration — the operator always sees the - * agent's latest reasoning without opening the group. + * Group tool rows (and resolved approvals between them) by turn. Text and + * thought rows stay outside the group as standalone transcript items — the + * operator sees all reasoning without expanding anything. * * Returns groups keyed by `turn:`. */ export function activityGroupsByTurn( rows: TranscriptRow[], pendingApprovalId: string | null, - turnActive: boolean, + _turnActive: boolean, ): Map { // First: count tools per turn to know which turns qualify for grouping. const toolCounts = new Map(); @@ -181,11 +175,9 @@ export function activityGroupsByTurn( const gid = `turn:${turnId}`; toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); } - // Second: build groups with interleaved text for turns with 2+ tools. + // Second: build groups for turns with 2+ tools. const groups = new Map(); turnId = "before-first-message"; - // Pending rows that might be intermediate (not yet confirmed by a following - // tool). If no tool follows, they stay standalone. let pending: TranscriptRow[] = []; let currentGid = `turn:${turnId}`; for (const row of rows) { @@ -197,15 +189,13 @@ export function activityGroupsByTurn( } const count = toolCounts.get(currentGid) ?? 0; if (count < 2) continue; - const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought"; const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId; - if (isIntermediateText || isResolvedApproval) { + if (isResolvedApproval) { pending.push(row); continue; } if (row.kind === "tool_call" || row.kind === "tool_diff") { const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" }; - // Flush any pending rows — a tool follows, so they're intermediate. group.rows.push(...pending); pending = []; group.rows.push(row); @@ -216,25 +206,6 @@ export function activityGroupsByTurn( groups.set(currentGid, group); } } - // Trailing text/thought rows after the last tool in this turn. - // While the turn is still live, the newest one stays outside as visible - // narration — the operator always sees the agent's latest reasoning. - // A final agent_text (the "answer") stays outside regardless. - const lastGroup = groups.get(currentGid); - if (lastGroup && pending.length > 0) { - const allThoughts = pending.every((row) => row.kind === "agent_thought"); - if (allThoughts) { - // Pure thoughts: absorb all when done, keep the newest outside while live. - const absorb = turnActive ? pending.slice(0, -1) : pending; - lastGroup.rows.push(...absorb); - } else if (turnActive) { - // Mixed or agent_text trailing: keep the very last row outside while live, - // absorb the rest. - lastGroup.rows.push(...pending.slice(0, -1)); - } - // When the turn ends with agent_text last, nothing extra is absorbed — the - // text stays outside as the final answer. - } return groups; }