authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 23:04:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 23:04:16-07:00
logb7465a3bc993e7f10f4ec7a469759b95eb4c2f2a
treeb19da481a0bef7d8b84602cc9bb1698be6f09a9a
parent4f782bedaf49b914903eb08501f35c0940845c51
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stop the work log flickering during subagent runs and failing calls (#10273)


4 files changed, 724 insertions(+), 52 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+21-12
......@@ -153,6 +153,7 @@ import {
153153} from "./thread-feed-live-follow";
154154import {
155155 collapsedWorkLogHeight,
156 ThreadAgentSpawnCard,
156157 ThreadDisclosureChevron,
157158 ThreadWorkGroupToggle,
158159 ThreadThinkingRow,
......@@ -504,12 +505,7 @@ function MessageAttachmentFile(props: {
504505function MessageAttachmentUnknown(props: { readonly name: string }) {
505506 return (
506507 <View className="flex-row items-center gap-2 py-1">
507 <SymbolView
508 name="doc.text"
509 size={16}
510 tintColorClassName="accent-icon-subtle"
511 type="monochrome"
512 />
508 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
513509 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
514510 {props.name}
515511 </Text>
......@@ -1357,7 +1353,7 @@ function renderFeedEntry(
13571353 accessibilityState={{ expanded: entry.expanded }}
13581354 onPress={() => props.onToggleTurnFold(entry.turnId)}
13591355 hitSlop={4}
1360 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2"
1356 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
13611357 style={{
13621358 minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
13631359 }}
......@@ -1382,6 +1378,19 @@ function renderFeedEntry(
13821378 return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;
13831379 }
13841380
1381 if (entry.type === "agent-spawn") {
1382 return (
1383 <ThreadAgentSpawnCard
1384 summary={entry.summary}
1385 expanded={entry.expanded}
1386 iconSubtleColor={iconSubtleColor}
1387 rowSizing={props.workRowSizing}
1388 onToggle={() => props.onToggleWorkGroup(entry.id, entry.id)}
1389 onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())}
1390 />
1391 );
1392 }
1393
13851394 if (entry.type === "work-toggle") {
13861395 return (
13871396 <ThreadWorkGroupToggle
......@@ -1411,7 +1420,7 @@ function renderFeedEntry(
14111420 accessibilityLabel={label}
14121421 className="mb-3 flex-row items-center gap-3 px-1 py-1"
14131422 >
1414 <View className="h-px flex-1 bg-border" />
1423 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
14151424 <View className="shrink-0 flex-row items-center gap-1.5">
14161425 <SymbolView
14171426 name="arrow.down.right.and.arrow.up.left"
......@@ -1421,7 +1430,7 @@ function renderFeedEntry(
14211430 />
14221431 <Text className="font-t3-medium text-xs text-foreground-muted">{label}</Text>
14231432 </View>
1424 <View className="h-px flex-1 bg-border" />
1433 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
14251434 </View>
14261435 );
14271436 }
......@@ -1508,7 +1517,7 @@ function renderFeedEntry(
15081517 })}
15091518 </View>
15101519 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
1511 <Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
1520 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
15121521 {timestampLabel}
15131522 </Text>
15141523 {message.text.trim().length > 0 ? (
......@@ -1557,7 +1566,7 @@ function renderFeedEntry(
15571566 attachmentId={attachment.id}
15581567 name={attachment.name}
15591568 mimeType={attachment.mimeType}
1560 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong"
1569 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
15611570 onPressPreview={props.onPressPreview}
15621571 />
15631572 ) : isFileAttachment(attachment) ? (
......@@ -1581,7 +1590,7 @@ function renderFeedEntry(
15811590 buttonSize={28}
15821591 iconSize={13}
15831592 />
1584 <Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
1593 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
15851594 {timestampLabel}
15861595 </Text>
15871596 </View>
apps/mobile/src/features/threads/thread-work-log.tsx+172-23
......@@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText";
3434import { T3Wordmark } from "../../components/T3Wordmark";
3535import { cn } from "../../lib/cn";
3636import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
37import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity";
37import {
38 type AgentSpawnSummary,
39 type ThreadFeedActivity,
40 workEntryRowLabel,
41} from "../../lib/threadActivity";
3842import {
3943 resolveThreadWorkGroupInitialScroll,
4044 shouldFollowThreadWorkGroupAppend,
......@@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: {
135139}
136140
137141function ShimmerWorkContent(props: {
142 readonly compact?: boolean;
138143 readonly environmentId?: EnvironmentId;
139144 readonly highlighted: boolean;
140145 readonly icon: WorkContentIcon;
......@@ -147,26 +152,29 @@ function ShimmerWorkContent(props: {
147152}) {
148153 return (
149154 <View className="flex-row items-center gap-1.5">
150 <View className="h-6 w-6 shrink-0 items-center justify-center">
151 {props.showIcon && props.toolIcon && props.environmentId ? (
152 <ToolActivityIconView
153 environmentId={props.environmentId}
154 icon={props.toolIcon}
155 fallback={props.icon}
156 fallbackColor={props.iconSubtleColor}
157 themeAppearance={props.themeAppearance ?? "light"}
158 />
159 ) : props.showIcon ? (
160 <WorkLogIcon
161 icon={props.icon}
162 color={props.iconSubtleColor}
163 highlighted={props.highlighted}
164 />
165 ) : null}
166 </View>
155 {props.showIcon ? (
156 <View className="h-6 w-6 shrink-0 items-center justify-center">
157 {props.toolIcon && props.environmentId ? (
158 <ToolActivityIconView
159 environmentId={props.environmentId}
160 icon={props.toolIcon}
161 fallback={props.icon}
162 fallbackColor={props.iconSubtleColor}
163 themeAppearance={props.themeAppearance ?? "light"}
164 />
165 ) : (
166 <WorkLogIcon
167 icon={props.icon}
168 color={props.iconSubtleColor}
169 highlighted={props.highlighted}
170 />
171 )}
172 </View>
173 ) : null}
167174 <Text
168175 className={cn(
169 "min-w-0 shrink text-sm",
176 "min-w-0 shrink",
177 props.compact ? "text-xs" : "text-sm",
170178 props.highlighted ? "text-foreground" : "text-foreground-muted",
171179 )}
172180 numberOfLines={1}
......@@ -179,6 +187,8 @@ function ShimmerWorkContent(props: {
179187}
180188
181189export function ShimmeringWorkContent(props: {
190 /** Secondary line: no icon slot, caption size. */
191 readonly compact?: boolean;
182192 readonly environmentId?: EnvironmentId;
183193 readonly icon: WorkContentIcon;
184194 readonly iconSubtleColor: ColorValue;
......@@ -194,7 +204,10 @@ export function ShimmeringWorkContent(props: {
194204 const screenIsFocused = useIsFocused();
195205 const progress = useSharedValue(0);
196206 const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;
197 const contentWidth = Math.min(availableWidth, SHIMMER_ICON_AND_GAP_WIDTH + Math.ceil(textWidth));
207 const contentWidth = Math.min(
208 availableWidth,
209 (props.showIcon ? SHIMMER_ICON_AND_GAP_WIDTH : 0) + Math.ceil(textWidth),
210 );
198211
199212 useEffect(() => {
200213 const subscription = AppState.addEventListener("change", (state) => {
......@@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: {
250263 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
251264 >
252265 <ShimmerWorkContent
266 compact={props.compact}
253267 environmentId={props.environmentId}
254268 highlighted={false}
255269 icon={props.icon}
......@@ -290,6 +304,7 @@ export function ShimmeringWorkContent(props: {
290304 >
291305 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
292306 <ShimmerWorkContent
307 compact={props.compact}
293308 environmentId={props.environmentId}
294309 highlighted
295310 icon={props.icon}
......@@ -773,7 +788,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
773788 color={props.iconSubtleColor}
774789 colorClassName={
775790 iconIsDestructive
776 ? "accent-danger-foreground"
791 ? "accent-adaptive-rose-600-400"
777792 : failed
778793 ? "accent-danger-foreground/40"
779794 : undefined
......@@ -784,7 +799,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
784799 <Text
785800 className={cn(
786801 "min-w-0 flex-1 text-sm text-foreground-muted",
787 iconIsDestructive && "font-t3-medium text-danger-foreground",
802 iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400",
788803 )}
789804 numberOfLines={1}
790805 >
......@@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
832847 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
833848 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
834849 layout={WORK_LOG_LAYOUT_TRANSITION}
835 className="ml-7 border-l border-border pb-1 pl-3 pt-0.5"
850 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
836851 >
837852 {viewedImagePath ? (
838853 <View className="pb-1.5">
......@@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: {
938953 );
939954}
940955
956const AGENT_SPAWN_TONE_DOT_CLASS = {
957 working: "bg-adaptive-sky-600-400",
958 completed: "bg-adaptive-emerald-600-400",
959 failed: "bg-adaptive-rose-600-400",
960 stopped: "bg-foreground-muted",
961} as const satisfies Record<AgentSpawnSummary["tone"], string>;
962
963/**
964 * A batch of spawned subagents. The status line updates in place as members
965 * report progress; expanding lists each member. Text nodes carry keys tied to
966 * the row identity only, so a progress tick re-renders the labels without
967 * remounting the card (see the batch key in appendActivityGroupRows).
968 */
969export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: {
970 readonly summary: AgentSpawnSummary;
971 readonly expanded: boolean;
972 readonly iconSubtleColor: ColorValue;
973 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
974 readonly onToggle: () => void;
975 readonly onCopy: () => void;
976}) {
977 const { summary, expanded } = props;
978 const working = summary.tone === "working";
979 const memberCount = summary.members.length;
980 const canExpand = memberCount > 0;
981 return (
982 <Animated.View layout={WORK_LOG_LAYOUT_TRANSITION} className="-mx-1 mb-1 px-1">
983 <Pressable
984 accessibilityRole={canExpand ? "button" : undefined}
985 accessibilityState={canExpand ? { expanded } : undefined}
986 accessibilityLabel={`${summary.title}, ${summary.status}`}
987 accessibilityHint={
988 canExpand
989 ? `Double tap to ${expanded ? "hide" : "show"} ${memberCount} ${memberCount === 1 ? "subagent" : "subagents"}. Long press to copy.`
990 : "Long press to copy."
991 }
992 hitSlop={4}
993 onPress={() => {
994 if (!canExpand) return;
995 void Haptics.selectionAsync();
996 props.onToggle();
997 }}
998 onLongPress={props.onCopy}
999 className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle"
1000 >
1001 <View className="flex-row items-center gap-2">
1002 <View className="h-6 w-6 shrink-0 items-center justify-center">
1003 <SymbolView
1004 name={{ ios: "sparkles", android: "auto_awesome" }}
1005 size={14}
1006 weight="medium"
1007 tintColor={props.iconSubtleColor}
1008 type="monochrome"
1009 />
1010 </View>
1011 <View className="min-w-0 flex-1 gap-0.5">
1012 <Text
1013 key={props.rowSizing.textSizeKey}
1014 className="font-t3-medium text-sm text-foreground"
1015 numberOfLines={1}
1016 >
1017 {summary.title}
1018 </Text>
1019 <View className="flex-row items-center gap-1.5">
1020 <View
1021 className={cn(
1022 "h-1.5 w-1.5 shrink-0 rounded-full",
1023 AGENT_SPAWN_TONE_DOT_CLASS[summary.tone],
1024 )}
1025 />
1026 {working ? (
1027 <ShimmeringWorkContent
1028 key={props.rowSizing.textSizeKey}
1029 compact
1030 icon="brain"
1031 iconSubtleColor={props.iconSubtleColor}
1032 label={summary.status}
1033 showIcon={false}
1034 />
1035 ) : (
1036 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
1037 {summary.status}
1038 </Text>
1039 )}
1040 </View>
1041 </View>
1042 {canExpand ? (
1043 <ThreadDisclosureChevron
1044 expanded={expanded}
1045 collapsedDirection="down"
1046 size={11}
1047 tintColor={props.iconSubtleColor}
1048 />
1049 ) : null}
1050 </View>
1051 {expanded && canExpand ? (
1052 <Animated.View
1053 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
1054 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
1055 layout={WORK_LOG_LAYOUT_TRANSITION}
1056 className="ml-8 mt-1.5 gap-1.5 border-l border-adaptive-neutral-300-a60-white-a12 pl-3"
1057 >
1058 {summary.members.map((member) => (
1059 <View key={member.title} className="gap-px">
1060 <View className="flex-row items-center gap-1.5">
1061 <View
1062 className={cn(
1063 "h-1.5 w-1.5 shrink-0 rounded-full",
1064 AGENT_SPAWN_TONE_DOT_CLASS[member.tone],
1065 )}
1066 />
1067 <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>
1068 {member.title}
1069 </Text>
1070 <Text className="shrink-0 text-2xs text-foreground-muted">{member.status}</Text>
1071 </View>
1072 {member.detail ? (
1073 <Text
1074 selectable
1075 className="pl-3 font-mono text-2xs leading-normal text-foreground-muted"
1076 numberOfLines={expanded ? 6 : 1}
1077 >
1078 {member.detail}
1079 </Text>
1080 ) : null}
1081 </View>
1082 ))}
1083 </Animated.View>
1084 ) : null}
1085 </Pressable>
1086 </Animated.View>
1087 );
1088});
1089
9411090export function ThreadThinkingRow(props: {
9421091 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
9431092 readonly iconSubtleColor: ColorValue;
apps/mobile/src/lib/threadActivity.test.ts+328-6
......@@ -13,6 +13,7 @@ import {
1313} from "@t3tools/contracts";
1414
1515import {
16 agentSpawnSummary,
1617 buildPendingUserInputAnswers,
1718 buildThreadFeed,
1819 derivePendingApprovals,
......@@ -24,6 +25,7 @@ import {
2425 workEntryRowLabel,
2526 type ThreadFeedActivity,
2627 type ThreadFeedEntry,
28 type WorkLogEntry,
2729} from "./threadActivity";
2830
2931describe("Codex feedback pseudo-messages", () => {
......@@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => {
23032305 new Set(),
23042306 latestTurn.startedAt,
23052307 );
2308 // The shimmering row is the turn's live slot; once it stops shimmering
2309 // the slot belongs to "Thinking" and the group keeps its own identity.
23062310 expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
23072311 ["work-toggle:work-group:activity-1", "work-toggle"],
23082312 ["activity-2", "activity-group"],
2309 ["work-live:work-group:activity-3", "work-toggle"],
2313 [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"],
23102314 ]);
23112315 expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
23122316 false,
......@@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => {
23812385
23822386 const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
23832387 expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);
2384 expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId });
2388 expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId });
23852389 // The row identity is stable across re-derivations so the list can reuse it.
23862390 expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
23872391 rows[1],
......@@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => {
23942398 ).toEqual(["message"]);
23952399 });
23962400
2401 it("keeps one live slot while calls fail and restart", () => {
2402 // Recorded from a Claude session whose Bash was broken: every call went
2403 // inProgress → failed within two seconds. Each transition used to insert
2404 // or remove a Thinking row under the group; now the same row id holds
2405 // the live call and then "Thinking", so the list updates it in place.
2406 const turnId = TurnId.make("turn-failing-calls");
2407 const latestTurn = {
2408 turnId,
2409 state: "running" as const,
2410 requestedAt: "2026-04-01T00:00:00.000Z",
2411 startedAt: "2026-04-01T00:00:00.000Z",
2412 completedAt: null,
2413 assistantMessageId: null,
2414 };
2415 const call = (n: number, status: "inProgress" | "failed") =>
2416 makeActivity({
2417 id: EventId.make(`call-${n}-${status}`),
2418 kind: status === "failed" ? "tool.completed" : "tool.updated",
2419 tone: "tool",
2420 summary: "Command run",
2421 createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`,
2422 turnId,
2423 payload: {
2424 itemType: "command_execution",
2425 toolCallId: `call-${n}`,
2426 title: "Command run",
2427 status,
2428 detail: `Bash: ls ${n}`,
2429 },
2430 });
2431 const liveIds = (activities: ReadonlyArray<ReturnType<typeof makeActivity>>) =>
2432 deriveThreadFeedPresentation(
2433 buildThreadFeed(
2434 makeThread({
2435 id: ThreadId.make("thread-failing-calls"),
2436 projectId: ProjectId.make("project-1"),
2437 title: "Failing calls",
2438 latestTurn,
2439 activities,
2440 }),
2441 ),
2442 latestTurn,
2443 new Set(),
2444 new Set(),
2445 latestTurn.startedAt,
2446 ).map((row) => `${row.type}:${row.id}`);
2447
2448 expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]);
2449 expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([
2450 "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
2451 "thinking:live-activity-row",
2452 ]);
2453 expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([
2454 "work-toggle:live-activity-row",
2455 ]);
2456 // A call whose end was never reported, in a run before an error row,
2457 // keeps its own identity: only the trailing run can hold the live slot.
2458 const errorRow = makeActivity({
2459 id: EventId.make("runtime-error"),
2460 kind: "runtime.error",
2461 tone: "error",
2462 summary: "Provider error",
2463 createdAt: "2026-04-01T00:00:02.500Z",
2464 turnId,
2465 payload: { message: "boom" },
2466 });
2467 expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([
2468 "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
2469 "activity-group:runtime-error",
2470 "work-toggle:live-activity-row",
2471 ]);
2472 });
2473
23972474 it("hands a settled tool run off to Thinking once assistant text streams after it", () => {
23982475 const turnId = TurnId.make("turn-streaming-tail");
23992476 const latestTurn = {
......@@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => {
28132890 }),
28142891 ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
28152892
2893 // The batch anchors on the first task.started: a fixed id and timestamp,
2894 // unlike progress ticks (which the server rewrites in place).
28162895 const running = rowsFor([]);
28172896 expect(running.map((row) => [row.id, row.summary])).toEqual([
2818 ["a-progress", "Kicked off 2 subagents · 2 working"],
2897 ["a-start", "Kicked off 2 subagents · 2 working"],
28192898 ["shell-1", "Run tests"],
28202899 ]);
28212900 expect(running[0]).toMatchObject({
2901 createdAt: "2026-04-01T00:00:01.000Z",
28222902 lifecycleStatus: "inProgress",
28232903 workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
28242904 });
28252905
28262906 const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
28272907 expect(oneDone[0]).toMatchObject({
2828 id: "a-progress",
2908 id: "a-start",
28292909 summary: "Kicked off 2 subagents · 1 working",
28302910 lifecycleStatus: "inProgress",
28312911 });
......@@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => {
28352915 agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
28362916 ]);
28372917 expect(allDone[0]).toMatchObject({
2838 id: "a-progress",
2918 id: "a-start",
28392919 summary: "Ran 2 subagents · 1 failed",
28402920 lifecycleStatus: "failed",
28412921 status: "failure",
......@@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => {
28432923 expect(allDone).toHaveLength(2);
28442924 });
28452925
2926 it("folds the tool call that launched an agent into its spawn card", () => {
2927 const turnId = TurnId.make("turn-agent-tool");
2928 const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
2929 const feed = buildThreadFeed(
2930 makeThread({
2931 id: ThreadId.make("thread-agent-tool"),
2932 projectId: ProjectId.make("project-1"),
2933 title: "Agent tool",
2934 activities: [
2935 makeActivity({
2936 id: EventId.make("agent-call-updated"),
2937 kind: "tool.updated",
2938 tone: "tool",
2939 summary: "Subagent task",
2940 createdAt: at(1),
2941 turnId,
2942 payload: {
2943 itemType: "collab_agent_tool_call",
2944 toolCallId: "toolu_agent",
2945 status: "inProgress",
2946 title: "Subagent task",
2947 detail: "Locate code",
2948 data: { toolName: "Agent" },
2949 },
2950 }),
2951 makeActivity({
2952 id: EventId.make("agent-started"),
2953 kind: "task.started",
2954 summary: "Locate code",
2955 createdAt: at(2),
2956 turnId,
2957 payload: {
2958 taskId: "a1",
2959 agentKind: "agent",
2960 taskType: "local_agent",
2961 title: "Locate code",
2962 toolUseId: "toolu_agent",
2963 },
2964 }),
2965 makeActivity({
2966 id: EventId.make("agent-done"),
2967 kind: "task.completed",
2968 summary: "Locate code",
2969 createdAt: at(3),
2970 turnId,
2971 payload: {
2972 taskId: "a1",
2973 agentKind: "agent",
2974 taskType: "local_agent",
2975 title: "Locate code",
2976 toolUseId: "toolu_agent",
2977 status: "completed",
2978 },
2979 }),
2980 makeActivity({
2981 id: EventId.make("agent-call-completed"),
2982 kind: "tool.completed",
2983 tone: "tool",
2984 summary: "Subagent task",
2985 createdAt: at(4),
2986 turnId,
2987 payload: {
2988 itemType: "collab_agent_tool_call",
2989 toolCallId: "toolu_agent",
2990 status: "completed",
2991 title: "Subagent task",
2992 detail: "Locate code",
2993 data: { toolName: "Agent" },
2994 },
2995 }),
2996 ],
2997 }),
2998 );
2999 const rows = feed.flatMap((entry) =>
3000 entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
3001 );
3002 expect(rows).toEqual(["agent-started"]);
3003 expect(
3004 deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type),
3005 ).toEqual(["turn-fold", "agent-spawn"]);
3006 });
3007
3008 it("presents a spawn batch as one card whose status line follows the newest member activity", () => {
3009 const turnId = TurnId.make("turn-spawn-card");
3010 const latestTurn = {
3011 turnId,
3012 state: "running" as const,
3013 requestedAt: "2026-04-01T00:00:00.000Z",
3014 startedAt: "2026-04-01T00:00:00.000Z",
3015 completedAt: null,
3016 assistantMessageId: null,
3017 };
3018 const agent = (
3019 id: string,
3020 kind: "task.started" | "task.progress" | "task.completed",
3021 taskId: string,
3022 seconds: number,
3023 extra: Record<string, unknown> = {},
3024 ) =>
3025 makeActivity({
3026 id: EventId.make(id),
3027 kind,
3028 summary: `Agent ${taskId}`,
3029 createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
3030 turnId,
3031 payload: {
3032 taskId,
3033 agentKind: "agent",
3034 taskType: "local_agent",
3035 title: `Agent ${taskId}`,
3036 ...extra,
3037 },
3038 });
3039 const presentFor = (activities: ReadonlyArray<ReturnType<typeof makeActivity>>) =>
3040 deriveThreadFeedPresentation(
3041 buildThreadFeed(
3042 makeThread({
3043 id: ThreadId.make("thread-spawn-card"),
3044 projectId: ProjectId.make("project-1"),
3045 title: "Spawn card",
3046 latestTurn,
3047 activities,
3048 }),
3049 ),
3050 latestTurn,
3051 new Set(),
3052 new Set(),
3053 latestTurn.startedAt,
3054 );
3055
3056 // A working card is the live activity; no Thinking row sits under it.
3057 const single = presentFor([agent("a-start", "task.started", "a", 1)]);
3058 expect(single.map((row) => row.type)).toEqual(["agent-spawn"]);
3059 expect(single[0]).toMatchObject({
3060 id: `agent-spawn:${turnId}`,
3061 summary: { title: "Agent a", status: "Working", tone: "working" },
3062 });
3063
3064 // The server upserts the progress row with a new createdAt each tick;
3065 // the card keeps its identity and only the status line changes.
3066 const tick = (seconds: number, detail: string) =>
3067 presentFor([
3068 agent("a-start", "task.started", "a", 1),
3069 agent("task-progress:a", "task.progress", "a", seconds, { detail }),
3070 ]);
3071 expect(tick(2, "Reading a.ts")[0]).toMatchObject({
3072 id: `agent-spawn:${turnId}`,
3073 createdAt: "2026-04-01T00:00:01.000Z",
3074 summary: { title: "Agent a", status: "Reading a.ts", tone: "working" },
3075 });
3076 expect(tick(3, "Reading b.ts")[0]).toMatchObject({
3077 id: `agent-spawn:${turnId}`,
3078 createdAt: "2026-04-01T00:00:01.000Z",
3079 summary: { status: "Reading b.ts" },
3080 });
3081
3082 const batch = presentFor([
3083 agent("a-start", "task.started", "a", 1),
3084 agent("b-start", "task.started", "b", 2),
3085 agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }),
3086 agent("a-done", "task.completed", "a", 4, { status: "completed" }),
3087 ]);
3088 expect(batch[0]).toMatchObject({
3089 id: `agent-spawn:${turnId}`,
3090 summary: {
3091 title: "2 subagents",
3092 status: "Grepping",
3093 tone: "working",
3094 members: [
3095 { title: "Agent a", status: "completed", tone: "completed" },
3096 { title: "Agent b", status: "working", tone: "working", detail: "Grepping" },
3097 ],
3098 },
3099 });
3100
3101 const settled = presentFor([
3102 agent("a-start", "task.started", "a", 1),
3103 agent("b-start", "task.started", "b", 2),
3104 agent("a-done", "task.completed", "a", 4, { status: "completed" }),
3105 agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }),
3106 ]);
3107 expect(settled[0]).toMatchObject({
3108 type: "agent-spawn",
3109 summary: { title: "2 subagents", status: "1 failed", tone: "failed" },
3110 });
3111 expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]);
3112 });
3113
28463114 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
28473115 "replaces Antigravity batch progress with %s",
28483116 (status) => {
......@@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => {
29883256 expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
29893257 });
29903258
3259 it("summarizes a spawn card from the newest member report and the batch outcome", () => {
3260 type Member = NonNullable<WorkLogEntry["agentSpawn"]>["agents"][number];
3261 const member = (title: string, status: Member["status"], detail: string, seconds: number) =>
3262 ({
3263 title,
3264 status,
3265 detail,
3266 updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
3267 }) satisfies Member;
3268 const direct = (agents: ReadonlyArray<Member>) => ({
3269 workflowId: null,
3270 agentTaskIds: agents.map((_, index) => `a${index}`),
3271 agents,
3272 });
3273
3274 // The newest report wins regardless of member order.
3275 expect(
3276 agentSpawnSummary(
3277 direct([
3278 member("Agent 0", "inProgress", "Reading b.ts", 5),
3279 member("Agent 1", "inProgress", "Reading a.ts", 2),
3280 ]),
3281 "inProgress",
3282 ),
3283 ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" });
3284
3285 // A declined request is a failed batch, not a completed one.
3286 expect(
3287 agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"),
3288 ).toMatchObject({ status: "failed", tone: "failed" });
3289
3290 // A coordinator that failed on its own reports the failure even when every
3291 // member succeeded; before any member reports, the card has a neutral title.
3292 const workflow = (agents: ReadonlyArray<Member>) => ({
3293 workflowId: "wf",
3294 agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)],
3295 agents: [member("review", "failed", "", 9), ...agents],
3296 });
3297 expect(
3298 agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"),
3299 ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" });
3300 expect(
3301 agentSpawnSummary(
3302 { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] },
3303 "inProgress",
3304 ),
3305 ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] });
3306 });
3307
29913308 it("treats a Codex child's idle turn end as a finished batch member", () => {
29923309 const turnId = TurnId.make("turn-codex");
29933310 const child = (
......@@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => {
30633380 expect(ids).toContain("nested-done");
30643381 expect(ids).not.toContain("shell-done");
30653382 expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
3066 { type: "activity-group", id: "nested-done" },
3383 {
3384 type: "agent-spawn",
3385 id: "agent-spawn:n-1",
3386 activity: { id: "nested-done" },
3387 summary: { title: "Task completed", status: "completed", tone: "completed" },
3388 },
30673389 ]);
30683390 });
30693391});
apps/mobile/src/lib/threadActivity.ts+203-11
......@@ -122,6 +122,8 @@ export interface WorkLogEntry {
122122 readonly title: string;
123123 readonly status: WorkLogToolLifecycleStatus | undefined;
124124 readonly detail: string | undefined;
125 /** When this member last reported, so the card can show the newest activity. */
126 readonly updatedAt: string;
125127 }>;
126128 };
127129 toolData?: unknown;
......@@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry {
132134 collapseKey?: string;
133135 /** Grouping key for subagent lifecycle rows (one row per agent). */
134136 taskId?: string;
137 /** The tool call that launched this agent, when the provider reports one. */
138 agentSpawnToolCallId?: string;
135139 isWorkflowCoordinator?: boolean;
136140 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
137141 isBackgroundTask?: boolean;
......@@ -187,12 +191,47 @@ export type ThreadFeedEntry =
187191 readonly expanded: boolean;
188192 }
189193 | {
194 /**
195 * The turn's single live slot. Web keys its live tool row and its
196 * "Thinking" row identically so the slot updates in place; here the
197 * slot holds "Thinking" whenever no tool row is shimmering, so a tool
198 * failing does not insert a row under the group it lives in.
199 */
190200 readonly type: "thinking";
191201 readonly id: string;
192202 readonly createdAt: string;
193203 readonly turnId: TurnId | null;
204 }
205 | {
206 /**
207 * One batch of spawned subagents. Rendered as its own card because a
208 * single-line tool row has no room for what the agents are doing now,
209 * which on a phone is the one thing worth showing.
210 */
211 readonly type: "agent-spawn";
212 readonly id: string;
213 readonly createdAt: string;
214 readonly turnId: TurnId | null;
215 readonly activity: ThreadFeedActivity;
216 readonly expanded: boolean;
217 readonly summary: AgentSpawnSummary;
194218 };
195219
220export interface AgentSpawnSummary {
221 /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */
222 readonly title: string;
223 /** Latest member activity while working, else the batch outcome. */
224 readonly status: string;
225 readonly tone: "working" | "completed" | "failed" | "stopped";
226 readonly members: ReadonlyArray<{
227 readonly title: string;
228 readonly status: string;
229 readonly tone: "working" | "completed" | "failed" | "stopped";
230 readonly detail: string | undefined;
231 readonly updatedAt: string;
232 }>;
233}
234
196235export type ThreadFeedLatestTurn = Pick<
197236 OrchestrationLatestTurn,
198237 "turnId" | "state" | "startedAt" | "completedAt"
......@@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
420459 return false;
421460 }
422461 const isTaskRow =
462 activity.kind === "task.started" ||
423463 activity.kind === "task.progress" ||
424464 activity.kind === "task.updated" ||
425465 activity.kind === "task.completed";
......@@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
441481 return payload.timelineBypass === true || ownedByAgent;
442482}
443483
484/** Agent (non-background) task.started rows seed spawn batches. */
485function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
486 const payload =
487 activity.payload && typeof activity.payload === "object"
488 ? (activity.payload as Record<string, unknown>)
489 : null;
490 return typeof payload?.taskId === "string" && payload.agentKind === "agent";
491}
492
444493function deriveWorkLogEntries(
445494 activities: ReadonlyArray<OrchestrationThreadActivity>,
446495): DerivedWorkLogEntry[] {
......@@ -449,7 +498,11 @@ function deriveWorkLogEntries(
449498 for (const activity of ordered) {
450499 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
451500 if (activity.kind === "tool.started") continue;
452 if (activity.kind === "task.started") continue;
501 // Like web: an agent's task.started row anchors its batch. It has a fixed
502 // id and timestamp, unlike progress ticks, whose stable per-task id is
503 // rewritten with a new createdAt on every update (and would otherwise
504 // make the batch row a "fresh" row again on each tick).
505 if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
453506 if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
454507 if (activity.kind === "tool.progress") continue;
455508 if (activity.kind === "context-window.updated") continue;
......@@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
496549 const toolPresentation = extractToolActivityPresentation(payload);
497550 // Terminal task updates carry identity so they replace each child's progress row.
498551 const isTaskActivity =
552 activity.kind === "task.started" ||
499553 activity.kind === "task.progress" ||
500554 activity.kind === "task.completed" ||
501555 activity.kind === "task.updated";
......@@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
539593 if (payload.agentKind !== "agent") {
540594 entry.isBackgroundTask = true;
541595 }
596 const spawnToolCallId = asTrimmedString(payload.toolUseId);
597 if (spawnToolCallId) {
598 entry.agentSpawnToolCallId = spawnToolCallId;
599 }
542600 if (
543601 payload.taskType === "local_workflow" ||
544602 (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
......@@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
608666 entry.requestKind = requestKind;
609667 }
610668 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
611 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
612 toolLifecycleStatus = "completed";
669 if (
670 !toolLifecycleStatus &&
671 (activity.kind === "tool.completed" || activity.kind === "task.completed")
672 ) {
673 toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed";
613674 }
614675 // A Codex child that finishes its turn reports "idle" (resumable, not
615676 // terminal). For the batch row that is a finished member.
......@@ -681,6 +742,7 @@ function agentSpawnMember(
681742 title: entry.toolTitle ?? previous?.title ?? entry.label,
682743 status: entry.toolLifecycleStatus ?? previous?.status,
683744 detail: entry.detail ?? previous?.detail,
745 updatedAt: entry.createdAt,
684746 };
685747}
686748
......@@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus(
713775 return "inProgress";
714776 }
715777 if (statuses.includes("failed")) return "failed";
778 if (statuses.includes("declined")) return "declined";
716779 if (statuses.includes("stopped")) return "stopped";
717780 return "completed";
718781}
......@@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries(
729792 const spawnRowIndex = new Map<string, number>();
730793 const spawnGroupByTaskId = new Map<string, string>();
731794 const toolLifecycleRowIndex = new Map<string, number>();
795 // Tool calls that launched an agent (Claude's Agent tool, ACP subagent
796 // calls). The batch card is the whole story of that call, so its own
797 // lifecycle row is dropped.
798 const spawnToolCallIds = new Set(
799 entries.flatMap((entry) =>
800 entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [],
801 ),
802 );
732803 for (const entry of entries) {
804 if (
805 entry.toolCallId !== undefined &&
806 entry.taskId === undefined &&
807 spawnToolCallIds.has(entry.toolCallId)
808 ) {
809 continue;
810 }
733811 const isTaskRow =
734812 entry.taskId !== undefined &&
735 (entry.sourceActivityKind === "task.progress" ||
813 (entry.sourceActivityKind === "task.started" ||
814 entry.sourceActivityKind === "task.progress" ||
736815 entry.sourceActivityKind === "task.completed" ||
737816 entry.sourceActivityKind === "task.updated");
738817 if (isTaskRow && entry.taskId !== undefined) {
......@@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) {
11421221 return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
11431222}
11441223
1224function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] {
1225 switch (status) {
1226 case undefined:
1227 case "inProgress":
1228 return "working";
1229 case "completed":
1230 return "completed";
1231 case "failed":
1232 case "declined":
1233 return "failed";
1234 case "stopped":
1235 return "stopped";
1236 }
1237}
1238
1239/**
1240 * What the spawn card shows. While members work, the status line is the
1241 * newest member activity (its progress detail), so the card reads like the
1242 * live tool row does for a single call. Once every member settles, it is the
1243 * batch outcome in web's CTA wording.
1244 */
1245export function agentSpawnSummary(
1246 spawn: NonNullable<WorkLogEntry["agentSpawn"]>,
1247 batchStatus: WorkLogToolLifecycleStatus | undefined,
1248): AgentSpawnSummary {
1249 const members = agentSpawnMembers(spawn).map((agent) => {
1250 const tone = agentSpawnTone(agent.status);
1251 return {
1252 title: agent.title,
1253 status: tone === "working" ? "working" : (agent.status ?? tone),
1254 tone,
1255 detail: agent.detail,
1256 updatedAt: agent.updatedAt,
1257 };
1258 });
1259 const tone = agentSpawnTone(batchStatus);
1260 // A workflow's coordinator is not a member; before any member reports the
1261 // batch has none.
1262 const title =
1263 members.length === 0
1264 ? "Subagents"
1265 : members.length === 1
1266 ? members[0]!.title
1267 : `${members.length} subagents`;
1268 if (tone === "working") {
1269 const working = members.filter((member) => member.tone === "working");
1270 const latest = working
1271 .filter((member) => member.detail !== undefined)
1272 .reduce<(typeof working)[number] | undefined>(
1273 (newest, member) =>
1274 newest === undefined || member.updatedAt > newest.updatedAt ? member : newest,
1275 undefined,
1276 );
1277 const status =
1278 latest?.detail ??
1279 (members.length > 1 ? `${working.length} of ${members.length} working` : "Working");
1280 return { title, status, tone, members };
1281 }
1282 // The batch tone covers a coordinator that failed or stopped on its own.
1283 const failed = members.filter((member) => member.tone === "failed").length;
1284 const stopped = members.filter((member) => member.tone === "stopped").length;
1285 const outcome =
1286 tone === "failed" || failed > 0
1287 ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed`
1288 : tone === "stopped" || stopped > 0
1289 ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped`
1290 : "completed";
1291 return { title, status: outcome, tone, members };
1292}
1293
11451294function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {
11461295 const lines = agentSpawnMembers(spawn).map((agent) => {
11471296 const status =
......@@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation(
17501899): ThreadFeedEntry[] {
17511900 const sourceFeed = feed.filter(
17521901 (entry) =>
1753 entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking",
1902 entry.type !== "turn-fold" &&
1903 entry.type !== "work-toggle" &&
1904 entry.type !== "thinking" &&
1905 entry.type !== "agent-spawn",
17541906 );
17551907 const activeTailGroup = sourceFeed.findLast(
17561908 (entry) => entry.type !== "message" || !isEmptyMessage(entry),
......@@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation(
18121964 }
18131965 // A working turn always shows one live activity. When no tool row is
18141966 // shimmering (no tools yet, or the latest failed), that row is "Thinking".
1967 // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so
1968 // the handoff between them happens in place (one row, new content) instead
1969 // of a row being inserted below the group every time a call fails.
18151970 if (
18161971 activeWorkStartedAt !== null &&
1817 !result.some((row) => row.type === "work-toggle" && row.shimmer)
1972 !result.some(
1973 (row) =>
1974 (row.type === "work-toggle" && row.shimmer) ||
1975 // A working spawn card is the live activity: its status line shows
1976 // what the agents are doing, so a Thinking row under it would lie.
1977 (row.type === "agent-spawn" &&
1978 row.summary.tone === "working" &&
1979 row.turnId === unsettledTurnId),
1980 )
18181981 ) {
18191982 result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId));
18201983 }
18211984 return result;
18221985}
18231986
1987/**
1988 * Shared by the trailing tool group's live row and the "Thinking" row so the
1989 * list keeps one mounted row for the turn's live slot (mirrors web's
1990 * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them.
1991 */
1992export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
1993
18241994function thinkingRow(createdAt: string, turnId: TurnId | null) {
18251995 if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) {
1826 cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId };
1996 cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId };
18271997 }
18281998 return cachedThinkingRow;
18291999}
......@@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry(
18522022 cached.isWorking !== isWorking ||
18532023 cached.activeTail !== activeTail ||
18542024 cached.rows.some(
1855 (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded,
2025 (row) =>
2026 (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) ||
2027 (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded),
18562028 )
18572029 ) {
18582030 const rows: ThreadFeedEntry[] = [];
......@@ -1908,11 +2080,27 @@ function appendActivityGroupRows(
19082080 groupableRun = [];
19092081 };
19102082 for (const activity of activities) {
1911 if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) {
2083 const spawn = activity.workEntry.agentSpawn;
2084 if (activity.workEntry.tone !== "error" && spawn === undefined) {
19122085 groupableRun.push(activity);
19132086 continue;
19142087 }
19152088 flushGroupableRun(false);
2089 if (spawn !== undefined) {
2090 // Keyed by the batch, not the anchor activity: the anchor can change
2091 // as members arrive, and a changed key remounts the card.
2092 const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`;
2093 result.push({
2094 type: "agent-spawn",
2095 id: groupId,
2096 createdAt: activity.createdAt,
2097 turnId: activity.turnId,
2098 activity,
2099 expanded: expandedWorkGroupIds.has(groupId),
2100 summary: agentSpawnSummary(spawn, activity.lifecycleStatus),
2101 });
2102 continue;
2103 }
19162104 result.push({
19172105 type: "activity-group",
19182106 id: activity.id,
......@@ -1953,7 +2141,9 @@ function appendToolGroupRows(
19532141 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
19542142 // Like web, the trailing run keeps shining after its latest call succeeds;
19552143 // only a failed, declined, or stopped call hands the live slot to "Thinking".
1956 const shimmer = active || (activeTail && latestActivity.status === "success");
2144 // Only the trailing run can be the turn's live slot; an in-progress row in
2145 // an earlier run (a call whose end was never reported) stays in place.
2146 const shimmer = activeTail && (active || latestActivity.status === "success");
19572147 const singleActivity = activities.length === 1 ? latestActivity : null;
19582148 const summary = live
19592149 ? liveToolActivitySummary(latestActivity, live)
......@@ -1994,7 +2184,9 @@ function appendToolGroupRows(
19942184 : undefined;
19952185 result.push({
19962186 type: "work-toggle",
1997 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
2187 // The shimmering trailing row is the turn's live slot; it keeps that
2188 // identity (and so its mounted view) until "Thinking" takes the slot.
2189 id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`,
19982190 createdAt: sourceGroup.createdAt,
19992191 turnId: sourceGroup.turnId,
20002192 groupId,