History

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

Julius Marminge committed 2 months ago

Commit b7465a31 parent 4f782be4 files · +724 −52Browse files

Unified · Split

4 files
  1. apps/mobile/src/features/threads/ThreadFeed.tsx +21 −12
  2. apps/mobile/src/features/threads/thread-work-log.tsx +172 −23
  3. apps/mobile/src/lib/threadActivity.test.ts +328 −6
  4. apps/mobile/src/lib/threadActivity.ts +203 −11
apps/mobile/src/features/threads/ThreadFeed.tsx+21−12
@@ -153,6 +153,7 @@ import { · 152 unmodified lines
153153 } from "./thread-feed-live-follow";
154154 import {
155155 collapsedWorkLogHeight,
156+ ThreadAgentSpawnCard,
156157 ThreadDisclosureChevron,
157158 ThreadWorkGroupToggle,
158159 ThreadThinkingRow,
@@ -504,12 +505,7 @@ function MessageAttachmentFile(props: { · 345 unmodified lines
504505 function 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( · 841 unmodified lines
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( · 18 unmodified lines
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( · 23 unmodified lines
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( · 3 unmodified lines
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( · 80 unmodified lines
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( · 42 unmodified lines
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( · 17 unmodified lines
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"; · 33 unmodified lines
3434 import { T3Wordmark } from "../../components/T3Wordmark";
3535 import { cn } from "../../lib/cn";
3636 import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
37−import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity";
37+import {
38+ type AgentSpawnSummary,
39+ type ThreadFeedActivity,
40+ workEntryRowLabel,
41+} from "../../lib/threadActivity";
3842 import {
3943 resolveThreadWorkGroupInitialScroll,
4044 shouldFollowThreadWorkGroupAppend,
@@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { · 94 unmodified lines
135139 }
136140
137141 function 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: { · 6 unmodified lines
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: { · 6 unmodified lines
179187 }
180188
181189 export 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: { · 9 unmodified lines
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: { · 49 unmodified lines
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: { · 34 unmodified lines
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( · 477 unmodified lines
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( · 4 unmodified lines
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( · 41 unmodified lines
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: { · 99 unmodified lines
938953 );
939954 }
940955
956+const 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+ */
969+export 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+
9411090 export 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 { · 12 unmodified lines
1313 } from "@t3tools/contracts";
1414
1515 import {
16+ agentSpawnSummary,
1617 buildPendingUserInputAnswers,
1718 buildThreadFeed,
1819 derivePendingApprovals,
@@ -24,6 +25,7 @@ import { · 5 unmodified lines
2425 workEntryRowLabel,
2526 type ThreadFeedActivity,
2627 type ThreadFeedEntry,
28+ type WorkLogEntry,
2729 } from "./threadActivity";
2830
2931 describe("Codex feedback pseudo-messages", () => {
@@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => { · 2273 unmodified lines
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", () => { · 68 unmodified lines
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", () => { · 6 unmodified lines
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", () => { · 413 unmodified lines
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", () => { · 3 unmodified lines
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", () => { · 1 unmodified line
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", () => { · 139 unmodified lines
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", () => { · 69 unmodified lines
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 { · 121 unmodified lines
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 { · 4 unmodified lines
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 = · 49 unmodified lines
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
220+export 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+
196235 export type ThreadFeedLatestTurn = Pick<
197236 OrchestrationLatestTurn,
198237 "turnId" | "state" | "startedAt" | "completedAt"
@@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean · 221 unmodified lines
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 · 15 unmodified lines
441481 return payload.timelineBypass === true || ownedByAgent;
442482 }
443483
484+/** Agent (non-background) task.started rows seed spawn batches. */
485+function 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+
444493 function deriveWorkLogEntries(
445494 activities: ReadonlyArray<OrchestrationThreadActivity>,
446495 ): DerivedWorkLogEntry[] {
@@ -449,7 +498,11 @@ function deriveWorkLogEntries( · 2 unmodified lines
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 · 40 unmodified lines
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 · 37 unmodified lines
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 · 63 unmodified lines
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( · 65 unmodified lines
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( · 26 unmodified lines
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( · 10 unmodified lines
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"]>) { · 403 unmodified lines
11421221 return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
11431222 }
11441223
1224+function 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+ */
1245+export 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+
11451294 function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {
11461295 const lines = agentSpawnMembers(spawn).map((agent) => {
11471296 const status =
@@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation( · 602 unmodified lines
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( · 55 unmodified lines
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+ */
1992+export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
1993+
18241994 function 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( · 22 unmodified lines
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( · 49 unmodified lines
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( · 34 unmodified lines
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( · 34 unmodified lines
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,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t