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 {...@@ -153,6 +153,7 @@ import {
153} from "./thread-feed-live-follow";153} from "./thread-feed-live-follow";
154import {154import {
155 collapsedWorkLogHeight,155 collapsedWorkLogHeight,
156 ThreadAgentSpawnCard,
156 ThreadDisclosureChevron,157 ThreadDisclosureChevron,
157 ThreadWorkGroupToggle,158 ThreadWorkGroupToggle,
158 ThreadThinkingRow,159 ThreadThinkingRow,
...@@ -504,12 +505,7 @@ function MessageAttachmentFile(props: {...@@ -504,12 +505,7 @@ function MessageAttachmentFile(props: {
504function MessageAttachmentUnknown(props: { readonly name: string }) {505function MessageAttachmentUnknown(props: { readonly name: string }) {
505 return (506 return (
506 <View className="flex-row items-center gap-2 py-1">507 <View className="flex-row items-center gap-2 py-1">
507 <SymbolView508 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
508 name="doc.text"
509 size={16}
510 tintColorClassName="accent-icon-subtle"
511 type="monochrome"
512 />
513 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>509 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
514 {props.name}510 {props.name}
515 </Text>511 </Text>
...@@ -1357,7 +1353,7 @@ function renderFeedEntry(...@@ -1357,7 +1353,7 @@ function renderFeedEntry(
1357 accessibilityState={{ expanded: entry.expanded }}1353 accessibilityState={{ expanded: entry.expanded }}
1358 onPress={() => props.onToggleTurnFold(entry.turnId)}1354 onPress={() => props.onToggleTurnFold(entry.turnId)}
1359 hitSlop={4}1355 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"
1361 style={{1357 style={{
1362 minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),1358 minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
1363 }}1359 }}
...@@ -1382,6 +1378,19 @@ function renderFeedEntry(...@@ -1382,6 +1378,19 @@ function renderFeedEntry(
1382 return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;1378 return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;
1383 }1379 }
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
1385 if (entry.type === "work-toggle") {1394 if (entry.type === "work-toggle") {
1386 return (1395 return (
1387 <ThreadWorkGroupToggle1396 <ThreadWorkGroupToggle
...@@ -1411,7 +1420,7 @@ function renderFeedEntry(...@@ -1411,7 +1420,7 @@ function renderFeedEntry(
1411 accessibilityLabel={label}1420 accessibilityLabel={label}
1412 className="mb-3 flex-row items-center gap-3 px-1 py-1"1421 className="mb-3 flex-row items-center gap-3 px-1 py-1"
1413 >1422 >
1414 <View className="h-px flex-1 bg-border" />1423 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
1415 <View className="shrink-0 flex-row items-center gap-1.5">1424 <View className="shrink-0 flex-row items-center gap-1.5">
1416 <SymbolView1425 <SymbolView
1417 name="arrow.down.right.and.arrow.up.left"1426 name="arrow.down.right.and.arrow.up.left"
...@@ -1421,7 +1430,7 @@ function renderFeedEntry(...@@ -1421,7 +1430,7 @@ function renderFeedEntry(
1421 />1430 />
1422 <Text className="font-t3-medium text-xs text-foreground-muted">{label}</Text>1431 <Text className="font-t3-medium text-xs text-foreground-muted">{label}</Text>
1423 </View>1432 </View>
1424 <View className="h-px flex-1 bg-border" />1433 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
1425 </View>1434 </View>
1426 );1435 );
1427 }1436 }
...@@ -1508,7 +1517,7 @@ function renderFeedEntry(...@@ -1508,7 +1517,7 @@ function renderFeedEntry(
1508 })}1517 })}
1509 </View>1518 </View>
1510 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">1519 <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">
1512 {timestampLabel}1521 {timestampLabel}
1513 </Text>1522 </Text>
1514 {message.text.trim().length > 0 ? (1523 {message.text.trim().length > 0 ? (
...@@ -1557,7 +1566,7 @@ function renderFeedEntry(...@@ -1557,7 +1566,7 @@ function renderFeedEntry(
1557 attachmentId={attachment.id}1566 attachmentId={attachment.id}
1558 name={attachment.name}1567 name={attachment.name}
1559 mimeType={attachment.mimeType}1568 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"
1561 onPressPreview={props.onPressPreview}1570 onPressPreview={props.onPressPreview}
1562 />1571 />
1563 ) : isFileAttachment(attachment) ? (1572 ) : isFileAttachment(attachment) ? (
...@@ -1581,7 +1590,7 @@ function renderFeedEntry(...@@ -1581,7 +1590,7 @@ function renderFeedEntry(
1581 buttonSize={28}1590 buttonSize={28}
1582 iconSize={13}1591 iconSize={13}
1583 />1592 />
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">
1585 {timestampLabel}1594 {timestampLabel}
1586 </Text>1595 </Text>
1587 </View>1596 </View>
apps/mobile/src/features/threads/thread-work-log.tsx+172-23
...@@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText";...@@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText";
34import { T3Wordmark } from "../../components/T3Wordmark";34import { T3Wordmark } from "../../components/T3Wordmark";
35import { cn } from "../../lib/cn";35import { cn } from "../../lib/cn";
36import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";36import { 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";
38import {42import {
39 resolveThreadWorkGroupInitialScroll,43 resolveThreadWorkGroupInitialScroll,
40 shouldFollowThreadWorkGroupAppend,44 shouldFollowThreadWorkGroupAppend,
...@@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: {...@@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: {
135}139}
136140
137function ShimmerWorkContent(props: {141function ShimmerWorkContent(props: {
142 readonly compact?: boolean;
138 readonly environmentId?: EnvironmentId;143 readonly environmentId?: EnvironmentId;
139 readonly highlighted: boolean;144 readonly highlighted: boolean;
140 readonly icon: WorkContentIcon;145 readonly icon: WorkContentIcon;
...@@ -147,26 +152,29 @@ function ShimmerWorkContent(props: {...@@ -147,26 +152,29 @@ function ShimmerWorkContent(props: {
147}) {152}) {
148 return (153 return (
149 <View className="flex-row items-center gap-1.5">154 <View className="flex-row items-center gap-1.5">
150 <View className="h-6 w-6 shrink-0 items-center justify-center">155 {props.showIcon ? (
151 {props.showIcon && props.toolIcon && props.environmentId ? (156 <View className="h-6 w-6 shrink-0 items-center justify-center">
152 <ToolActivityIconView157 {props.toolIcon && props.environmentId ? (
153 environmentId={props.environmentId}158 <ToolActivityIconView
154 icon={props.toolIcon}159 environmentId={props.environmentId}
155 fallback={props.icon}160 icon={props.toolIcon}
156 fallbackColor={props.iconSubtleColor}161 fallback={props.icon}
157 themeAppearance={props.themeAppearance ?? "light"}162 fallbackColor={props.iconSubtleColor}
158 />163 themeAppearance={props.themeAppearance ?? "light"}
159 ) : props.showIcon ? (164 />
160 <WorkLogIcon165 ) : (
161 icon={props.icon}166 <WorkLogIcon
162 color={props.iconSubtleColor}167 icon={props.icon}
163 highlighted={props.highlighted}168 color={props.iconSubtleColor}
164 />169 highlighted={props.highlighted}
165 ) : null}170 />
166 </View>171 )}
172 </View>
173 ) : null}
167 <Text174 <Text
168 className={cn(175 className={cn(
169 "min-w-0 shrink text-sm",176 "min-w-0 shrink",
177 props.compact ? "text-xs" : "text-sm",
170 props.highlighted ? "text-foreground" : "text-foreground-muted",178 props.highlighted ? "text-foreground" : "text-foreground-muted",
171 )}179 )}
172 numberOfLines={1}180 numberOfLines={1}
...@@ -179,6 +187,8 @@ function ShimmerWorkContent(props: {...@@ -179,6 +187,8 @@ function ShimmerWorkContent(props: {
179}187}
180188
181export function ShimmeringWorkContent(props: {189export function ShimmeringWorkContent(props: {
190 /** Secondary line: no icon slot, caption size. */
191 readonly compact?: boolean;
182 readonly environmentId?: EnvironmentId;192 readonly environmentId?: EnvironmentId;
183 readonly icon: WorkContentIcon;193 readonly icon: WorkContentIcon;
184 readonly iconSubtleColor: ColorValue;194 readonly iconSubtleColor: ColorValue;
...@@ -194,7 +204,10 @@ export function ShimmeringWorkContent(props: {...@@ -194,7 +204,10 @@ export function ShimmeringWorkContent(props: {
194 const screenIsFocused = useIsFocused();204 const screenIsFocused = useIsFocused();
195 const progress = useSharedValue(0);205 const progress = useSharedValue(0);
196 const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;206 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
199 useEffect(() => {212 useEffect(() => {
200 const subscription = AppState.addEventListener("change", (state) => {213 const subscription = AppState.addEventListener("change", (state) => {
...@@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: {...@@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: {
250 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}263 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
251 >264 >
252 <ShimmerWorkContent265 <ShimmerWorkContent
266 compact={props.compact}
253 environmentId={props.environmentId}267 environmentId={props.environmentId}
254 highlighted={false}268 highlighted={false}
255 icon={props.icon}269 icon={props.icon}
...@@ -290,6 +304,7 @@ export function ShimmeringWorkContent(props: {...@@ -290,6 +304,7 @@ export function ShimmeringWorkContent(props: {
290 >304 >
291 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>305 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
292 <ShimmerWorkContent306 <ShimmerWorkContent
307 compact={props.compact}
293 environmentId={props.environmentId}308 environmentId={props.environmentId}
294 highlighted309 highlighted
295 icon={props.icon}310 icon={props.icon}
...@@ -773,7 +788,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -773,7 +788,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
773 color={props.iconSubtleColor}788 color={props.iconSubtleColor}
774 colorClassName={789 colorClassName={
775 iconIsDestructive790 iconIsDestructive
776 ? "accent-danger-foreground"791 ? "accent-adaptive-rose-600-400"
777 : failed792 : failed
778 ? "accent-danger-foreground/40"793 ? "accent-danger-foreground/40"
779 : undefined794 : undefined
...@@ -784,7 +799,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -784,7 +799,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
784 <Text799 <Text
785 className={cn(800 className={cn(
786 "min-w-0 flex-1 text-sm text-foreground-muted",801 "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",
788 )}803 )}
789 numberOfLines={1}804 numberOfLines={1}
790 >805 >
...@@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
832 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}847 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
833 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}848 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
834 layout={WORK_LOG_LAYOUT_TRANSITION}849 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"
836 >851 >
837 {viewedImagePath ? (852 {viewedImagePath ? (
838 <View className="pb-1.5">853 <View className="pb-1.5">
...@@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: {...@@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: {
938 );953 );
939}954}
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
941export function ThreadThinkingRow(props: {1090export function ThreadThinkingRow(props: {
942 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;1091 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
943 readonly iconSubtleColor: ColorValue;1092 readonly iconSubtleColor: ColorValue;
apps/mobile/src/lib/threadActivity.test.ts+328-6
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
1414
15import {15import {
16 agentSpawnSummary,
16 buildPendingUserInputAnswers,17 buildPendingUserInputAnswers,
17 buildThreadFeed,18 buildThreadFeed,
18 derivePendingApprovals,19 derivePendingApprovals,
...@@ -24,6 +25,7 @@ import {...@@ -24,6 +25,7 @@ import {
24 workEntryRowLabel,25 workEntryRowLabel,
25 type ThreadFeedActivity,26 type ThreadFeedActivity,
26 type ThreadFeedEntry,27 type ThreadFeedEntry,
28 type WorkLogEntry,
27} from "./threadActivity";29} from "./threadActivity";
2830
29describe("Codex feedback pseudo-messages", () => {31describe("Codex feedback pseudo-messages", () => {
...@@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => {...@@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => {
2303 new Set(),2305 new Set(),
2304 latestTurn.startedAt,2306 latestTurn.startedAt,
2305 );2307 );
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.
2306 expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([2310 expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
2307 ["work-toggle:work-group:activity-1", "work-toggle"],2311 ["work-toggle:work-group:activity-1", "work-toggle"],
2308 ["activity-2", "activity-group"],2312 ["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"],
2310 ]);2314 ]);
2311 expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([2315 expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
2312 false,2316 false,
...@@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => {...@@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => {
23812385
2382 const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");2386 const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
2383 expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);2387 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 });
2385 // The row identity is stable across re-derivations so the list can reuse it.2389 // The row identity is stable across re-derivations so the list can reuse it.
2386 expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(2390 expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
2387 rows[1],2391 rows[1],
...@@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => {...@@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => {
2394 ).toEqual(["message"]);2398 ).toEqual(["message"]);
2395 });2399 });
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
2397 it("hands a settled tool run off to Thinking once assistant text streams after it", () => {2474 it("hands a settled tool run off to Thinking once assistant text streams after it", () => {
2398 const turnId = TurnId.make("turn-streaming-tail");2475 const turnId = TurnId.make("turn-streaming-tail");
2399 const latestTurn = {2476 const latestTurn = {
...@@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => {...@@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => {
2813 }),2890 }),
2814 ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));2891 ).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).
2816 const running = rowsFor([]);2895 const running = rowsFor([]);
2817 expect(running.map((row) => [row.id, row.summary])).toEqual([2896 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"],
2819 ["shell-1", "Run tests"],2898 ["shell-1", "Run tests"],
2820 ]);2899 ]);
2821 expect(running[0]).toMatchObject({2900 expect(running[0]).toMatchObject({
2901 createdAt: "2026-04-01T00:00:01.000Z",
2822 lifecycleStatus: "inProgress",2902 lifecycleStatus: "inProgress",
2823 workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },2903 workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
2824 });2904 });
28252905
2826 const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);2906 const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
2827 expect(oneDone[0]).toMatchObject({2907 expect(oneDone[0]).toMatchObject({
2828 id: "a-progress",2908 id: "a-start",
2829 summary: "Kicked off 2 subagents · 1 working",2909 summary: "Kicked off 2 subagents · 1 working",
2830 lifecycleStatus: "inProgress",2910 lifecycleStatus: "inProgress",
2831 });2911 });
...@@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => {...@@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => {
2835 agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),2915 agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
2836 ]);2916 ]);
2837 expect(allDone[0]).toMatchObject({2917 expect(allDone[0]).toMatchObject({
2838 id: "a-progress",2918 id: "a-start",
2839 summary: "Ran 2 subagents · 1 failed",2919 summary: "Ran 2 subagents · 1 failed",
2840 lifecycleStatus: "failed",2920 lifecycleStatus: "failed",
2841 status: "failure",2921 status: "failure",
...@@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => {...@@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => {
2843 expect(allDone).toHaveLength(2);2923 expect(allDone).toHaveLength(2);
2844 });2924 });
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
2846 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(3114 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
2847 "replaces Antigravity batch progress with %s",3115 "replaces Antigravity batch progress with %s",
2848 (status) => {3116 (status) => {
...@@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => {...@@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => {
2988 expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");3256 expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
2989 });3257 });
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
2991 it("treats a Codex child's idle turn end as a finished batch member", () => {3308 it("treats a Codex child's idle turn end as a finished batch member", () => {
2992 const turnId = TurnId.make("turn-codex");3309 const turnId = TurnId.make("turn-codex");
2993 const child = (3310 const child = (
...@@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => {...@@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => {
3063 expect(ids).toContain("nested-done");3380 expect(ids).toContain("nested-done");
3064 expect(ids).not.toContain("shell-done");3381 expect(ids).not.toContain("shell-done");
3065 expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([3382 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 },
3067 ]);3389 ]);
3068 });3390 });
3069});3391});
apps/mobile/src/lib/threadActivity.ts+203-11
...@@ -122,6 +122,8 @@ export interface WorkLogEntry {...@@ -122,6 +122,8 @@ export interface WorkLogEntry {
122 readonly title: string;122 readonly title: string;
123 readonly status: WorkLogToolLifecycleStatus | undefined;123 readonly status: WorkLogToolLifecycleStatus | undefined;
124 readonly detail: string | undefined;124 readonly detail: string | undefined;
125 /** When this member last reported, so the card can show the newest activity. */
126 readonly updatedAt: string;
125 }>;127 }>;
126 };128 };
127 toolData?: unknown;129 toolData?: unknown;
...@@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry {...@@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry {
132 collapseKey?: string;134 collapseKey?: string;
133 /** Grouping key for subagent lifecycle rows (one row per agent). */135 /** Grouping key for subagent lifecycle rows (one row per agent). */
134 taskId?: string;136 taskId?: string;
137 /** The tool call that launched this agent, when the provider reports one. */
138 agentSpawnToolCallId?: string;
135 isWorkflowCoordinator?: boolean;139 isWorkflowCoordinator?: boolean;
136 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */140 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
137 isBackgroundTask?: boolean;141 isBackgroundTask?: boolean;
...@@ -187,12 +191,47 @@ export type ThreadFeedEntry =...@@ -187,12 +191,47 @@ export type ThreadFeedEntry =
187 readonly expanded: boolean;191 readonly expanded: boolean;
188 }192 }
189 | {193 | {
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 */
190 readonly type: "thinking";200 readonly type: "thinking";
191 readonly id: string;201 readonly id: string;
192 readonly createdAt: string;202 readonly createdAt: string;
193 readonly turnId: TurnId | null;203 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;
194 };218 };
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
196export type ThreadFeedLatestTurn = Pick<235export type ThreadFeedLatestTurn = Pick<
197 OrchestrationLatestTurn,236 OrchestrationLatestTurn,
198 "turnId" | "state" | "startedAt" | "completedAt"237 "turnId" | "state" | "startedAt" | "completedAt"
...@@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean...@@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
420 return false;459 return false;
421 }460 }
422 const isTaskRow =461 const isTaskRow =
462 activity.kind === "task.started" ||
423 activity.kind === "task.progress" ||463 activity.kind === "task.progress" ||
424 activity.kind === "task.updated" ||464 activity.kind === "task.updated" ||
425 activity.kind === "task.completed";465 activity.kind === "task.completed";
...@@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean...@@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
441 return payload.timelineBypass === true || ownedByAgent;481 return payload.timelineBypass === true || ownedByAgent;
442}482}
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
444function deriveWorkLogEntries(493function deriveWorkLogEntries(
445 activities: ReadonlyArray<OrchestrationThreadActivity>,494 activities: ReadonlyArray<OrchestrationThreadActivity>,
446): DerivedWorkLogEntry[] {495): DerivedWorkLogEntry[] {
...@@ -449,7 +498,11 @@ function deriveWorkLogEntries(...@@ -449,7 +498,11 @@ function deriveWorkLogEntries(
449 for (const activity of ordered) {498 for (const activity of ordered) {
450 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;499 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
451 if (activity.kind === "tool.started") continue;500 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;
453 if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;506 if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
454 if (activity.kind === "tool.progress") continue;507 if (activity.kind === "tool.progress") continue;
455 if (activity.kind === "context-window.updated") continue;508 if (activity.kind === "context-window.updated") continue;
...@@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
496 const toolPresentation = extractToolActivityPresentation(payload);549 const toolPresentation = extractToolActivityPresentation(payload);
497 // Terminal task updates carry identity so they replace each child's progress row.550 // Terminal task updates carry identity so they replace each child's progress row.
498 const isTaskActivity =551 const isTaskActivity =
552 activity.kind === "task.started" ||
499 activity.kind === "task.progress" ||553 activity.kind === "task.progress" ||
500 activity.kind === "task.completed" ||554 activity.kind === "task.completed" ||
501 activity.kind === "task.updated";555 activity.kind === "task.updated";
...@@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
539 if (payload.agentKind !== "agent") {593 if (payload.agentKind !== "agent") {
540 entry.isBackgroundTask = true;594 entry.isBackgroundTask = true;
541 }595 }
596 const spawnToolCallId = asTrimmedString(payload.toolUseId);
597 if (spawnToolCallId) {
598 entry.agentSpawnToolCallId = spawnToolCallId;
599 }
542 if (600 if (
543 payload.taskType === "local_workflow" ||601 payload.taskType === "local_workflow" ||
544 (typeof payload.workflowName === "string" && payload.workflowName.length > 0)602 (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
...@@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
608 entry.requestKind = requestKind;666 entry.requestKind = requestKind;
609 }667 }
610 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);668 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
611 if (!toolLifecycleStatus && activity.kind === "tool.completed") {669 if (
612 toolLifecycleStatus = "completed";670 !toolLifecycleStatus &&
671 (activity.kind === "tool.completed" || activity.kind === "task.completed")
672 ) {
673 toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed";
613 }674 }
614 // A Codex child that finishes its turn reports "idle" (resumable, not675 // A Codex child that finishes its turn reports "idle" (resumable, not
615 // terminal). For the batch row that is a finished member.676 // terminal). For the batch row that is a finished member.
...@@ -681,6 +742,7 @@ function agentSpawnMember(...@@ -681,6 +742,7 @@ function agentSpawnMember(
681 title: entry.toolTitle ?? previous?.title ?? entry.label,742 title: entry.toolTitle ?? previous?.title ?? entry.label,
682 status: entry.toolLifecycleStatus ?? previous?.status,743 status: entry.toolLifecycleStatus ?? previous?.status,
683 detail: entry.detail ?? previous?.detail,744 detail: entry.detail ?? previous?.detail,
745 updatedAt: entry.createdAt,
684 };746 };
685}747}
686748
...@@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus(...@@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus(
713 return "inProgress";775 return "inProgress";
714 }776 }
715 if (statuses.includes("failed")) return "failed";777 if (statuses.includes("failed")) return "failed";
778 if (statuses.includes("declined")) return "declined";
716 if (statuses.includes("stopped")) return "stopped";779 if (statuses.includes("stopped")) return "stopped";
717 return "completed";780 return "completed";
718}781}
...@@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries(...@@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries(
729 const spawnRowIndex = new Map<string, number>();792 const spawnRowIndex = new Map<string, number>();
730 const spawnGroupByTaskId = new Map<string, string>();793 const spawnGroupByTaskId = new Map<string, string>();
731 const toolLifecycleRowIndex = new Map<string, number>();794 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 );
732 for (const entry of entries) {803 for (const entry of entries) {
804 if (
805 entry.toolCallId !== undefined &&
806 entry.taskId === undefined &&
807 spawnToolCallIds.has(entry.toolCallId)
808 ) {
809 continue;
810 }
733 const isTaskRow =811 const isTaskRow =
734 entry.taskId !== undefined &&812 entry.taskId !== undefined &&
735 (entry.sourceActivityKind === "task.progress" ||813 (entry.sourceActivityKind === "task.started" ||
814 entry.sourceActivityKind === "task.progress" ||
736 entry.sourceActivityKind === "task.completed" ||815 entry.sourceActivityKind === "task.completed" ||
737 entry.sourceActivityKind === "task.updated");816 entry.sourceActivityKind === "task.updated");
738 if (isTaskRow && entry.taskId !== undefined) {817 if (isTaskRow && entry.taskId !== undefined) {
...@@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) {...@@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) {
1142 return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);1221 return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
1143}1222}
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
1145function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {1294function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {
1146 const lines = agentSpawnMembers(spawn).map((agent) => {1295 const lines = agentSpawnMembers(spawn).map((agent) => {
1147 const status =1296 const status =
...@@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation(...@@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation(
1750): ThreadFeedEntry[] {1899): ThreadFeedEntry[] {
1751 const sourceFeed = feed.filter(1900 const sourceFeed = feed.filter(
1752 (entry) =>1901 (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",
1754 );1906 );
1755 const activeTailGroup = sourceFeed.findLast(1907 const activeTailGroup = sourceFeed.findLast(
1756 (entry) => entry.type !== "message" || !isEmptyMessage(entry),1908 (entry) => entry.type !== "message" || !isEmptyMessage(entry),
...@@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation(...@@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation(
1812 }1964 }
1813 // A working turn always shows one live activity. When no tool row is1965 // A working turn always shows one live activity. When no tool row is
1814 // shimmering (no tools yet, or the latest failed), that row is "Thinking".1966 // 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.
1815 if (1970 if (
1816 activeWorkStartedAt !== null &&1971 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 )
1818 ) {1981 ) {
1819 result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId));1982 result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId));
1820 }1983 }
1821 return result;1984 return result;
1822}1985}
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
1824function thinkingRow(createdAt: string, turnId: TurnId | null) {1994function thinkingRow(createdAt: string, turnId: TurnId | null) {
1825 if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) {1995 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 };
1827 }1997 }
1828 return cachedThinkingRow;1998 return cachedThinkingRow;
1829}1999}
...@@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry(...@@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry(
1852 cached.isWorking !== isWorking ||2022 cached.isWorking !== isWorking ||
1853 cached.activeTail !== activeTail ||2023 cached.activeTail !== activeTail ||
1854 cached.rows.some(2024 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),
1856 )2028 )
1857 ) {2029 ) {
1858 const rows: ThreadFeedEntry[] = [];2030 const rows: ThreadFeedEntry[] = [];
...@@ -1908,11 +2080,27 @@ function appendActivityGroupRows(...@@ -1908,11 +2080,27 @@ function appendActivityGroupRows(
1908 groupableRun = [];2080 groupableRun = [];
1909 };2081 };
1910 for (const activity of activities) {2082 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) {
1912 groupableRun.push(activity);2085 groupableRun.push(activity);
1913 continue;2086 continue;
1914 }2087 }
1915 flushGroupableRun(false);2088 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 }
1916 result.push({2104 result.push({
1917 type: "activity-group",2105 type: "activity-group",
1918 id: activity.id,2106 id: activity.id,
...@@ -1953,7 +2141,9 @@ function appendToolGroupRows(...@@ -1953,7 +2141,9 @@ function appendToolGroupRows(
1953 const latestActivity = latestActiveActivity ?? activities.at(-1)!;2141 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
1954 // Like web, the trailing run keeps shining after its latest call succeeds;2142 // Like web, the trailing run keeps shining after its latest call succeeds;
1955 // only a failed, declined, or stopped call hands the live slot to "Thinking".2143 // 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");
1957 const singleActivity = activities.length === 1 ? latestActivity : null;2147 const singleActivity = activities.length === 1 ? latestActivity : null;
1958 const summary = live2148 const summary = live
1959 ? liveToolActivitySummary(latestActivity, live)2149 ? liveToolActivitySummary(latestActivity, live)
...@@ -1994,7 +2184,9 @@ function appendToolGroupRows(...@@ -1994,7 +2184,9 @@ function appendToolGroupRows(
1994 : undefined;2184 : undefined;
1995 result.push({2185 result.push({
1996 type: "work-toggle",2186 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}`,
1998 createdAt: sourceGroup.createdAt,2190 createdAt: sourceGroup.createdAt,
1999 turnId: sourceGroup.turnId,2191 turnId: sourceGroup.turnId,
2000 groupId,2192 groupId,