From 8f3d47c49ef3aca8799d2fd0d7a5d3dd5a2a0de1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 7 Sep 2026 11:42:25 +0100 Subject: [PATCH] feat(web): absorb intermediate agent text into tool groups Intermediate agent text/thought rows between tool calls in the same turn now render inside the collapsed tool group instead of after it, preserving chronological order. The last agent text (no subsequent tool) stays outside as the prominent final answer. Co-Authored-By: Claude Opus 4.6 --- docs/STRUCTURED-VIEW.md | 10 +++-- web/src/structured.test.ts | 33 ++++++++++++++++ web/src/structured.ts | 78 +++++++++++++++++++++++++++---------- web/src/structuredRender.ts | 4 +- web/src/styles.css | 20 ++++++++++ 5 files changed, 121 insertions(+), 24 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 97e3a0b..fc0e871 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -190,9 +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. Opening it reveals independently - expandable tool calls, including any raw command, input, output, and diff - content the adapter supplied. + 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. - 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 97c17e6..3ec0e5f 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -413,6 +413,39 @@ describe("structured connection: data flow", () => { conn.dispose(); }); + it("absorbs intermediate agent text into tool group, keeps final text outside", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 8, + rows: [ + row("user:1", "user", { content: { type: "text", text: "do it" } }), + row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }), + toolRow("tool:1", "one"), + toolRow("tool:2", "two"), + row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }), + toolRow("tool:3", "three"), + toolRow("tool:4", "four"), + row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }), + ], + }, + }); + const groups = conn.el.querySelectorAll(".st-tool-group"); + expect(groups).toHaveLength(1); + // Header counts only tool rows, not intermediate text. + expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls"); + // Intermediate text (agent:2) is inside the group. + expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull(); + // First text (agent:1) is outside — it precedes all tools. + expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull(); + // Final text (agent:3) is outside — no tool follows it. + expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).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 9aa52c7..f605544 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -393,17 +393,53 @@ export function createStructuredConnection( return el; } - function toolGroupsByTurn(): Map { - const groups = new Map(); + /** Group tool rows (and intermediate agent text 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. The *last* agent text with no + * subsequent tool stays standalone (the prominent final answer). + * Returns groups keyed by `turn:`, each with `toolCount` (for the + * header label) and `rows` (tools + intermediate text, in order). */ + function activityGroupsByTurn(): Map { + // First: count tools per turn to know which turns qualify for grouping. + const toolCounts = new Map(); let turnId = "before-first-message"; for (const row of rows) { if (row.kind === "user") turnId = row.id; if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue; - const id = `turn:${turnId}`; - const group = groups.get(id) ?? []; - group.push(row); - groups.set(id, group); + const gid = `turn:${turnId}`; + toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); } + // Second: build groups with interleaved text for turns with 2+ tools. + const groups = new Map(); + turnId = "before-first-message"; + // Pending text rows that might be intermediate (not yet confirmed by a + // following tool). If no tool follows, they stay standalone. + let pendingText: TranscriptRow[] = []; + let currentGid = `turn:${turnId}`; + for (const row of rows) { + if (row.kind === "user") { + turnId = row.id; + currentGid = `turn:${turnId}`; + pendingText = []; + continue; + } + const count = toolCounts.get(currentGid) ?? 0; + if (count < 2) continue; + if (row.kind === "agent_text" || row.kind === "agent_thought") { + pendingText.push(row); + continue; + } + if (row.kind === "tool_call" || row.kind === "tool_diff") { + const group = groups.get(currentGid) ?? { toolCount: count, rows: [] }; + // Flush any pending text — a tool follows, so it's intermediate. + group.rows.push(...pendingText); + pendingText = []; + group.rows.push(row); + groups.set(currentGid, group); + } + } + // pendingText at end of a turn = final text, stays outside (not flushed). return groups; } @@ -413,9 +449,9 @@ export function createStructuredConnection( const focusedGroupId = active?.closest("[data-tool-group-id]")?.dataset.toolGroupId; const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false; const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false; - const groups = toolGroupsByTurn(); + const groups = activityGroupsByTurn(); const groupedRowIds = new Set(); - const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id)); + const liveGroupIds = new Set([...groups.keys()]); for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id); rowEls.clear(); @@ -425,28 +461,30 @@ export function createStructuredConnection( for (const row of rows) { if (row.kind === "user") turnId = row.id; const groupId = `turn:${turnId}`; - const groupRows = groups.get(groupId) ?? []; - if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) { - if (groupedRowIds.has(row.id)) continue; - const toolEls = groupRows.flatMap((toolRow) => { - groupedRowIds.add(toolRow.id); - const toolEl = buildRowElement(toolRow); - if (!toolEl) return []; - rowEls.set(toolRow.id, toolEl); - return [toolEl]; + const group = groups.get(groupId); + if (group && groupedRowIds.has(row.id)) continue; + if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) { + // First ungrouped tool in this turn — render the whole activity group. + const memberEls = group.rows.flatMap((memberRow) => { + groupedRowIds.add(memberRow.id); + const memberEl = buildRowElement(memberRow); + if (!memberEl) return []; + rowEls.set(memberRow.id, memberEl); + return [memberEl]; }); const groupEl = buildToolGroup( groupId, - groupRows, - toolEls, + group.rows, + memberEls, expandedGroupIds.has(groupId), (expanded) => { if (expanded) expandedGroupIds.add(groupId); else expandedGroupIds.delete(groupId); settleDisclosureLayout(); }, + group.toolCount, ); - if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) { + if (group.rows.some((r) => enteringRowIds.has(r.id))) { groupEl.classList.add("st-row-enter"); } groupEls.set(groupId, groupEl); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index 65c6601..41a87ce 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -581,7 +581,9 @@ export function buildToolGroup( children: HTMLElement[], expanded: boolean, onExpandedChange?: (expanded: boolean) => void, + toolCount?: number, ): HTMLElement { + const count = toolCount ?? rows.length; const wrap = el("div", "st-tool-group"); wrap.dataset.toolGroupId = id; const head = document.createElement("button"); @@ -593,7 +595,7 @@ export function buildToolGroup( glyph.textContent = "›"; glyph.setAttribute("aria-hidden", "true"); const title = el("span", "st-tool-group-title"); - title.textContent = `${rows.length} tool calls`; + title.textContent = `${count} tool calls`; head.append(glyph, title); const body = el("div", "st-tool-group-body st-disclosure"); diff --git a/web/src/styles.css b/web/src/styles.css index 7857ac2..af78048 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2895,6 +2895,26 @@ input.leaf-rename:focus { border-radius: var(--r-1); } +/* intermediate agent text inside a tool group — subdued so the final + answer outside the group stays the prominent one. */ +.st-tool-group-body .st-row-agent { + color: var(--muted); + font-size: var(--fs-12); + padding: var(--sp-1) var(--sp-2); +} + +.st-tool-group-body .st-row-agent .st-body > :first-child { + margin-top: 0; +} + +.st-tool-group-body .st-row-agent .st-body > :last-child { + margin-bottom: 0; +} + +.st-tool-group-body .st-row-thought { + padding: var(--sp-1) var(--sp-2); +} + .st-tool-group-body[hidden] { display: none; }