authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 15:06:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 15:06:03-07:00
log95ccbf406626a8a80fa545777a6388bdf5153086
treef9d213fc4ccce1add716d9b1cb776746e55b52c3
parentabbf5d5bcd045cdb6baf16128ef54d4df3a7f3fd
signature Signed by PGP key B5690EEEBB952194

feat(clients): live row shows the agent's latest thought (#16284)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

10 files changed, 241 insertions(+), 13 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+3
......@@ -1628,6 +1628,7 @@ function renderFeedEntry(
16281628 summaryToolIcon={entry.summaryToolIcon}
16291629 hasFailure={entry.hasFailure}
16301630 shimmer={entry.shimmer}
1631 thought={entry.thought}
16311632 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
16321633 />
16331634 );
......@@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29372938 case "run-fold":
29382939 return resolveThreadFeedFixedItemSize(entry.type);
29392940 case "work-toggle":
2941 // A live thought wraps up to four lines, so that row measures itself.
2942 return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT;
29402943 case "thinking":
29412944 return WORK_GROUP_TOGGLE_HEIGHT;
29422945 case "activity-group":
apps/mobile/src/features/threads/thread-work-log.tsx+25-3
......@@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: {
12221222 readonly toolIcon?: ToolActivityIcon;
12231223 readonly hasFailure: boolean;
12241224 readonly shimmer: boolean;
1225 readonly thought?: string | undefined;
12251226 readonly onToggle: () => void;
12261227}) {
1227 const accessibilityLabel = props.hasFailure
1228 ? `${props.summary}, tool call failed`
1229 : props.summary;
1228 const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary;
1229 const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel;
12301230 const icon =
12311231 props.summaryToolIcon ??
12321232 (props.toolSurface
......@@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: {
12351235
12361236 return (
12371237 <WorkLogBlock layout="group-header">
1238 {props.thought ? (
1239 // The latest thought sits above the status line as an ordinary work-log
1240 // row, wrapped up to four lines.
1241 <Pressable
1242 accessible={false}
1243 onPress={props.onToggle}
1244 className="flex-row items-start gap-1.5 rounded-md px-0.5 active:bg-subtle"
1245 >
1246 <WorkLogIconSlot>
1247 <WorkLogIcon icon="brain" color={props.iconSubtleColor} />
1248 </WorkLogIconSlot>
1249 <Text
1250 key={props.rowSizing.textSizeKey}
1251 selectable={false}
1252 numberOfLines={4}
1253 ellipsizeMode="tail"
1254 className="min-w-0 flex-1 py-0.5 text-sm text-foreground-muted"
1255 >
1256 {props.thought}
1257 </Text>
1258 </Pressable>
1259 ) : null}
12381260 <WorkLogPressable
12391261 accessibilityRole="button"
12401262 accessibilityState={{ expanded: props.expanded }}
apps/mobile/src/lib/threadActivity.test.ts+35-5
......@@ -2279,7 +2279,8 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(
22792279 );
22802280 if (text) {
22812281 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
2282 summary: "First paragraph. Second paragraph.",
2282 summary: "Thinking",
2283 thought: "First paragraph.",
22832284 live: true,
22842285 });
22852286 } else {
......@@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(
22932294 },
22942295);
22952296
2297it("keeps the latest thought under the live tool status", () => {
2298 const at = "2026-06-20T00:00:02.000Z";
2299 const thought: OrchestrationV2TurnItem = {
2300 ...base("found-thought", at, 1),
2301 type: "reasoning",
2302 status: "completed",
2303 streaming: false,
2304 text: "Found the cause: no commits yet. Checking the UI next.",
2305 };
2306 const tool = { ...command(at), status: "running" as const, completedAt: null };
2307 const rows = deriveThreadFeedPresentation(
2308 buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]),
2309 { runId, status: "running", startedAt: at, completedAt: null },
2310 new Set(),
2311 new Set(),
2312 at,
2313 );
2314 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
2315 thought: "Found the cause: no commits yet.",
2316 live: true,
2317 });
2318 expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({
2319 summary: "Thinking",
2320 });
2321});
2322
22962323it("stops stranded thinking after a steer and follows the next thought or tool", () => {
22972324 const at = "2026-06-20T00:00:02.000Z";
22982325 const thought = (id: string): OrchestrationV2TurnItem => ({
......@@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool",
23162343 at,
23172344 );
23182345 expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({
2319 summary: "first-thought",
2346 summary: "Thinking",
2347 thought: "first-thought",
23202348 live: true,
23212349 shimmer: true,
23222350 });
......@@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool",
23402368 ]) {
23412369 const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer);
23422370 expect(live).toHaveLength(1);
2343 expect(live[0]).toMatchObject({
2344 summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp",
2345 });
2371 expect(live[0]).toMatchObject(
2372 items.at(-1)!.type === "reasoning"
2373 ? { summary: "Thinking", thought: "next-thought" }
2374 : { summary: "Running vp" },
2375 );
23462376 }
23472377 expect(first.status).toBe("running");
23482378});
apps/mobile/src/lib/threadActivity.ts+14-1
......@@ -18,6 +18,7 @@ import {
1818} from "@t3tools/client-runtime/work-log/command-label";
1919import {
2020 contextCompactionLabel,
21 liveThoughtLine,
2122 toolItemForDisplay,
2223 workEntryDisplayIndicatesToolFailure,
2324 liveActivityToolStatus,
......@@ -213,6 +214,8 @@ type ThreadFeedEntryContent =
213214 readonly hasFailure: boolean;
214215 readonly live: boolean;
215216 readonly shimmer: boolean;
217 /** First sentence of the latest thought, shown above the live status line. */
218 readonly thought?: string;
216219 }
217220 | {
218221 readonly type: "run-fold";
......@@ -1496,8 +1499,17 @@ function appendToolGroupRows(
14961499 const shimmer = activeTail && (active || latestActivity.status === "success");
14971500 const singleActivity = activities.length === 1 ? latestActivity : null;
14981501 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));
1502 const latestThought =
1503 live && !expanded
1504 ? activities.findLast(
1505 (activity) =>
1506 activity.workEntry.itemType === "reasoning" &&
1507 (activity.workEntry.detail?.trim() ?? "") !== "",
1508 )?.workEntry.detail
1509 : undefined;
1510 const thought = latestThought ? liveThoughtLine(latestThought) : "";
14991511 const summary = live
1500 ? expanded && latestActivity.workEntry.itemType === "reasoning"
1512 ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning"
15011513 ? latestActivity.lifecycleStatus === "inProgress"
15021514 ? "Thinking"
15031515 : "Thought"
......@@ -1552,6 +1564,7 @@ function appendToolGroupRows(
15521564 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
15531565 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
15541566 ...(summaryToolIcon ? { summaryToolIcon } : {}),
1567 ...(thought ? { thought } : {}),
15551568 hasFailure: (() => {
15561569 const lastToolLike = activities.findLast((activity) => activity.toolLike);
15571570 return (
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+54
......@@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => {
20442044 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
20452045 });
20462046
2047 it("carries the latest thought on the live row while a later tool runs", () => {
2048 type WorkEntry = Extract<
2049 Parameters<typeof deriveMessagesTimelineRows>[0]["timelineEntries"][number],
2050 { kind: "work" }
2051 >;
2052 const work = (
2053 id: string,
2054 at: string,
2055 fields: Omit<WorkEntry["entry"], "id" | "createdAt" | "runId" | "label">,
2056 ): WorkEntry => ({
2057 id: `${id}-entry`,
2058 kind: "work",
2059 createdAt: at,
2060 entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields },
2061 });
2062 const rows = deriveMessagesTimelineRows({
2063 timelineEntries: [
2064 work("old-thought", "2026-01-01T00:00:01Z", {
2065 itemType: "reasoning",
2066 detail: "First idea.",
2067 tone: "thinking" as const,
2068 toolLifecycleStatus: "completed" as const,
2069 }),
2070 work("new-thought", "2026-01-01T00:00:02Z", {
2071 itemType: "reasoning",
2072 detail: "Found the cause.",
2073 tone: "thinking" as const,
2074 toolLifecycleStatus: "completed" as const,
2075 }),
2076 work("running-command", "2026-01-01T00:00:03Z", {
2077 command: "rg cause",
2078 requestKind: "command",
2079 tone: "tool" as const,
2080 toolLifecycleStatus: "inProgress" as const,
2081 }),
2082 ],
2083 latestRun: {
2084 runId: "turn-1" as never,
2085 status: "running",
2086 startedAt: "2026-01-01T00:00:00Z",
2087 completedAt: null,
2088 },
2089 isWorking: true,
2090 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2091 turnDiffSummaries: [],
2092 supportsConversationRollback: false,
2093 });
2094
2095 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
2096 entry: { id: "running-command" },
2097 thought: { id: "new-thought" },
2098 });
2099 });
2100
20472101 it("keeps an actually running tool in the shared activity row", () => {
20482102 const rows = deriveMessagesTimelineRows({
20492103 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+6
......@@ -540,6 +540,8 @@ type MessagesTimelineRowContent =
540540 groupId: string;
541541 expanded: boolean;
542542 active: boolean;
543 /** Latest reasoning in the live group, shown above the status line. */
544 thought?: WorkLogEntry;
543545 }
544546 | {
545547 kind: "working";
......@@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: {
14341436 latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&
14351437 workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);
14361438
1439 const latestThoughtEntry = visibleActiveToolEntries.findLast(
1440 (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "",
1441 );
14371442 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
14381443 const activeWorkRow =
14391444 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
......@@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: {
14501455 groupId,
14511456 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
14521457 active: latestToolKeepsActivityLive,
1458 ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}),
14531459 };
14541460 })()
14551461 : null;
apps/web/src/components/chat/MessagesTimeline.tsx+39-3
......@@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr
4040import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
4141import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
4242import {
43 liveThoughtLine,
4344 resolveWorkEntryToolPresentation,
4445 resolveViewedImageAsset,
4546 workEntryViewedImagePath,
......@@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
37803781 const threadLabel = threadReadLabel(label, threadTarget);
37813782
37823783 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
3784 // The expanded group already lists the thought, so the preview steps aside.
3785 const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : "";
3786 // While the thought itself streams, the status line just says Thinking.
3787 const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought;
37833788
37843789 return (
37853790 <button
37863791 type="button"
3787 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
3788 aria-label={failed ? `${threadLabel?.text ?? label}, tool call failed` : undefined}
3792 className={cn(
3793 "group/live-work flex w-full max-w-full cursor-pointer rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
3794 thoughtLine ? "flex-col items-start" : "min-h-6 items-center",
3795 )}
3796 aria-label={
3797 failed
3798 ? `${thoughtLine ? `${thoughtLine} ` : ""}${threadLabel?.text ?? label}, tool call failed`
3799 : undefined
3800 }
37893801 aria-expanded={row.expanded}
37903802 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
37913803 >
3804 {thoughtLine ? (
3805 // The latest thought's first sentence sits above the status line as an
3806 // ordinary work-log row, so a finding never hides behind the next tool call.
3807 <WorkLogRow
3808 icon={
3809 <span className="flex size-4 items-center justify-center text-icon-muted">
3810 <ToolActivityIconView
3811 icon={undefined}
3812 fallbackName="brain"
3813 className="block size-4 shrink-0 stroke-2"
3814 muted
3815 />
3816 </span>
3817 }
3818 label={<span className="line-clamp-4">{thoughtLine}</span>}
3819 wrapLabel
3820 />
3821 ) : null}
37923822 <LiveActivityRow
37933823 label={
3794 row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? (
3824 thoughtIsStatus ? (
3825 row.active ? (
3826 "Thinking"
3827 ) : (
3828 "Thought"
3829 )
3830 ) : row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? (
37953831 <span className="flex min-w-0 gap-1.5">
37963832 <span className="min-w-0 truncate">{label}</span>
37973833 <span className="min-w-0 truncate text-foreground">
apps/web/src/components/chat/WorkLog.tsx+7-1
......@@ -46,7 +46,13 @@ type RowContent = {
4646
4747function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) {
4848 return (
49 <div className="flex min-h-6 min-w-0 items-center gap-1.5 text-sm leading-relaxed select-none [&_*]:select-none">
49 <div
50 className={cn(
51 "flex min-h-6 min-w-0 gap-1.5 text-sm leading-relaxed select-none [&_*]:select-none",
52 // A wrapped label keeps its icon beside the first line.
53 wrapLabel ? "items-start" : "items-center",
54 )}
55 >
5056 {icon ? (
5157 <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span>
5258 ) : null}
packages/client-runtime/src/work-log/presentation.test.ts+38
......@@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
77import {
88 commandDetailRepeatsCommand,
99 extractCommandOutputText,
10 liveThoughtLine,
1011 resolveViewedImageAsset,
1112 resolveWorkEntryToolPresentation,
1213 summarizeToolGroup,
......@@ -960,3 +961,40 @@ describe("device group summaries", () => {
960961 ).toBe("Used 1 tool");
961962 });
962963});
964
965describe("liveThoughtLine", () => {
966 it("keeps the first sentence, including a closing quote", () => {
967 expect(
968 liveThoughtLine(
969 'Found the cause: the repo has no commits, so `git worktree add` fails with "invalid reference: main." Now checking the UI.',
970 ),
971 ).toBe(
972 'Found the cause: the repo has no commits, so git worktree add fails with "invalid reference: main."',
973 );
974 });
975
976 it("does not cut at dots inside file names or long dashes", () => {
977 expect(
978 liveThoughtLine("I read ThreadLaunchService.ts \u2014 it skips the fetch. Next step."),
979 ).toBe("I read ThreadLaunchService.ts \u2014 it skips the fetch.");
980 });
981
982 it("uses a bold-only opening line as the whole line", () => {
983 expect(
984 liveThoughtLine("**Narrowing dispatch files**\n\nI should check the adapter. Then more."),
985 ).toBe("Narrowing dispatch files");
986 expect(liveThoughtLine("**Narrowing dispatch files**\r\n\r\nI should check the adapter.")).toBe(
987 "Narrowing dispatch files",
988 );
989 });
990
991 it("returns unpunctuated text whole and flattens markdown", () => {
992 expect(liveThoughtLine("- Checking [the docs](https://x.dev) for **limits**")).toBe(
993 "Checking the docs for limits",
994 );
995 expect(liveThoughtLine("This is *really* ~~not~~ _fine_ in snake_case_names.")).toBe(
996 "This is really not fine in snake_case_names.",
997 );
998 expect(liveThoughtLine(" ")).toBe("");
999 });
1000});
packages/client-runtime/src/work-log/presentation.ts+20
......@@ -754,3 +754,23 @@ export function toolGroupSummaryKind(
754754 );
755755 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
756756}
757
758/**
759 * Plain-text line for the latest thought in the live activity row. A
760 * bold-only opening line (the Codex summary heading) wins; otherwise this is
761 * the first sentence of the reasoning text. Web and mobile both render it.
762 */
763export function liveThoughtLine(markdown: string): string {
764 const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1];
765 const text = (heading ?? markdown)
766 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
767 .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "")
768 .replace(/`+|\*\*|~~/g, "")
769 .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2")
770 .replace(/\s+/g, " ")
771 .trim();
772 if (heading !== undefined) return text;
773 // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows.
774 const end = /[.?!]["'”’)]?(?=\s)/.exec(text);
775 return end ? text.slice(0, end.index + end[0].length) : text;
776}