From 95ccbf406626a8a80fa545777a6388bdf5153086 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 15:06:03 -0700 Subject: [PATCH] feat(clients): live row shows the agent's latest thought (#16284) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 3 ++ .../src/features/threads/thread-work-log.tsx | 28 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 40 ++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 15 +++++- .../chat/MessagesTimeline.logic.test.ts | 54 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +++ .../src/components/chat/MessagesTimeline.tsx | 42 +++++++++++++-- apps/web/src/components/chat/WorkLog.tsx | 8 ++- .../src/work-log/presentation.test.ts | 38 +++++++++++++ .../src/work-log/presentation.ts | 20 +++++++ 10 files changed, 241 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c18874b1cb03ffdede7000c5b7bae041f1351d28..b5f258a0889f9e07357411b61d717e395121560c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1628,6 +1628,7 @@ function renderFeedEntry( summaryToolIcon={entry.summaryToolIcon} hasFailure={entry.hasFailure} shimmer={entry.shimmer} + thought={entry.thought} onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": + // A live thought wraps up to four lines, so that row measures itself. + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 4a9067529510d63605f8c243607b9a1067af768e..32e6d272b678076a912f6c1c4beee7ac2991399c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: { readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; + readonly thought?: string | undefined; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; + const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; + const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel; const icon = props.summaryToolIcon ?? (props.toolSurface @@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: { return ( + {props.thought ? ( + // The latest thought sits above the status line as an ordinary work-log + // row, wrapped up to four lines. + + + + + + {props.thought} + + + ) : null} row.type === "work-toggle")).toMatchObject({ - summary: "First paragraph. Second paragraph.", + summary: "Thinking", + thought: "First paragraph.", live: true, }); } else { @@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])( }, ); +it("keeps the latest thought under the live tool status", () => { + const at = "2026-06-20T00:00:02.000Z"; + const thought: OrchestrationV2TurnItem = { + ...base("found-thought", at, 1), + type: "reasoning", + status: "completed", + streaming: false, + text: "Found the cause: no commits yet. Checking the UI next.", + }; + const tool = { ...command(at), status: "running" as const, completedAt: null }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ + thought: "Found the cause: no commits yet.", + live: true, + }); + expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ + summary: "Thinking", + }); +}); + it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool", at, ); expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "first-thought", + summary: "Thinking", + thought: "first-thought", live: true, shimmer: true, }); @@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool", ]) { const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); expect(live).toHaveLength(1); - expect(live[0]).toMatchObject({ - summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp", - }); + expect(live[0]).toMatchObject( + items.at(-1)!.type === "reasoning" + ? { summary: "Thinking", thought: "next-thought" } + : { summary: "Running vp" }, + ); } expect(first.status).toBe("running"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d88b1dba2ea2e7c7113b19351e5a84e2ee6d52aa..2dc513e40f0238a1cf22b98afdf5304e29d756e2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/work-log/command-label"; import { contextCompactionLabel, + liveThoughtLine, toolItemForDisplay, workEntryDisplayIndicatesToolFailure, liveActivityToolStatus, @@ -213,6 +214,8 @@ type ThreadFeedEntryContent = readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; + /** First sentence of the latest thought, shown above the live status line. */ + readonly thought?: string; } | { readonly type: "run-fold"; @@ -1496,8 +1499,17 @@ function appendToolGroupRows( const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const latestThought = + live && !expanded + ? activities.findLast( + (activity) => + activity.workEntry.itemType === "reasoning" && + (activity.workEntry.detail?.trim() ?? "") !== "", + )?.workEntry.detail + : undefined; + const thought = latestThought ? liveThoughtLine(latestThought) : ""; const summary = live - ? expanded && latestActivity.workEntry.itemType === "reasoning" + ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" ? "Thinking" : "Thought" @@ -1552,6 +1564,7 @@ function appendToolGroupRows( ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), + ...(thought ? { thought } : {}), hasFailure: (() => { const lastToolLike = activities.findLast((activity) => activity.toolLike); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1ce7145d63a3231dc663920b3208e5555bf4c12..98339041f68b487bb5c9580c20967567ab4767bf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); }); + it("carries the latest thought on the live row while a later tool runs", () => { + type WorkEntry = Extract< + Parameters[0]["timelineEntries"][number], + { kind: "work" } + >; + const work = ( + id: string, + at: string, + fields: Omit, + ): WorkEntry => ({ + id: `${id}-entry`, + kind: "work", + createdAt: at, + entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + work("old-thought", "2026-01-01T00:00:01Z", { + itemType: "reasoning", + detail: "First idea.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("new-thought", "2026-01-01T00:00:02Z", { + itemType: "reasoning", + detail: "Found the cause.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("running-command", "2026-01-01T00:00:03Z", { + command: "rg cause", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }), + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "running-command" }, + thought: { id: "new-thought" }, + }); + }); + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b619a347255b58b323ca3ccf5a6a58cd0b1becf1..74f3991396ef99183d8bbec51589c5c6554b18a6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -540,6 +540,8 @@ type MessagesTimelineRowContent = groupId: string; expanded: boolean; active: boolean; + /** Latest reasoning in the live group, shown above the status line. */ + thought?: WorkLogEntry; } | { kind: "working"; @@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: { latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); + const latestThoughtEntry = visibleActiveToolEntries.findLast( + (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "", + ); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed @@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, active: latestToolKeepsActivityLive, + ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), }; })() : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43f9746a6590b24c9a703658100f3072aae0ef52..6145098f604378071f57544f5ce4fccc81f976c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + liveThoughtLine, resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, @@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {thoughtLine ? ( + // The latest thought's first sentence sits above the status line as an + // ordinary work-log row, so a finding never hides behind the next tool call. + + + + } + label={{thoughtLine}} + wrapLabel + /> + ) : null} {label} diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 4358f30f4de36abf8a2ca0969ce2f81fd3ad9c7d..8ac63e0dae4671aa0b838896e880e6c55b80efef 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -46,7 +46,13 @@ type RowContent = { function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { return ( -
+
{icon ? ( {icon} ) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8a73dd70fb73f4604967822607520714656bf0c7..6e41a53478a1ce9bf82ac387084062447c9dd052 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, extractCommandOutputText, + liveThoughtLine, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -960,3 +961,40 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("liveThoughtLine", () => { + it("keeps the first sentence, including a closing quote", () => { + expect( + liveThoughtLine( + 'Found the cause: the repo has no commits, so `git worktree add` fails with "invalid reference: main." Now checking the UI.', + ), + ).toBe( + 'Found the cause: the repo has no commits, so git worktree add fails with "invalid reference: main."', + ); + }); + + it("does not cut at dots inside file names or long dashes", () => { + expect( + liveThoughtLine("I read ThreadLaunchService.ts \u2014 it skips the fetch. Next step."), + ).toBe("I read ThreadLaunchService.ts \u2014 it skips the fetch."); + }); + + it("uses a bold-only opening line as the whole line", () => { + expect( + liveThoughtLine("**Narrowing dispatch files**\n\nI should check the adapter. Then more."), + ).toBe("Narrowing dispatch files"); + expect(liveThoughtLine("**Narrowing dispatch files**\r\n\r\nI should check the adapter.")).toBe( + "Narrowing dispatch files", + ); + }); + + it("returns unpunctuated text whole and flattens markdown", () => { + expect(liveThoughtLine("- Checking [the docs](https://x.dev) for **limits**")).toBe( + "Checking the docs for limits", + ); + expect(liveThoughtLine("This is *really* ~~not~~ _fine_ in snake_case_names.")).toBe( + "This is really not fine in snake_case_names.", + ); + expect(liveThoughtLine(" ")).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f5e9daab749016eff8c60aa48ee202f9e0f050f7..8ef6f58be5954f7c51cec6fd6a77c2292ac42aa3 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -754,3 +754,23 @@ export function toolGroupSummaryKind( ); return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; } + +/** + * Plain-text line for the latest thought in the live activity row. A + * bold-only opening line (the Codex summary heading) wins; otherwise this is + * the first sentence of the reasoning text. Web and mobile both render it. + */ +export function liveThoughtLine(markdown: string): string { + const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1]; + const text = (heading ?? markdown) + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "") + .replace(/`+|\*\*|~~/g, "") + .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); + if (heading !== undefined) return text; + // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows. + const end = /[.?!]["'”’)]?(?=\s)/.exec(text); + return end ? text.slice(0, end.index + end[0].length) : text; +} -- 2.54.0