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(...@@ -1628,6 +1628,7 @@ function renderFeedEntry(
1628 summaryToolIcon={entry.summaryToolIcon}1628 summaryToolIcon={entry.summaryToolIcon}
1629 hasFailure={entry.hasFailure}1629 hasFailure={entry.hasFailure}
1630 shimmer={entry.shimmer}1630 shimmer={entry.shimmer}
1631 thought={entry.thought}
1631 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}1632 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
1632 />1633 />
1633 );1634 );
...@@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2937 case "run-fold":2938 case "run-fold":
2938 return resolveThreadFeedFixedItemSize(entry.type);2939 return resolveThreadFeedFixedItemSize(entry.type);
2939 case "work-toggle":2940 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;
2940 case "thinking":2943 case "thinking":
2941 return WORK_GROUP_TOGGLE_HEIGHT;2944 return WORK_GROUP_TOGGLE_HEIGHT;
2942 case "activity-group":2945 case "activity-group":
apps/mobile/src/features/threads/thread-work-log.tsx+25-3
...@@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: {...@@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: {
1222 readonly toolIcon?: ToolActivityIcon;1222 readonly toolIcon?: ToolActivityIcon;
1223 readonly hasFailure: boolean;1223 readonly hasFailure: boolean;
1224 readonly shimmer: boolean;1224 readonly shimmer: boolean;
1225 readonly thought?: string | undefined;
1225 readonly onToggle: () => void;1226 readonly onToggle: () => void;
1226}) {1227}) {
1227 const accessibilityLabel = props.hasFailure1228 const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary;
1228 ? `${props.summary}, tool call failed`1229 const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel;
1229 : props.summary;
1230 const icon =1230 const icon =
1231 props.summaryToolIcon ??1231 props.summaryToolIcon ??
1232 (props.toolSurface1232 (props.toolSurface
...@@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: {...@@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: {
12351235
1236 return (1236 return (
1237 <WorkLogBlock layout="group-header">1237 <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}
1238 <WorkLogPressable1260 <WorkLogPressable
1239 accessibilityRole="button"1261 accessibilityRole="button"
1240 accessibilityState={{ expanded: props.expanded }}1262 accessibilityState={{ expanded: props.expanded }}
apps/mobile/src/lib/threadActivity.test.ts+35-5
...@@ -2279,7 +2279,8 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(...@@ -2279,7 +2279,8 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(
2279 );2279 );
2280 if (text) {2280 if (text) {
2281 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({2281 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
2282 summary: "First paragraph. Second paragraph.",2282 summary: "Thinking",
2283 thought: "First paragraph.",
2283 live: true,2284 live: true,
2284 });2285 });
2285 } else {2286 } else {
...@@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(...@@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])(
2293 },2294 },
2294);2295);
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
2296it("stops stranded thinking after a steer and follows the next thought or tool", () => {2323it("stops stranded thinking after a steer and follows the next thought or tool", () => {
2297 const at = "2026-06-20T00:00:02.000Z";2324 const at = "2026-06-20T00:00:02.000Z";
2298 const thought = (id: string): OrchestrationV2TurnItem => ({2325 const thought = (id: string): OrchestrationV2TurnItem => ({
...@@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool",...@@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool",
2316 at,2343 at,
2317 );2344 );
2318 expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({2345 expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({
2319 summary: "first-thought",2346 summary: "Thinking",
2347 thought: "first-thought",
2320 live: true,2348 live: true,
2321 shimmer: true,2349 shimmer: true,
2322 });2350 });
...@@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool",...@@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool",
2340 ]) {2368 ]) {
2341 const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer);2369 const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer);
2342 expect(live).toHaveLength(1);2370 expect(live).toHaveLength(1);
2343 expect(live[0]).toMatchObject({2371 expect(live[0]).toMatchObject(
2344 summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp",2372 items.at(-1)!.type === "reasoning"
2345 });2373 ? { summary: "Thinking", thought: "next-thought" }
2374 : { summary: "Running vp" },
2375 );
2346 }2376 }
2347 expect(first.status).toBe("running");2377 expect(first.status).toBe("running");
2348});2378});
apps/mobile/src/lib/threadActivity.ts+14-1
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "@t3tools/client-runtime/work-log/command-label";18} from "@t3tools/client-runtime/work-log/command-label";
19import {19import {
20 contextCompactionLabel,20 contextCompactionLabel,
21 liveThoughtLine,
21 toolItemForDisplay,22 toolItemForDisplay,
22 workEntryDisplayIndicatesToolFailure,23 workEntryDisplayIndicatesToolFailure,
23 liveActivityToolStatus,24 liveActivityToolStatus,
...@@ -213,6 +214,8 @@ type ThreadFeedEntryContent =...@@ -213,6 +214,8 @@ type ThreadFeedEntryContent =
213 readonly hasFailure: boolean;214 readonly hasFailure: boolean;
214 readonly live: boolean;215 readonly live: boolean;
215 readonly shimmer: boolean;216 readonly shimmer: boolean;
217 /** First sentence of the latest thought, shown above the live status line. */
218 readonly thought?: string;
216 }219 }
217 | {220 | {
218 readonly type: "run-fold";221 readonly type: "run-fold";
...@@ -1496,8 +1499,17 @@ function appendToolGroupRows(...@@ -1496,8 +1499,17 @@ function appendToolGroupRows(
1496 const shimmer = activeTail && (active || latestActivity.status === "success");1499 const shimmer = activeTail && (active || latestActivity.status === "success");
1497 const singleActivity = activities.length === 1 ? latestActivity : null;1500 const singleActivity = activities.length === 1 ? latestActivity : null;
1498 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));1501 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) : "";
1499 const summary = live1511 const summary = live
1500 ? expanded && latestActivity.workEntry.itemType === "reasoning"1512 ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning"
1501 ? latestActivity.lifecycleStatus === "inProgress"1513 ? latestActivity.lifecycleStatus === "inProgress"
1502 ? "Thinking"1514 ? "Thinking"
1503 : "Thought"1515 : "Thought"
...@@ -1552,6 +1564,7 @@ function appendToolGroupRows(...@@ -1552,6 +1564,7 @@ function appendToolGroupRows(
1552 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),1564 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
1553 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),1565 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
1554 ...(summaryToolIcon ? { summaryToolIcon } : {}),1566 ...(summaryToolIcon ? { summaryToolIcon } : {}),
1567 ...(thought ? { thought } : {}),
1555 hasFailure: (() => {1568 hasFailure: (() => {
1556 const lastToolLike = activities.findLast((activity) => activity.toolLike);1569 const lastToolLike = activities.findLast((activity) => activity.toolLike);
1557 return (1570 return (
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+54
...@@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => {
2044 expect(rows.some((row) => row.kind === "thinking")).toBe(false);2044 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
2045 });2045 });
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
2047 it("keeps an actually running tool in the shared activity row", () => {2101 it("keeps an actually running tool in the shared activity row", () => {
2048 const rows = deriveMessagesTimelineRows({2102 const rows = deriveMessagesTimelineRows({
2049 timelineEntries: [2103 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+6
...@@ -540,6 +540,8 @@ type MessagesTimelineRowContent =...@@ -540,6 +540,8 @@ type MessagesTimelineRowContent =
540 groupId: string;540 groupId: string;
541 expanded: boolean;541 expanded: boolean;
542 active: boolean;542 active: boolean;
543 /** Latest reasoning in the live group, shown above the status line. */
544 thought?: WorkLogEntry;
543 }545 }
544 | {546 | {
545 kind: "working";547 kind: "working";
...@@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: {
1434 latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&1436 latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&
1435 workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);1437 workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);
14361438
1439 const latestThoughtEntry = visibleActiveToolEntries.findLast(
1440 (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "",
1441 );
1437 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;1442 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
1438 const activeWorkRow =1443 const activeWorkRow =
1439 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed1444 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
...@@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: {
1450 groupId,1455 groupId,
1451 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,1456 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
1452 active: latestToolKeepsActivityLive,1457 active: latestToolKeepsActivityLive,
1458 ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}),
1453 };1459 };
1454 })()1460 })()
1455 : null;1461 : null;
apps/web/src/components/chat/MessagesTimeline.tsx+39-3
...@@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr...@@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr
40import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";40import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
41import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";41import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
42import {42import {
43 liveThoughtLine,
43 resolveWorkEntryToolPresentation,44 resolveWorkEntryToolPresentation,
44 resolveViewedImageAsset,45 resolveViewedImageAsset,
45 workEntryViewedImagePath,46 workEntryViewedImagePath,
...@@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "...@@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
3780 const threadLabel = threadReadLabel(label, threadTarget);3781 const threadLabel = threadReadLabel(label, threadTarget);
37813782
3782 const failed = workEntryDisplayIndicatesToolFailure(row.entry);3783 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
3784 return (3789 return (
3785 <button3790 <button
3786 type="button"3791 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"3792 className={cn(
3788 aria-label={failed ? `${threadLabel?.text ?? label}, tool call failed` : undefined}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 }
3789 aria-expanded={row.expanded}3801 aria-expanded={row.expanded}
3790 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}3802 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
3791 >3803 >
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}
3792 <LiveActivityRow3822 <LiveActivityRow
3793 label={3823 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) ? (
3795 <span className="flex min-w-0 gap-1.5">3831 <span className="flex min-w-0 gap-1.5">
3796 <span className="min-w-0 truncate">{label}</span>3832 <span className="min-w-0 truncate">{label}</span>
3797 <span className="min-w-0 truncate text-foreground">3833 <span className="min-w-0 truncate text-foreground">
apps/web/src/components/chat/WorkLog.tsx+7-1
...@@ -46,7 +46,13 @@ type RowContent = {...@@ -46,7 +46,13 @@ type RowContent = {
4646
47function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) {47function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) {
48 return (48 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 >
50 {icon ? (56 {icon ? (
51 <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span>57 <span className="relative flex size-6 shrink-0 items-center justify-center">{icon}</span>
52 ) : null}58 ) : null}
packages/client-runtime/src/work-log/presentation.test.ts+38
...@@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";...@@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
7import {7import {
8 commandDetailRepeatsCommand,8 commandDetailRepeatsCommand,
9 extractCommandOutputText,9 extractCommandOutputText,
10 liveThoughtLine,
10 resolveViewedImageAsset,11 resolveViewedImageAsset,
11 resolveWorkEntryToolPresentation,12 resolveWorkEntryToolPresentation,
12 summarizeToolGroup,13 summarizeToolGroup,
...@@ -960,3 +961,40 @@ describe("device group summaries", () => {...@@ -960,3 +961,40 @@ describe("device group summaries", () => {
960 ).toBe("Used 1 tool");961 ).toBe("Used 1 tool");
961 });962 });
962});963});
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(...@@ -754,3 +754,23 @@ export function toolGroupSummaryKind(
754 );754 );
755 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";755 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
756}756}
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}