diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index fe288bc..de28fff 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -468,18 +468,40 @@ describe("structured connection: data flow", () => { }, }); 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. + // Intermediate updates stay inside the collapsed activity disclosure, + // rather than becoming a second visible transcript alongside its summary. + 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(); - // When the turn ends, all intermediate text is absorbed. + // It remains collapsed when the turn ends. 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("collapses trailing thought commentary with a turn's tool activity", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 5, + rows: [ + row("user:1", "user", { content: { type: "text", text: "do it" } }), + toolRow("tool:1", "one"), + toolRow("tool:2", "two"), + row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }), + row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }), + ], + }, + }); + for (const id of ["thought:1", "thought:2"]) { + expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.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 3651215..e17c03b 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -488,24 +488,14 @@ export function createStructuredConnection( groups.set(currentGid, group); } } - // 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; - } - } - } + // A final agent text is the answer, so it stays outside. Thought rows are + // working commentary, though: when a turn already has a disclosure they + // belong with its activity even if the next tool call has not arrived yet. + // Leaving that tail loose made a busy agent look like it was publishing a + // long italic answer above a collapsed "N tool calls" summary. + const lastGroup = groups.get(currentGid); + if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) { + lastGroup.rows.push(...pending); } return groups; diff --git a/web/src/styles.css b/web/src/styles.css index 38898c2..737db1e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2507,7 +2507,8 @@ input.leaf-rename:focus { .st-row-thought { color: var(--faint); - font-style: italic; + font-size: var(--fs-12); + line-height: 1.4; white-space: pre-wrap; overflow-wrap: anywhere; }