authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:57:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:57:42-07:00
log3fb8942a427d564cfee4724ad6e40eb9c8881aea
treeb6f1b2715f903c87c116137649afad31d82e4481
parent3be90ced4462f3943b198c10eba55d32563b87b0
signature Signed by PGP key B5690EEEBB952194

fix(mobile): restore live tool shimmer and add a Thinking row (#10173)

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";
20import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";20import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
21import IconBolt from "@tabler/icons-react-native/IconBolt";21import IconBolt from "@tabler/icons-react-native/IconBolt";
22import IconBox from "@tabler/icons-react-native/IconBox";22import IconBox from "@tabler/icons-react-native/IconBox";
23import IconBrain from "@tabler/icons-react-native/IconBrain";
23import IconCamera from "@tabler/icons-react-native/IconCamera";24import IconCamera from "@tabler/icons-react-native/IconCamera";
24import IconChartBar from "@tabler/icons-react-native/IconChartBar";25import IconChartBar from "@tabler/icons-react-native/IconChartBar";
25import IconCheck from "@tabler/icons-react-native/IconCheck";26import 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 }
13731374
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 <ThreadWorkGroupToggle1381 <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}
924924
925export 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
925function ToolActivityIconView(props: {947function 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");
22312236
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 );
22552261
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 };
173179
174export type ThreadFeedLatestTurn = Pick<180export 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>();
210let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null;
204211
205export function isContextCompactionActivityGroup(212export 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}
15751591
1592function 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
1576function appendPresentedFeedEntry(1599function 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 = live1726 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;