From 11133d178bbe5f2cf8c0deda6da84bb193619f71 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:55:11 +1000 Subject: [PATCH] fix(app): show live Thinking instead of hiding reasoning-only turns (#58299) --- .../e2e/regression/timeline-stream.spec.ts | 31 ++++++++-- .../src/session/timeline/message-timeline.tsx | 11 +++- .../session-ui/src/timeline/projection.ts | 11 ---- .../src/timeline/rows-current.test.ts | 14 +++-- .../session-ui/src/tools/tool-renderer.tsx | 56 ++++++++++++++++++- 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 03832f259b1..f90d9f1d45c 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -681,7 +681,9 @@ test.describe("Working", () => { await expect(working).toHaveCount(0) }) - test("keeps Working instead of Used 1 Thought while reasoning is the only activity", async ({ page }) => { + test("shows an expandable live Thinking in place of Working while reasoning is the only activity", async ({ + page, + }) => { const timeline = await setupTimeline(page, { messages: [ userMessage(), @@ -690,15 +692,36 @@ test.describe("Working", () => { settings: { timelineDetail: detailed }, }) + // reasoningPart starts at 1700000001000, so the pause lands 8m 12s into the thought. + await page.clock.install({ time: 1700000001000 }) + await page.reload() + await timeline.transport.waitForConnection() + await page.clock.pauseAt(1700000001000 + 492_000) + const working = page.locator('[data-component="session-working"]') const group = page.locator('[data-component="collapsed-tool-group"]') - await expect(working).toBeVisible() - await expect(group).toHaveCount(0) + const trigger = group.locator('[data-component="context-tool-group-trigger"]') + const elapsed = trigger.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0`) + await expect(trigger).toHaveAttribute("aria-label", "Thinking") + await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(elapsed).toHaveText("8m 12s") + await expect(working).toHaveCount(0) await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0) + await page.clock.fastForward(1_000) + await expect(elapsed).toHaveText("8m 13s") + + await trigger.click() + await expect(group.getByText("Still considering the answer.", { exact: true })).toBeVisible() + + // Event delivery schedules timers, so let time run again before streaming the next part. + await page.clock.resume() + await timeline.send(partUpdated(toolPart("prt_after_thought", "read", "running", { filePath: "package.json" }))) - await expect(group).toBeVisible() await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0,prt_after_thought`) + await expect(trigger).toHaveAttribute("aria-label", "Used 1 Read") + await expect(elapsed).toHaveCount(0) await expect(working).toBeVisible() }) diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 1663dbc24dd..cdd87ac71aa 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -419,9 +419,16 @@ function MessageTimelineView( return message?.type === "compaction" && message.status === "running" } + if (!Predicate.isTagged(row, "AssistantPart")) return false + + // A live group of only thoughts reads as Thinking and stands in for Working. + if (row.group.type === "context" && projection.lastAssistantGroupKey().get(active) === row.group.key) + return row.group.refs.every( + (ref) => Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)?.type === "reasoning", + ) + // Used and read groups keep the fallback, so each new read does not swap Working out for its short call. - if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read") - return false + if (row.group.type === "context" || row.group.type === "read") return false return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => { const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index a17d7e798a4..c806e215836 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -505,15 +505,6 @@ export namespace Timeline { lastContent?.type === "reasoning" && lastContent.time?.completed === undefined - const thoughtOnly = - working && - detail?.thinking.placement === "grouped" && - assistantMessages.every((message) => - message.content.every( - (content) => content.type === "reasoning" || !isRenderable(content, showReasoning, detail), - ), - ) - if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID })) if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID })) @@ -524,8 +515,6 @@ export namespace Timeline { // An assistant message can produce several rows because its content parts are // rendered separately. Notices end a segment so none of those rows cross it. const appendAssistantSegment = (messages: SessionMessageAssistant[]) => { - if (thoughtOnly) return - const refs = messages.flatMap((message, messageIndex) => contentEntries(message) .filter( diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index eddd39894b8..346a90bfdae 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -277,7 +277,7 @@ describe("current session timeline rows", () => { }) }) - test("omits a grouped reasoning-only row while busy until other content or completion", () => { + test("keeps a grouped reasoning-only row while busy", () => { const thought = { type: "reasoning", text: "Thinking", time: { created: 1, completed: 2 } } as const const rows = (content: SessionMessageAssistant["content"], busy: boolean) => @@ -288,9 +288,15 @@ describe("current session timeline rows", () => { timelineDetail: timelinePresets[2].value, }).rows - expect(rows([{ ...thought, time: { created: 1 } }], true).map((row) => row._tag)).toEqual(["UserMessage"]) - expect(rows([thought], true).map((row) => row._tag)).toEqual(["UserMessage"]) - expect(rows([thought, { type: "text", text: "" }], true).map((row) => row._tag)).toEqual(["UserMessage"]) + expect(rows([{ ...thought, time: { created: 1 } }], true).map((row) => row._tag)).toEqual([ + "UserMessage", + "AssistantPart", + ]) + expect(rows([thought], true).map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + expect(rows([thought, { type: "text", text: "" }], true).map((row) => row._tag)).toEqual([ + "UserMessage", + "AssistantPart", + ]) expect(rows([thought, { type: "text", text: "Answer" }], true).map((row) => row._tag)).toEqual([ "UserMessage", "AssistantPart", diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 60aea25a11d..86ffc73a6cb 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -429,8 +429,48 @@ export function CurrentContextToolGroup(props: { ]), ) + const reasoning = createMemo(() => props.parts.flatMap((part) => (part.type === "reasoning" ? [part] : []))) + // Thoughts alone are not finished work yet, so the live group reads as Thinking, in place of Working, rather than + // "Used 1 Thought". + const thinking = createMemo(() => props.busy && reasoning().length > 0 && reasoning().length === props.parts.length) + const [now, setNow] = createSignal(Date.now()) + + createEffect(() => { + if (!thinking()) return + setNow(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(timer)) + }) + + const elapsed = createMemo(() => { + const started = reasoning()[0]?.time?.created + + if (!thinking() || started === undefined) return "" + const total = Math.max(0, Math.floor((now() - started) / 1000)) + const numfmt = new Intl.NumberFormat(i18n.locale()) + + if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt.format(total) }) + + if (total < 3600) + return i18n.t("ui.message.duration.minutesSeconds", { + minutes: numfmt.format(Math.floor(total / 60)), + seconds: numfmt.format(total % 60), + }) + + return i18n.t("ui.message.duration.hoursMinutes", { + hours: numfmt.format(Math.floor(total / 3600)), + minutes: numfmt.format(Math.floor(total / 60) % 60), + }) + }) + const label = createMemo(() => { - const thoughts = props.parts.filter((part) => part.type === "reasoning").length + const thoughts = reasoning().length + + if (thinking()) { + const text = i18n.t("ui.sessionTurn.status.thinking") + + return { text, title: text, before: "", count: undefined, between: "", after: "" } + } if (!names() && !thoughts) { const text = i18n.t("ui.messagePart.context.updates") @@ -566,7 +606,16 @@ export function CurrentContextToolGroup(props: { - {(title) => {title()}} + + {(title) => ( + + + + + + )} + + {(value) => {value()}} {(after) => {after()}} @@ -616,7 +665,8 @@ export function CurrentContextToolGroup(props: { id={part().id} content={part()} streaming={part().streaming ?? false} - defaultOpen={props.reasoningDefaultOpen} + // The group already reads as Thinking, so opening it shows the thoughts themselves. + defaultOpen={thinking() || props.reasoningDefaultOpen} open={props.reasoningOpen?.(part().id)} onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)} onContentRendered={props.onSizeChange}