| author | |
| committer | |
| log | b7465a3bc993e7f10f4ec7a469759b95eb4c2f2a |
| tree | b19da481a0bef7d8b84602cc9bb1698be6f09a9a |
| parent | 4f782bedaf49b914903eb08501f35c0940845c51 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 724 insertions(+), 52 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+21-12| ... | ... | @@ -153,6 +153,7 @@ import { |
| 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: { |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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> |
apps/mobile/src/features/threads/thread-work-log.tsx+172-23| ... | ... | @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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( |
| 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( |
| 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( |
| 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: { |
| 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; |
apps/mobile/src/lib/threadActivity.test.ts+328-6| ... | ... | @@ -13,6 +13,7 @@ import { |
| 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 { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/mobile/src/lib/threadActivity.ts+203-11| ... | ... | @@ -122,6 +122,8 @@ export interface WorkLogEntry { |
| 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 { |
| 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 = |
| 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 |
| 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 |
| 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( |
| 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 |
| 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 |
| 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 |
| 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( |
| 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( |
| 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( |
| 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"]>) { |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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, |