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; }