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