| author | |
| committer | |
| log | 3fb8942a427d564cfee4724ad6e40eb9c8881aea |
| tree | b6f1b2715f903c87c116137649afad31d82e4481 |
| parent | 3be90ced4462f3943b198c10eba55d32563b87b0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 170 insertions(+), 5 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; | ... | @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; |
| 20 | import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; | 20 | import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; |
| 21 | import IconBolt from "@tabler/icons-react-native/IconBolt"; | 21 | import IconBolt from "@tabler/icons-react-native/IconBolt"; |
| 22 | import IconBox from "@tabler/icons-react-native/IconBox"; | 22 | import IconBox from "@tabler/icons-react-native/IconBox"; |
| 23 | import IconBrain from "@tabler/icons-react-native/IconBrain"; | ||
| 23 | import IconCamera from "@tabler/icons-react-native/IconCamera"; | 24 | import IconCamera from "@tabler/icons-react-native/IconCamera"; |
| 24 | import IconChartBar from "@tabler/icons-react-native/IconChartBar"; | 25 | import IconChartBar from "@tabler/icons-react-native/IconChartBar"; |
| 25 | import IconCheck from "@tabler/icons-react-native/IconCheck"; | 26 | import IconCheck from "@tabler/icons-react-native/IconCheck"; |
| ... | @@ -109,6 +110,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -109,6 +110,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 109 | "bell.badge": IconBellRinging, | 110 | "bell.badge": IconBellRinging, |
| 110 | "bolt.circle": IconBolt, | 111 | "bolt.circle": IconBolt, |
| 111 | "bolt.horizontal.circle": IconBolt, | 112 | "bolt.horizontal.circle": IconBolt, |
| 113 | brain: IconBrain, | ||
| 112 | camera: IconCamera, | 114 | camera: IconCamera, |
| 113 | "chart.bar.xaxis": IconChartBar, | 115 | "chart.bar.xaxis": IconChartBar, |
| 114 | checkmark: IconCheck, | 116 | checkmark: IconCheck, |
apps/mobile/src/features/threads/ThreadFeed.tsx+6| ... | @@ -155,6 +155,7 @@ import { | ... | @@ -155,6 +155,7 @@ import { |
| 155 | collapsedWorkLogHeight, | 155 | collapsedWorkLogHeight, |
| 156 | ThreadDisclosureChevron, | 156 | ThreadDisclosureChevron, |
| 157 | ThreadWorkGroupToggle, | 157 | ThreadWorkGroupToggle, |
| 158 | ThreadThinkingRow, | ||
| 158 | ThreadWorkLog, | 159 | ThreadWorkLog, |
| 159 | THREAD_DISCLOSURE_TRANSITION_MS, | 160 | THREAD_DISCLOSURE_TRANSITION_MS, |
| 160 | WORK_GROUP_TOGGLE_HEIGHT, | 161 | WORK_GROUP_TOGGLE_HEIGHT, |
| ... | @@ -1371,6 +1372,10 @@ function renderFeedEntry( | ... | @@ -1371,6 +1372,10 @@ function renderFeedEntry( |
| 1371 | ); | 1372 | ); |
| 1372 | } | 1373 | } |
| 1373 | 1374 | ||
| 1375 | if (entry.type === "thinking") { | ||
| 1376 | return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />; | ||
| 1377 | } | ||
| 1378 | |||
| 1374 | if (entry.type === "work-toggle") { | 1379 | if (entry.type === "work-toggle") { |
| 1375 | return ( | 1380 | return ( |
| 1376 | <ThreadWorkGroupToggle | 1381 | <ThreadWorkGroupToggle |
| ... | @@ -2584,6 +2589,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2584,6 +2589,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2584 | case "turn-fold": | 2589 | case "turn-fold": |
| 2585 | return TURN_FOLD_HEIGHT; | 2590 | return TURN_FOLD_HEIGHT; |
| 2586 | case "work-toggle": | 2591 | case "work-toggle": |
| 2592 | case "thinking": | ||
| 2587 | return WORK_GROUP_TOGGLE_HEIGHT; | 2593 | return WORK_GROUP_TOGGLE_HEIGHT; |
| 2588 | case "activity-group": | 2594 | case "activity-group": |
| 2589 | if (isContextCompactionActivityGroup(entry)) { | 2595 | if (isContextCompactionActivityGroup(entry)) { |
apps/mobile/src/features/threads/thread-work-log.tsx+22| ... | @@ -922,6 +922,28 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -922,6 +922,28 @@ export function ThreadWorkGroupToggle(props: { |
| 922 | ); | 922 | ); |
| 923 | } | 923 | } |
| 924 | 924 | ||
| 925 | export function ThreadThinkingRow(props: { | ||
| 926 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | ||
| 927 | readonly iconSubtleColor: ColorValue; | ||
| 928 | }) { | ||
| 929 | return ( | ||
| 930 | <View | ||
| 931 | accessible | ||
| 932 | accessibilityLabel="Thinking" | ||
| 933 | className="-mx-1 min-h-8 flex-row items-center px-1.5 py-0" | ||
| 934 | style={{ minHeight: props.rowSizing.estimatedRowHeight }} | ||
| 935 | > | ||
| 936 | <ShimmeringWorkContent | ||
| 937 | key={props.rowSizing.textSizeKey} | ||
| 938 | icon="brain" | ||
| 939 | iconSubtleColor={props.iconSubtleColor} | ||
| 940 | label="Thinking" | ||
| 941 | showIcon | ||
| 942 | /> | ||
| 943 | </View> | ||
| 944 | ); | ||
| 945 | } | ||
| 946 | |||
| 925 | function ToolActivityIconView(props: { | 947 | function ToolActivityIconView(props: { |
| 926 | readonly environmentId: EnvironmentId; | 948 | readonly environmentId: EnvironmentId; |
| 927 | readonly icon?: ToolActivityIcon; | 949 | readonly icon?: ToolActivityIcon; |
apps/mobile/src/lib/threadActivity.test.ts+111-2| ... | @@ -1566,7 +1566,8 @@ describe("buildThreadFeed", () => { | ... | @@ -1566,7 +1566,8 @@ describe("buildThreadFeed", () => { |
| 1566 | summaryToolIcon: "browser", | 1566 | summaryToolIcon: "browser", |
| 1567 | hasFailure, | 1567 | hasFailure, |
| 1568 | live: true, | 1568 | live: true, |
| 1569 | shimmer: false, | 1569 | // A successful trailing call keeps shining; a failure hands off to "Thinking". |
| 1570 | shimmer: !hasFailure, | ||
| 1570 | }, | 1571 | }, |
| 1571 | { | 1572 | { |
| 1572 | type: "activity-group", | 1573 | type: "activity-group", |
| ... | @@ -1580,6 +1581,7 @@ describe("buildThreadFeed", () => { | ... | @@ -1580,6 +1581,7 @@ describe("buildThreadFeed", () => { |
| 1580 | }, | 1581 | }, |
| 1581 | ], | 1582 | ], |
| 1582 | }, | 1583 | }, |
| 1584 | ...(hasFailure ? [{ type: "thinking", turnId }] : []), | ||
| 1583 | ]); | 1585 | ]); |
| 1584 | const terminalGroup = terminalRows[1]; | 1586 | const terminalGroup = terminalRows[1]; |
| 1585 | if (terminalGroup?.type !== "activity-group") return; | 1587 | if (terminalGroup?.type !== "activity-group") return; |
| ... | @@ -2128,7 +2130,7 @@ describe("buildThreadFeed", () => { | ... | @@ -2128,7 +2130,7 @@ describe("buildThreadFeed", () => { |
| 2128 | ( | 2130 | ( |
| 2129 | [ | 2131 | [ |
| 2130 | { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, | 2132 | { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, |
| 2131 | { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, | 2133 | { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true }, |
| 2132 | { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, | 2134 | { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, |
| 2133 | { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, | 2135 | { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, |
| 2134 | { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, | 2136 | { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, |
| ... | @@ -2228,8 +2230,12 @@ describe("buildThreadFeed", () => { | ... | @@ -2228,8 +2230,12 @@ describe("buildThreadFeed", () => { |
| 2228 | shimmer, | 2230 | shimmer, |
| 2229 | }); | 2231 | }); |
| 2230 | expect(rows[0]).toMatchObject({ live: false, shimmer: false }); | 2232 | expect(rows[0]).toMatchObject({ live: false, shimmer: false }); |
| 2233 | // Exactly one live activity: the shimmering call, or "Thinking" once it fails. | ||
| 2234 | expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1); | ||
| 2235 | expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking"); | ||
| 2231 | 2236 | ||
| 2232 | const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); | 2237 | const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); |
| 2238 | expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false); | ||
| 2233 | expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ | 2239 | expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ |
| 2234 | { live: false, shimmer: false }, | 2240 | { live: false, shimmer: false }, |
| 2235 | { | 2241 | { |
| ... | @@ -2253,6 +2259,109 @@ describe("buildThreadFeed", () => { | ... | @@ -2253,6 +2259,109 @@ describe("buildThreadFeed", () => { |
| 2253 | }, | 2259 | }, |
| 2254 | ); | 2260 | ); |
| 2255 | 2261 | ||
| 2262 | it("shows one Thinking row while a turn works without live tool activity", () => { | ||
| 2263 | const turnId = TurnId.make("turn-thinking"); | ||
| 2264 | const latestTurn = { | ||
| 2265 | turnId, | ||
| 2266 | state: "running" as const, | ||
| 2267 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 2268 | startedAt: "2026-04-01T00:00:00.000Z", | ||
| 2269 | completedAt: null, | ||
| 2270 | assistantMessageId: null, | ||
| 2271 | }; | ||
| 2272 | const feed = buildThreadFeed( | ||
| 2273 | makeThread({ | ||
| 2274 | id: ThreadId.make("thread-thinking"), | ||
| 2275 | projectId: ProjectId.make("project-1"), | ||
| 2276 | title: "Thinking", | ||
| 2277 | latestTurn, | ||
| 2278 | messages: [ | ||
| 2279 | { | ||
| 2280 | id: MessageId.make("user-1"), | ||
| 2281 | role: "user", | ||
| 2282 | text: "hello", | ||
| 2283 | turnId, | ||
| 2284 | streaming: false, | ||
| 2285 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 2286 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 2287 | }, | ||
| 2288 | ], | ||
| 2289 | }), | ||
| 2290 | ); | ||
| 2291 | |||
| 2292 | const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); | ||
| 2293 | expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); | ||
| 2294 | expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); | ||
| 2295 | // The row identity is stable across re-derivations so the list can reuse it. | ||
| 2296 | expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( | ||
| 2297 | rows[1], | ||
| 2298 | ); | ||
| 2299 | // Idle threads show no live activity. | ||
| 2300 | expect( | ||
| 2301 | deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map( | ||
| 2302 | (entry) => entry.type, | ||
| 2303 | ), | ||
| 2304 | ).toEqual(["message"]); | ||
| 2305 | }); | ||
| 2306 | |||
| 2307 | it("hands a settled tool run off to Thinking once assistant text streams after it", () => { | ||
| 2308 | const turnId = TurnId.make("turn-streaming-tail"); | ||
| 2309 | const latestTurn = { | ||
| 2310 | turnId, | ||
| 2311 | state: "running" as const, | ||
| 2312 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 2313 | startedAt: "2026-04-01T00:00:00.000Z", | ||
| 2314 | completedAt: null, | ||
| 2315 | assistantMessageId: null, | ||
| 2316 | }; | ||
| 2317 | const feed = buildThreadFeed( | ||
| 2318 | makeThread({ | ||
| 2319 | id: ThreadId.make("thread-streaming-tail"), | ||
| 2320 | projectId: ProjectId.make("project-1"), | ||
| 2321 | title: "Streaming tail", | ||
| 2322 | latestTurn, | ||
| 2323 | messages: [ | ||
| 2324 | { | ||
| 2325 | id: MessageId.make("assistant-1"), | ||
| 2326 | role: "assistant", | ||
| 2327 | text: "Here is what I found", | ||
| 2328 | turnId, | ||
| 2329 | streaming: true, | ||
| 2330 | createdAt: "2026-04-01T00:00:05.000Z", | ||
| 2331 | updatedAt: "2026-04-01T00:00:06.000Z", | ||
| 2332 | }, | ||
| 2333 | ], | ||
| 2334 | activities: [ | ||
| 2335 | makeActivity({ | ||
| 2336 | id: EventId.make("read-completed"), | ||
| 2337 | kind: "tool.completed", | ||
| 2338 | tone: "tool", | ||
| 2339 | summary: "Read file", | ||
| 2340 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 2341 | turnId, | ||
| 2342 | payload: { | ||
| 2343 | itemType: "file_read", | ||
| 2344 | toolCallId: "read-1", | ||
| 2345 | title: "Read file", | ||
| 2346 | status: "completed", | ||
| 2347 | detail: "src/index.ts", | ||
| 2348 | }, | ||
| 2349 | }), | ||
| 2350 | ], | ||
| 2351 | }), | ||
| 2352 | ); | ||
| 2353 | |||
| 2354 | const rows = deriveThreadFeedPresentation( | ||
| 2355 | feed, | ||
| 2356 | latestTurn, | ||
| 2357 | new Set(), | ||
| 2358 | new Set(), | ||
| 2359 | latestTurn.startedAt, | ||
| 2360 | ); | ||
| 2361 | expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]); | ||
| 2362 | expect(rows[0]).toMatchObject({ live: false, shimmer: false }); | ||
| 2363 | }); | ||
| 2364 | |||
| 2256 | it("preserves serialized shell wrappers with non-matching boundary quotes", () => { | 2365 | it("preserves serialized shell wrappers with non-matching boundary quotes", () => { |
| 2257 | const turnId = TurnId.make("turn-serialized-shell-wrapper"); | 2366 | const turnId = TurnId.make("turn-serialized-shell-wrapper"); |
| 2258 | const command = | 2367 | const command = |
apps/mobile/src/lib/threadActivity.ts+29-3| ... | @@ -169,6 +169,12 @@ export type ThreadFeedEntry = | ... | @@ -169,6 +169,12 @@ export type ThreadFeedEntry = |
| 169 | readonly turnId: TurnId; | 169 | readonly turnId: TurnId; |
| 170 | readonly label: string; | 170 | readonly label: string; |
| 171 | readonly expanded: boolean; | 171 | readonly expanded: boolean; |
| 172 | } | ||
| 173 | | { | ||
| 174 | readonly type: "thinking"; | ||
| 175 | readonly id: string; | ||
| 176 | readonly createdAt: string; | ||
| 177 | readonly turnId: TurnId | null; | ||
| 172 | }; | 178 | }; |
| 173 | 179 | ||
| 174 | export type ThreadFeedLatestTurn = Pick< | 180 | export type ThreadFeedLatestTurn = Pick< |
| ... | @@ -201,6 +207,7 @@ const turnFoldRowsCache = new WeakMap< | ... | @@ -201,6 +207,7 @@ const turnFoldRowsCache = new WeakMap< |
| 201 | ThreadFeedEntry, | 207 | ThreadFeedEntry, |
| 202 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> | 208 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> |
| 203 | >(); | 209 | >(); |
| 210 | let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; | ||
| 204 | 211 | ||
| 205 | export function isContextCompactionActivityGroup( | 212 | export function isContextCompactionActivityGroup( |
| 206 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, | 213 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| ... | @@ -1510,7 +1517,8 @@ export function deriveThreadFeedPresentation( | ... | @@ -1510,7 +1517,8 @@ export function deriveThreadFeedPresentation( |
| 1510 | activeWorkStartedAt: string | null = null, | 1517 | activeWorkStartedAt: string | null = null, |
| 1511 | ): ThreadFeedEntry[] { | 1518 | ): ThreadFeedEntry[] { |
| 1512 | const sourceFeed = feed.filter( | 1519 | const sourceFeed = feed.filter( |
| 1513 | (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", | 1520 | (entry) => |
| 1521 | entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", | ||
| 1514 | ); | 1522 | ); |
| 1515 | const activeTailGroup = sourceFeed.findLast( | 1523 | const activeTailGroup = sourceFeed.findLast( |
| 1516 | (entry) => entry.type !== "message" || !isEmptyMessage(entry), | 1524 | (entry) => entry.type !== "message" || !isEmptyMessage(entry), |
| ... | @@ -1570,12 +1578,27 @@ export function deriveThreadFeedPresentation( | ... | @@ -1570,12 +1578,27 @@ export function deriveThreadFeedPresentation( |
| 1570 | ); | 1578 | ); |
| 1571 | } | 1579 | } |
| 1572 | } | 1580 | } |
| 1581 | // A working turn always shows one live activity. When no tool row is | ||
| 1582 | // shimmering (no tools yet, or the latest failed), that row is "Thinking". | ||
| 1583 | if ( | ||
| 1584 | activeWorkStartedAt !== null && | ||
| 1585 | !result.some((row) => row.type === "work-toggle" && row.shimmer) | ||
| 1586 | ) { | ||
| 1587 | result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); | ||
| 1588 | } | ||
| 1573 | return result; | 1589 | return result; |
| 1574 | } | 1590 | } |
| 1575 | 1591 | ||
| 1592 | function thinkingRow(createdAt: string, turnId: TurnId | null) { | ||
| 1593 | if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { | ||
| 1594 | cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; | ||
| 1595 | } | ||
| 1596 | return cachedThinkingRow; | ||
| 1597 | } | ||
| 1598 | |||
| 1576 | function appendPresentedFeedEntry( | 1599 | function appendPresentedFeedEntry( |
| 1577 | result: ThreadFeedEntry[], | 1600 | result: ThreadFeedEntry[], |
| 1578 | entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>, | 1601 | entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "thinking" }>, |
| 1579 | expandedWorkGroupIds: ReadonlySet<string>, | 1602 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1580 | unsettledTurnId: TurnId | null, | 1603 | unsettledTurnId: TurnId | null, |
| 1581 | isWorking: boolean, | 1604 | isWorking: boolean, |
| ... | @@ -1696,6 +1719,9 @@ function appendToolGroupRows( | ... | @@ -1696,6 +1719,9 @@ function appendToolGroupRows( |
| 1696 | const active = latestActiveActivity !== undefined; | 1719 | const active = latestActiveActivity !== undefined; |
| 1697 | const live = activeTail || active; | 1720 | const live = activeTail || active; |
| 1698 | const latestActivity = latestActiveActivity ?? activities.at(-1)!; | 1721 | const latestActivity = latestActiveActivity ?? activities.at(-1)!; |
| 1722 | // Like web, the trailing run keeps shining after its latest call succeeds; | ||
| 1723 | // only a failed, declined, or stopped call hands the live slot to "Thinking". | ||
| 1724 | const shimmer = active || (activeTail && latestActivity.status === "success"); | ||
| 1699 | const singleActivity = activities.length === 1 ? latestActivity : null; | 1725 | const singleActivity = activities.length === 1 ? latestActivity : null; |
| 1700 | const summary = live | 1726 | const summary = live |
| 1701 | ? liveToolActivitySummary(latestActivity, live) | 1727 | ? liveToolActivitySummary(latestActivity, live) |
| ... | @@ -1751,7 +1777,7 @@ function appendToolGroupRows( | ... | @@ -1751,7 +1777,7 @@ function appendToolGroupRows( |
| 1751 | ...(summaryToolIcon ? { summaryToolIcon } : {}), | 1777 | ...(summaryToolIcon ? { summaryToolIcon } : {}), |
| 1752 | hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", | 1778 | hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", |
| 1753 | live, | 1779 | live, |
| 1754 | shimmer: active, | 1780 | shimmer, |
| 1755 | }); | 1781 | }); |
| 1756 | if (!expanded) { | 1782 | if (!expanded) { |
| 1757 | return; | 1783 | return; |