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