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}