From 25721c784798d12dbfde035efe8896b184b240ea Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 22:34:13 +0700 Subject: [PATCH] fix(app): keep grouped reasoning closed when Expand thinking is off (#8042) Thoughts are grouped with tool calls only while Expand thinking is off, yet opening a group rendered each thought with `defaultExpanded`, so the reasoning text popped open against the setting. Grouped thoughts now open like any other call: a closed Thinking row the reader clicks. The folded history also treated the latest retained turn as live whenever the agent was active without a known turn id, which unfolded the previous turn until the new turn's rows arrived. Without head rows, the latest turn stays live only while the active run is that turn. Co-authored-by: Claude --- .../app/e2e/browser/turn-collapse.spec.ts | 15 ++++++++++++- .../app/src/agent-stream/presentation.test.ts | 22 +++++++++++++++++++ packages/app/src/agent-stream/presentation.ts | 18 ++++++++------- packages/app/src/agent-stream/view.tsx | 4 +++- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index 4d98dea506..ddbff1e2f3 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -475,7 +475,20 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole await expect(group).toContainText(/read 1 file/i); await expect(group.getByRole("button").first()).not.toContainText(/thought/i); await group.getByRole("button").first().click(); - await expect(reasoning).toBeVisible(); + // Expand thinking is off: the group opens its rows, but the thought row stays closed + // until the reader opens it. + const thought = group + .getByTestId("tool-call-badge") + .filter({ hasText: /^Thinking/ }) + .first(); + await expect(thought).toBeVisible(); + await expect(reasoning).toHaveCount(0); + // A press right after the group opens occasionally does not register; retry until the + // thought row opens. + await expect(async () => { + await thought.getByRole("button").first().click(); + await expect(reasoning).toBeVisible({ timeout: 2_000 }); + }).toPass(); } } diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index f7d146d92a..ce3c0352ff 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -702,6 +702,28 @@ it("does not reopen the previous turn while a new run waits for its first row", expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); }); +// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is +// the completed one; it must stay folded until the new turn's rows arrive. +it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: null, + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); + it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { const present = createStreamPresentation(); const timestamp = new Date(); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index b449cb63cc..94140ca395 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -113,14 +113,16 @@ export function createStreamPresentation() { } // Live text updates reach the head only, so the folded history is reused until the // retained rows, the latest turn's liveness, or the reader's expanded turns change. - const isLatestTurnOpen = - (input.isTurnActive || projection.head.length > 0) && - !isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) && - !( - input.activeTurnId && - projection.tail.at(-1)?.turnId && - input.activeTurnId !== projection.tail.at(-1)?.turnId - ); + const last = projection.tail.at(-1) ?? null; + const first = projection.head[0] ?? null; + const activeTurnIsAnother = Boolean( + input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId, + ); + // Without head rows, the latest retained turn is live only while the active run is that + // turn. A run that has not opened yet carries no id and belongs to the next turn. + const isLatestTurnOpen = first + ? !isTurnBoundary(last, first) && !activeTurnIsAnother + : input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId); if ( !collapsed || collapsedSource !== projection.tail || diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index f35af10498..90c88fb0d1 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -898,12 +898,14 @@ const AgentStreamViewComponent = forwardRef ( {call.kind === "thought" ? ( + // Reasoning is grouped only while Expand thinking is off, so an opened + // group shows each thought as a closed row like any other call. ) : (