Unified · Split
| @@ -153,6 +153,7 @@ import { · 152 unmodified lines | ||
| 153 | 153 | } from "./thread-feed-live-follow"; |
| 154 | 154 | import { |
| 155 | 155 | collapsedWorkLogHeight, |
| 156 | + ThreadAgentSpawnCard, | |
| 156 | 157 | ThreadDisclosureChevron, |
| 157 | 158 | ThreadWorkGroupToggle, |
| 158 | 159 | ThreadThinkingRow, |
| @@ -504,12 +505,7 @@ function MessageAttachmentFile(props: { · 345 unmodified lines | ||
| 504 | 505 | function MessageAttachmentUnknown(props: { readonly name: string }) { |
| 505 | 506 | return ( |
| 506 | 507 | <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" /> | |
| 513 | 509 | <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}> |
| 514 | 510 | {props.name} |
| 515 | 511 | </Text> |
| @@ -1357,7 +1353,7 @@ function renderFeedEntry( · 841 unmodified lines | ||
| 1357 | 1353 | accessibilityState={{ expanded: entry.expanded }} |
| 1358 | 1354 | onPress={() => props.onToggleTurnFold(entry.turnId)} |
| 1359 | 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 | 1357 | style={{ |
| 1362 | 1358 | minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), |
| 1363 | 1359 | }} |
| @@ -1382,6 +1378,19 @@ function renderFeedEntry( · 18 unmodified lines | ||
| 1382 | 1378 | return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />; |
| 1383 | 1379 | } |
| 1384 | 1380 | |
| 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 | 1394 | if (entry.type === "work-toggle") { |
| 1386 | 1395 | return ( |
| 1387 | 1396 | <ThreadWorkGroupToggle |
| @@ -1411,7 +1420,7 @@ function renderFeedEntry( · 23 unmodified lines | ||
| 1411 | 1420 | accessibilityLabel={label} |
| 1412 | 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 | 1424 | <View className="shrink-0 flex-row items-center gap-1.5"> |
| 1416 | 1425 | <SymbolView |
| 1417 | 1426 | name="arrow.down.right.and.arrow.up.left" |
| @@ -1421,7 +1430,7 @@ function renderFeedEntry( · 3 unmodified lines | ||
| 1421 | 1430 | /> |
| 1422 | 1431 | <Text className="font-t3-medium text-xs text-foreground-muted">{label}</Text> |
| 1423 | 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 | 1434 | </View> |
| 1426 | 1435 | ); |
| 1427 | 1436 | } |
| @@ -1508,7 +1517,7 @@ function renderFeedEntry( · 80 unmodified lines | ||
| 1508 | 1517 | })} |
| 1509 | 1518 | </View> |
| 1510 | 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 | 1521 | {timestampLabel} |
| 1513 | 1522 | </Text> |
| 1514 | 1523 | {message.text.trim().length > 0 ? ( |
| @@ -1557,7 +1566,7 @@ function renderFeedEntry( · 42 unmodified lines | ||
| 1557 | 1566 | attachmentId={attachment.id} |
| 1558 | 1567 | name={attachment.name} |
| 1559 | 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 | 1570 | onPressPreview={props.onPressPreview} |
| 1562 | 1571 | /> |
| 1563 | 1572 | ) : isFileAttachment(attachment) ? ( |
| @@ -1581,7 +1590,7 @@ function renderFeedEntry( · 17 unmodified lines | ||
| 1581 | 1590 | buttonSize={28} |
| 1582 | 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 | 1594 | {timestampLabel} |
| 1586 | 1595 | </Text> |
| 1587 | 1596 | </View> |
| @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; · 33 unmodified lines | ||
| 34 | 34 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| 35 | 35 | import { cn } from "../../lib/cn"; |
| 36 | 36 | 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"; | |
| 38 | 42 | import { |
| 39 | 43 | resolveThreadWorkGroupInitialScroll, |
| 40 | 44 | shouldFollowThreadWorkGroupAppend, |
| @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { · 94 unmodified lines | ||
| 135 | 139 | } |
| 136 | 140 | |
| 137 | 141 | function ShimmerWorkContent(props: { |
| 142 | + readonly compact?: boolean; | |
| 138 | 143 | readonly environmentId?: EnvironmentId; |
| 139 | 144 | readonly highlighted: boolean; |
| 140 | 145 | readonly icon: WorkContentIcon; |
| @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { · 6 unmodified lines | ||
| 147 | 152 | }) { |
| 148 | 153 | return ( |
| 149 | 154 | <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} | |
| 167 | 174 | <Text |
| 168 | 175 | className={cn( |
| 169 | − "min-w-0 shrink text-sm", | |
| 176 | + "min-w-0 shrink", | |
| 177 | + props.compact ? "text-xs" : "text-sm", | |
| 170 | 178 | props.highlighted ? "text-foreground" : "text-foreground-muted", |
| 171 | 179 | )} |
| 172 | 180 | numberOfLines={1} |
| @@ -179,6 +187,8 @@ function ShimmerWorkContent(props: { · 6 unmodified lines | ||
| 179 | 187 | } |
| 180 | 188 | |
| 181 | 189 | export function ShimmeringWorkContent(props: { |
| 190 | + /** Secondary line: no icon slot, caption size. */ | |
| 191 | + readonly compact?: boolean; | |
| 182 | 192 | readonly environmentId?: EnvironmentId; |
| 183 | 193 | readonly icon: WorkContentIcon; |
| 184 | 194 | readonly iconSubtleColor: ColorValue; |
| @@ -194,7 +204,10 @@ export function ShimmeringWorkContent(props: { · 9 unmodified lines | ||
| 194 | 204 | const screenIsFocused = useIsFocused(); |
| 195 | 205 | const progress = useSharedValue(0); |
| 196 | 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 | + ); | |
| 198 | 211 | |
| 199 | 212 | useEffect(() => { |
| 200 | 213 | const subscription = AppState.addEventListener("change", (state) => { |
| @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { · 49 unmodified lines | ||
| 250 | 263 | onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} |
| 251 | 264 | > |
| 252 | 265 | <ShimmerWorkContent |
| 266 | + compact={props.compact} | |
| 253 | 267 | environmentId={props.environmentId} |
| 254 | 268 | highlighted={false} |
| 255 | 269 | icon={props.icon} |
| @@ -290,6 +304,7 @@ export function ShimmeringWorkContent(props: { · 34 unmodified lines | ||
| 290 | 304 | > |
| 291 | 305 | <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}> |
| 292 | 306 | <ShimmerWorkContent |
| 307 | + compact={props.compact} | |
| 293 | 308 | environmentId={props.environmentId} |
| 294 | 309 | highlighted |
| 295 | 310 | icon={props.icon} |
| @@ -773,7 +788,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( · 477 unmodified lines | ||
| 773 | 788 | color={props.iconSubtleColor} |
| 774 | 789 | colorClassName={ |
| 775 | 790 | iconIsDestructive |
| 776 | − ? "accent-danger-foreground" | |
| 791 | + ? "accent-adaptive-rose-600-400" | |
| 777 | 792 | : failed |
| 778 | 793 | ? "accent-danger-foreground/40" |
| 779 | 794 | : undefined |
| @@ -784,7 +799,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( · 4 unmodified lines | ||
| 784 | 799 | <Text |
| 785 | 800 | className={cn( |
| 786 | 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 | 804 | numberOfLines={1} |
| 790 | 805 | > |
| @@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( · 41 unmodified lines | ||
| 832 | 847 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} |
| 833 | 848 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} |
| 834 | 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 | 852 | {viewedImagePath ? ( |
| 838 | 853 | <View className="pb-1.5"> |
| @@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: { · 99 unmodified lines | ||
| 938 | 953 | ); |
| 939 | 954 | } |
| 940 | 955 | |
| 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 | + | |
| 941 | 1090 | export function ThreadThinkingRow(props: { |
| 942 | 1091 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| 943 | 1092 | readonly iconSubtleColor: ColorValue; |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | |
| 15 | 15 | import { |
| 16 | + agentSpawnSummary, | |
| 16 | 17 | buildPendingUserInputAnswers, |
| 17 | 18 | buildThreadFeed, |
| 18 | 19 | derivePendingApprovals, |
| @@ -24,6 +25,7 @@ import { · 5 unmodified lines | ||
| 24 | 25 | workEntryRowLabel, |
| 25 | 26 | type ThreadFeedActivity, |
| 26 | 27 | type ThreadFeedEntry, |
| 28 | + type WorkLogEntry, | |
| 27 | 29 | } from "./threadActivity"; |
| 28 | 30 | |
| 29 | 31 | describe("Codex feedback pseudo-messages", () => { |
| @@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => { · 2273 unmodified lines | ||
| 2303 | 2305 | new Set(), |
| 2304 | 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 | 2310 | expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ |
| 2307 | 2311 | ["work-toggle:work-group:activity-1", "work-toggle"], |
| 2308 | 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 | 2315 | expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ |
| 2312 | 2316 | false, |
| @@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => { · 68 unmodified lines | ||
| 2381 | 2385 | |
| 2382 | 2386 | const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); |
| 2383 | 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 | 2389 | // The row identity is stable across re-derivations so the list can reuse it. |
| 2386 | 2390 | expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( |
| 2387 | 2391 | rows[1], |
| @@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => { · 6 unmodified lines | ||
| 2394 | 2398 | ).toEqual(["message"]); |
| 2395 | 2399 | }); |
| 2396 | 2400 | |
| 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 | 2474 | it("hands a settled tool run off to Thinking once assistant text streams after it", () => { |
| 2398 | 2475 | const turnId = TurnId.make("turn-streaming-tail"); |
| 2399 | 2476 | const latestTurn = { |
| @@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => { · 413 unmodified lines | ||
| 2813 | 2890 | }), |
| 2814 | 2891 | ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); |
| 2815 | 2892 | |
| 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 | 2895 | const running = rowsFor([]); |
| 2817 | 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 | 2898 | ["shell-1", "Run tests"], |
| 2820 | 2899 | ]); |
| 2821 | 2900 | expect(running[0]).toMatchObject({ |
| 2901 | + createdAt: "2026-04-01T00:00:01.000Z", | |
| 2822 | 2902 | lifecycleStatus: "inProgress", |
| 2823 | 2903 | workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, |
| 2824 | 2904 | }); |
| 2825 | 2905 | |
| 2826 | 2906 | const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); |
| 2827 | 2907 | expect(oneDone[0]).toMatchObject({ |
| 2828 | − id: "a-progress", | |
| 2908 | + id: "a-start", | |
| 2829 | 2909 | summary: "Kicked off 2 subagents · 1 working", |
| 2830 | 2910 | lifecycleStatus: "inProgress", |
| 2831 | 2911 | }); |
| @@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => { · 3 unmodified lines | ||
| 2835 | 2915 | agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), |
| 2836 | 2916 | ]); |
| 2837 | 2917 | expect(allDone[0]).toMatchObject({ |
| 2838 | − id: "a-progress", | |
| 2918 | + id: "a-start", | |
| 2839 | 2919 | summary: "Ran 2 subagents · 1 failed", |
| 2840 | 2920 | lifecycleStatus: "failed", |
| 2841 | 2921 | status: "failure", |
| @@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => { · 1 unmodified line | ||
| 2843 | 2923 | expect(allDone).toHaveLength(2); |
| 2844 | 2924 | }); |
| 2845 | 2925 | |
| 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 | 3114 | it.each(["cancelled", "failed", "interrupted", "idle"] as const)( |
| 2847 | 3115 | "replaces Antigravity batch progress with %s", |
| 2848 | 3116 | (status) => { |
| @@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => { · 139 unmodified lines | ||
| 2988 | 3256 | expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); |
| 2989 | 3257 | }); |
| 2990 | 3258 | |
| 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 | 3308 | it("treats a Codex child's idle turn end as a finished batch member", () => { |
| 2992 | 3309 | const turnId = TurnId.make("turn-codex"); |
| 2993 | 3310 | const child = ( |
| @@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => { · 69 unmodified lines | ||
| 3063 | 3380 | expect(ids).toContain("nested-done"); |
| 3064 | 3381 | expect(ids).not.toContain("shell-done"); |
| 3065 | 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 | }); |
| @@ -122,6 +122,8 @@ export interface WorkLogEntry { · 121 unmodified lines | ||
| 122 | 122 | readonly title: string; |
| 123 | 123 | readonly status: WorkLogToolLifecycleStatus | undefined; |
| 124 | 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 | 129 | toolData?: unknown; |
| @@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry { · 4 unmodified lines | ||
| 132 | 134 | collapseKey?: string; |
| 133 | 135 | /** Grouping key for subagent lifecycle rows (one row per agent). */ |
| 134 | 136 | taskId?: string; |
| 137 | + /** The tool call that launched this agent, when the provider reports one. */ | |
| 138 | + agentSpawnToolCallId?: string; | |
| 135 | 139 | isWorkflowCoordinator?: boolean; |
| 136 | 140 | /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ |
| 137 | 141 | isBackgroundTask?: boolean; |
| @@ -187,12 +191,47 @@ export type ThreadFeedEntry = · 49 unmodified lines | ||
| 187 | 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 | 200 | readonly type: "thinking"; |
| 191 | 201 | readonly id: string; |
| 192 | 202 | readonly createdAt: string; |
| 193 | 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 | }; |
| 195 | 219 | |
| 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 | + | |
| 196 | 235 | export type ThreadFeedLatestTurn = Pick< |
| 197 | 236 | OrchestrationLatestTurn, |
| 198 | 237 | "turnId" | "state" | "startedAt" | "completedAt" |
| @@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean · 221 unmodified lines | ||
| 420 | 459 | return false; |
| 421 | 460 | } |
| 422 | 461 | const isTaskRow = |
| 462 | + activity.kind === "task.started" || | |
| 423 | 463 | activity.kind === "task.progress" || |
| 424 | 464 | activity.kind === "task.updated" || |
| 425 | 465 | activity.kind === "task.completed"; |
| @@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean · 15 unmodified lines | ||
| 441 | 481 | return payload.timelineBypass === true || ownedByAgent; |
| 442 | 482 | } |
| 443 | 483 | |
| 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 | + | |
| 444 | 493 | function deriveWorkLogEntries( |
| 445 | 494 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 446 | 495 | ): DerivedWorkLogEntry[] { |
| @@ -449,7 +498,11 @@ function deriveWorkLogEntries( · 2 unmodified lines | ||
| 449 | 498 | for (const activity of ordered) { |
| 450 | 499 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; |
| 451 | 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 | 506 | if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; |
| 454 | 507 | if (activity.kind === "tool.progress") continue; |
| 455 | 508 | if (activity.kind === "context-window.updated") continue; |
| @@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 40 unmodified lines | ||
| 496 | 549 | const toolPresentation = extractToolActivityPresentation(payload); |
| 497 | 550 | // Terminal task updates carry identity so they replace each child's progress row. |
| 498 | 551 | const isTaskActivity = |
| 552 | + activity.kind === "task.started" || | |
| 499 | 553 | activity.kind === "task.progress" || |
| 500 | 554 | activity.kind === "task.completed" || |
| 501 | 555 | activity.kind === "task.updated"; |
| @@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 37 unmodified lines | ||
| 539 | 593 | if (payload.agentKind !== "agent") { |
| 540 | 594 | entry.isBackgroundTask = true; |
| 541 | 595 | } |
| 596 | + const spawnToolCallId = asTrimmedString(payload.toolUseId); | |
| 597 | + if (spawnToolCallId) { | |
| 598 | + entry.agentSpawnToolCallId = spawnToolCallId; | |
| 599 | + } | |
| 542 | 600 | if ( |
| 543 | 601 | payload.taskType === "local_workflow" || |
| 544 | 602 | (typeof payload.workflowName === "string" && payload.workflowName.length > 0) |
| @@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 63 unmodified lines | ||
| 608 | 666 | entry.requestKind = requestKind; |
| 609 | 667 | } |
| 610 | 668 | 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"; | |
| 613 | 674 | } |
| 614 | 675 | // A Codex child that finishes its turn reports "idle" (resumable, not |
| 615 | 676 | // terminal). For the batch row that is a finished member. |
| @@ -681,6 +742,7 @@ function agentSpawnMember( · 65 unmodified lines | ||
| 681 | 742 | title: entry.toolTitle ?? previous?.title ?? entry.label, |
| 682 | 743 | status: entry.toolLifecycleStatus ?? previous?.status, |
| 683 | 744 | detail: entry.detail ?? previous?.detail, |
| 745 | + updatedAt: entry.createdAt, | |
| 684 | 746 | }; |
| 685 | 747 | } |
| 686 | 748 | |
| @@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus( · 26 unmodified lines | ||
| 713 | 775 | return "inProgress"; |
| 714 | 776 | } |
| 715 | 777 | if (statuses.includes("failed")) return "failed"; |
| 778 | + if (statuses.includes("declined")) return "declined"; | |
| 716 | 779 | if (statuses.includes("stopped")) return "stopped"; |
| 717 | 780 | return "completed"; |
| 718 | 781 | } |
| @@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries( · 10 unmodified lines | ||
| 729 | 792 | const spawnRowIndex = new Map<string, number>(); |
| 730 | 793 | const spawnGroupByTaskId = new Map<string, string>(); |
| 731 | 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 | 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 | 811 | const isTaskRow = |
| 734 | 812 | entry.taskId !== undefined && |
| 735 | − (entry.sourceActivityKind === "task.progress" || | |
| 813 | + (entry.sourceActivityKind === "task.started" || | |
| 814 | + entry.sourceActivityKind === "task.progress" || | |
| 736 | 815 | entry.sourceActivityKind === "task.completed" || |
| 737 | 816 | entry.sourceActivityKind === "task.updated"); |
| 738 | 817 | if (isTaskRow && entry.taskId !== undefined) { |
| @@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) { · 403 unmodified lines | ||
| 1142 | 1221 | return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); |
| 1143 | 1222 | } |
| 1144 | 1223 | |
| 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 | + | |
| 1145 | 1294 | function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null { |
| 1146 | 1295 | const lines = agentSpawnMembers(spawn).map((agent) => { |
| 1147 | 1296 | const status = |
| @@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation( · 602 unmodified lines | ||
| 1750 | 1899 | ): ThreadFeedEntry[] { |
| 1751 | 1900 | const sourceFeed = feed.filter( |
| 1752 | 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 | 1907 | const activeTailGroup = sourceFeed.findLast( |
| 1756 | 1908 | (entry) => entry.type !== "message" || !isEmptyMessage(entry), |
| @@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation( · 55 unmodified lines | ||
| 1812 | 1964 | } |
| 1813 | 1965 | // A working turn always shows one live activity. When no tool row is |
| 1814 | 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 | 1970 | if ( |
| 1816 | 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 | 1982 | result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); |
| 1820 | 1983 | } |
| 1821 | 1984 | return result; |
| 1822 | 1985 | } |
| 1823 | 1986 | |
| 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 | + | |
| 1824 | 1994 | function thinkingRow(createdAt: string, turnId: TurnId | null) { |
| 1825 | 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 | 1998 | return cachedThinkingRow; |
| 1829 | 1999 | } |
| @@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry( · 22 unmodified lines | ||
| 1852 | 2022 | cached.isWorking !== isWorking || |
| 1853 | 2023 | cached.activeTail !== activeTail || |
| 1854 | 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 | 2030 | const rows: ThreadFeedEntry[] = []; |
| @@ -1908,11 +2080,27 @@ function appendActivityGroupRows( · 49 unmodified lines | ||
| 1908 | 2080 | groupableRun = []; |
| 1909 | 2081 | }; |
| 1910 | 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 | 2085 | groupableRun.push(activity); |
| 1913 | 2086 | continue; |
| 1914 | 2087 | } |
| 1915 | 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 | 2104 | result.push({ |
| 1917 | 2105 | type: "activity-group", |
| 1918 | 2106 | id: activity.id, |
| @@ -1953,7 +2141,9 @@ function appendToolGroupRows( · 34 unmodified lines | ||
| 1953 | 2141 | const latestActivity = latestActiveActivity ?? activities.at(-1)!; |
| 1954 | 2142 | // Like web, the trailing run keeps shining after its latest call succeeds; |
| 1955 | 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 | 2147 | const singleActivity = activities.length === 1 ? latestActivity : null; |
| 1958 | 2148 | const summary = live |
| 1959 | 2149 | ? liveToolActivitySummary(latestActivity, live) |
| @@ -1994,7 +2184,9 @@ function appendToolGroupRows( · 34 unmodified lines | ||
| 1994 | 2184 | : undefined; |
| 1995 | 2185 | result.push({ |
| 1996 | 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 | 2190 | createdAt: sourceGroup.createdAt, |
| 1999 | 2191 | turnId: sourceGroup.turnId, |
| 2000 | 2192 | groupId, |