1import { SubagentStatusDot } from "./SubagentStatusDot";
2import { ThreadSubagentGroup } from "./thread-subagent-group";
3import {
4 WorkLogLabel,
5 WorkLogBlock,
6 WorkLogRows,
7 WorkLogIconSlot,
8 WorkLogPressable,
9} from "./work-log-layout";
10import { QuestionAnswerHistory } from "./QuestionAnswerHistory";
11import {
12 getQuestionAnswerPreview,
13 hasQuestionAnswer,
14} from "@t3tools/client-runtime/work-log/user-input";
15import * as Haptics from "expo-haptics";
16import { Image } from "expo-image";
17import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
18import { MaskedView } from "@expo/ui/community/masked-view";
19import type { LegendListRef } from "@legendapp/list/react-native";
20import { AnimatedLegendList } from "@legendapp/list/reanimated";
21import { StackActions, useIsFocused, useNavigation } from "@react-navigation/native";
22import {
23 memo,
24 useCallback,
25 useEffect,
26 useId,
27 useLayoutEffect,
28 useMemo,
29 useRef,
30 useState,
31 type ComponentProps,
32 type ReactNode,
33} from "react";
34import {
35 AccessibilityInfo,
36 AppState,
37 type ColorValue,
38 Pressable,
39 ScrollView,
40 StyleSheet,
41 View,
42} from "react-native";
43import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
44import {
45 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
46 type EnvironmentId,
47 type RunId,
48 type ThreadId,
49 type ToolActivityIcon,
50} from "@t3tools/contracts";
51import { useAtomValue } from "@effect/atom-react";
52import { scopeThreadRef } from "@t3tools/client-runtime/environment";
53import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
54import { useAtomCommand } from "../../state/use-atom-command";
55import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
56import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
57import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
58
59import { AppText as Text } from "../../components/AppText";
60import { KnifeMark } from "../../components/KnifeMark";
61import { BrowserAppIcon } from "../../components/BrowserAppIcon";
62import { cn } from "../../lib/cn";
63import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
64import {
65 type AgentSpawnSummary,
66 formatItemFullDetail,
67 type ThreadFeedActivity,
68 workEntryRowLabel,
69} from "../../lib/threadActivity";
70import {
71 toolCallLines,
72 turnItemOutputImages,
73 turnItemOutputText,
74} from "@t3tools/client-runtime/work-log/item-detail";
75import { useTurnItemDetail } from "../../state/queries";
76import {
77 resolveThreadWorkGroupInitialScroll,
78 shouldFollowThreadWorkGroupAppend,
79 type ThreadWorkGroupScrollPosition,
80} from "./thread-feed-live-follow";
81import {
82 resolveWorkEntryToolPresentation,
83 toolGroupAction,
84 type ToolGroupSummaryKind,
85 workEntryViewedImagePath,
86} from "@t3tools/client-runtime/work-log/presentation";
87import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
88import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
89import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
90import type { FilePreviewSource } from "../../components/FilePreviewModal";
91import { FilePreviewGallery } from "../../components/FilePreviewGallery";
92import { ThreadMarkdownImage } from "./ThreadMarkdownImage";
93import Animated, {
94 cancelAnimation,
95 Easing,
96 FadeIn,
97 FadeOut,
98 LinearTransition,
99 ReduceMotion,
100 useAnimatedStyle,
101 useSharedValue,
102 withDelay,
103 withRepeat,
104 withSequence,
105 withTiming,
106} from "react-native-reanimated";
107import { useAssetUrl } from "../../state/assets";
108
109const SHIMMER_WIDTH = 72;
110const SHIMMER_SWEEP_MS = 1_350;
111const SHIMMER_PAUSE_MS = 1_450;
112const SHIMMER_ICON_AND_GAP_WIDTH = 30;
113export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
114const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
115const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
116const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
117type WorkContentIcon =
118 | AppSymbolName
119 | "browser"
120 | "computer"
121 | "device"
122 | "t3-code"
123 | "pull-request";
124
125function WorkLogIcon(props: {
126 readonly icon: WorkContentIcon;
127 readonly color: ColorValue;
128 readonly colorClassName?: string;
129 readonly highlighted?: boolean;
130}) {
131 const colorClassName = props.highlighted ? "accent-foreground" : props.colorClassName;
132 if (props.icon === "t3-code") {
133 return (
134 <KnifeMark height={14} />
135 );
136 }
137 if (props.icon === "computer") {
138 return (
139 <BrowserAppIcon
140 size={14}
141 {...(colorClassName ? { colorClassName } : { color: props.color })}
142 />
143 );
144 }
145 return (
146 <SymbolView
147 name={
148 props.icon === "pull-request"
149 ? "arrow.triangle.pull"
150 : props.icon === "browser"
151 ? { ios: "globe", android: "public" }
152 : props.icon === "device"
153 ? { ios: "iphone", android: "smartphone" }
154 : props.icon
155 }
156 size={14}
157 weight="medium"
158 {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
159 type="monochrome"
160 />
161 );
162}
163
164export function ThreadDisclosureChevron(props: {
165 readonly expanded: boolean;
166 readonly collapsedDirection: "right" | "down";
167 readonly size: number;
168 readonly tintColor: ColorValue;
169}) {
170 const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
171 const rotation = useSharedValue(props.expanded ? expandedAngle : 0);
172
173 useLayoutEffect(() => {
174 rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
175 duration: THREAD_DISCLOSURE_TRANSITION_MS,
176 reduceMotion: ReduceMotion.System,
177 });
178 }, [expandedAngle, props.expanded, rotation]);
179
180 const rotationStyle = useAnimatedStyle(() => ({
181 transform: [{ rotate: `${rotation.value}deg` }],
182 }));
183
184 return (
185 <Animated.View
186 accessible={false}
187 accessibilityElementsHidden
188 importantForAccessibility="no-hide-descendants"
189 pointerEvents="none"
190 style={[{ width: props.size, height: props.size }, rotationStyle]}
191 >
192 <SymbolView
193 name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
194 size={props.size}
195 tintColor={props.tintColor}
196 type="monochrome"
197 />
198 </Animated.View>
199 );
200}
201
202function ShimmerWorkContent(props: {
203 readonly textClassName?: string;
204 readonly compact?: boolean;
205 readonly environmentId?: EnvironmentId;
206 readonly highlighted: boolean;
207 readonly icon: WorkContentIcon;
208 readonly iconSubtleColor: ColorValue;
209 readonly label: string;
210 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];
211 readonly showIcon: boolean;
212 readonly themeAppearance?: "light" | "dark";
213 readonly toolIcon?: ToolActivityIcon;
214}) {
215 return (
216 <View className="flex-row items-center gap-1.5">
217 {props.showIcon ? (
218 <View className="h-6 w-6 shrink-0 items-center justify-center">
219 {props.toolIcon && props.environmentId ? (
220 <ToolActivityIconView
221 environmentId={props.environmentId}
222 icon={props.toolIcon}
223 fallback={props.icon}
224 fallbackColor={props.iconSubtleColor}
225 themeAppearance={props.themeAppearance ?? "light"}
226 />
227 ) : (
228 <WorkLogIcon
229 icon={props.icon}
230 color={props.iconSubtleColor}
231 highlighted={props.highlighted}
232 />
233 )}
234 </View>
235 ) : null}
236 <Text
237 className={cn(
238 "min-w-0 shrink",
239 props.compact ? "text-xs" : "text-sm",
240 props.highlighted ? "text-foreground" : "text-foreground-muted",
241 props.textClassName,
242 )}
243 numberOfLines={1}
244 onTextLayout={props.onTextLayout}
245 >
246 {props.label}
247 </Text>
248 </View>
249 );
250}
251
252export function ShimmeringWorkContent(props: {
253 readonly className?: string;
254 readonly textClassName?: string;
255 /** Secondary line: no icon slot, caption size. */
256 readonly compact?: boolean;
257 readonly environmentId?: EnvironmentId;
258 readonly icon: WorkContentIcon;
259 readonly iconSubtleColor: ColorValue;
260 readonly label: string;
261 readonly showIcon: boolean;
262 readonly themeAppearance?: "light" | "dark";
263 readonly toolIcon?: ToolActivityIcon;
264}) {
265 const [availableWidth, setAvailableWidth] = useState(0);
266 const [textWidth, setTextWidth] = useState(0);
267 const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active");
268 const [reducedMotion, setReducedMotion] = useState(true);
269 const screenIsFocused = useIsFocused();
270 const progress = useSharedValue(0);
271 const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`;
272 const contentWidth = Math.min(
273 availableWidth,
274 (props.showIcon ? SHIMMER_ICON_AND_GAP_WIDTH : 0) + Math.ceil(textWidth),
275 );
276
277 useEffect(() => {
278 const subscription = AppState.addEventListener("change", (state) => {
279 setAppIsActive(state === "active");
280 });
281 return () => subscription.remove();
282 }, []);
283
284 useEffect(() => {
285 void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion);
286 const subscription = AccessibilityInfo.addEventListener(
287 "reduceMotionChanged",
288 setReducedMotion,
289 );
290 return () => subscription.remove();
291 }, []);
292
293 useEffect(() => {
294 cancelAnimation(progress);
295 progress.value = 0;
296 if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return;
297
298 progress.value = withRepeat(
299 withSequence(
300 withTiming(1, {
301 duration: SHIMMER_SWEEP_MS,
302 easing: Easing.linear,
303 reduceMotion: ReduceMotion.Never,
304 }),
305 withDelay(
306 SHIMMER_PAUSE_MS,
307 withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }),
308 ),
309 ),
310 -1,
311 false,
312 undefined,
313 ReduceMotion.Never,
314 );
315 return () => cancelAnimation(progress);
316 }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]);
317
318 const sweepStyle = useAnimatedStyle(() => ({
319 transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }],
320 }));
321 const counterSweepStyle = useAnimatedStyle(() => ({
322 transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }],
323 }));
324
325 return (
326 <View
327 className={cn("min-w-0 flex-1 overflow-hidden", props.className)}
328 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
329 >
330 <ShimmerWorkContent
331 textClassName={props.textClassName}
332 compact={props.compact}
333 environmentId={props.environmentId}
334 highlighted={false}
335 icon={props.icon}
336 iconSubtleColor={props.iconSubtleColor}
337 label={props.label}
338 showIcon={props.showIcon}
339 themeAppearance={props.themeAppearance}
340 toolIcon={props.toolIcon}
341 onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}
342 />
343 {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (
344 <Animated.View
345 className="absolute inset-y-0 left-0 overflow-hidden"
346 pointerEvents="none"
347 accessible={false}
348 accessibilityElementsHidden
349 importantForAccessibility="no-hide-descendants"
350 style={[{ width: SHIMMER_WIDTH }, sweepStyle]}
351 >
352 <MaskedView
353 style={StyleSheet.absoluteFill}
354 maskElement={
355 <Svg width="100%" height="100%">
356 <Defs>
357 <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
358 <Stop offset="0" stopColor="white" stopOpacity={0} />
359 <Stop offset="0.15" stopColor="white" stopOpacity={0.12} />
360 <Stop offset="0.35" stopColor="white" stopOpacity={0.55} />
361 <Stop offset="0.5" stopColor="white" stopOpacity={1} />
362 <Stop offset="0.65" stopColor="white" stopOpacity={0.55} />
363 <Stop offset="0.85" stopColor="white" stopOpacity={0.12} />
364 <Stop offset="1" stopColor="white" stopOpacity={0} />
365 </LinearGradient>
366 </Defs>
367 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
368 </Svg>
369 }
370 >
371 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
372 <ShimmerWorkContent
373 textClassName={props.textClassName}
374 compact={props.compact}
375 environmentId={props.environmentId}
376 highlighted
377 icon={props.icon}
378 iconSubtleColor={props.iconSubtleColor}
379 label={props.label}
380 showIcon={props.showIcon}
381 themeAppearance={props.themeAppearance}
382 toolIcon={props.toolIcon}
383 />
384 </Animated.View>
385 </MaskedView>
386 </Animated.View>
387 ) : null}
388 </View>
389 );
390}
391
392function workRowSymbolName(icon: ThreadFeedActivity["icon"]): WorkContentIcon {
393 switch (icon) {
394 case "agent":
395 return { ios: "sparkles", android: "auto_awesome" };
396 case "alert":
397 return { ios: "exclamationmark.triangle", android: "error" };
398 case "browser":
399 return { ios: "safari", android: "public" };
400 case "check":
401 return { ios: "checkmark", android: "check" };
402 case "command":
403 return { ios: "terminal", android: "terminal" };
404 case "computer":
405 return "computer";
406 case "code":
407 return "chevron.left.forwardslash.chevron.right";
408 case "edit":
409 return { ios: "square.and.pencil", android: "edit" };
410 case "eye":
411 return { ios: "eye", android: "visibility" };
412 case "globe":
413 return { ios: "globe", android: "public" };
414 case "search":
415 return "magnifyingglass";
416 case "hammer":
417 return { ios: "hammer", android: "construction" };
418 case "lock":
419 return { ios: "lock", android: "lock" };
420 case "message":
421 return { ios: "bubble.left", android: "chat_bubble" };
422 case "warning":
423 return { ios: "xmark", android: "close" };
424 case "wrench":
425 return { ios: "wrench", android: "build" };
426 case "zap":
427 return { ios: "bolt", android: "bolt" };
428 }
429}
430
431// Entering fades only for rows created moments ago: rows remount whenever the
432// list scrolls them back into view, and old rows must not replay an entrance.
433const FRESH_ROW_WINDOW_MS = 3_000;
434function isFreshRow(createdAt: string): boolean {
435 const timestamp = Date.parse(createdAt);
436 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
437}
438
439// The minimum matches min-h-8 below. Exact sizing is disabled when native
440// accessibility scaling can make the single-line text taller than that minimum.
441const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
442const WORK_ROW_GAP = 1; // gap-px
443const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem
444const WORK_GROUP_MAX_HEIGHT = 256;
445const WORK_GROUP_EDGE_FADE_HEIGHT = 12;
446
447export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
448
449function workLogRowsHeight(
450 activities: ReadonlyArray<ThreadFeedActivity>,
451 rowHeight = WORK_ROW_HEIGHT,
452): number {
453 return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP;
454}
455
456export function collapsedWorkLogHeight(
457 activities: ReadonlyArray<ThreadFeedActivity>,
458 continues = false,
459): number {
460 const bottomMargin = continues ? 0 : WORK_LOG_BOTTOM_MARGIN;
461 if (activities.length === 0) {
462 return 0;
463 }
464 if (activities[0]?.projectedItem.item.type === "subagent") {
465 return bottomMargin + WORK_ROW_HEIGHT;
466 }
467 const height = workLogRowsHeight(activities);
468 return (
469 bottomMargin +
470 (activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height)
471 );
472}
473
474interface ThreadWorkLogProps {
475 readonly continuesWorkLog?: boolean | undefined;
476 readonly activities: ReadonlyArray<ThreadFeedActivity>;
477 readonly anchorKey: string;
478 readonly environmentId: EnvironmentId;
479 readonly copiedRowId: string | null;
480 readonly expandedRows: Readonly<Record<string, boolean>>;
481 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
482 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
483 readonly iconSubtleColor: ColorValue;
484 /** Feed background, painted as the scroll-edge fade over a long group. */
485 readonly edgeFadeColor: string;
486 readonly themeAppearance: "light" | "dark";
487 readonly onCopyRow: (rowId: string, value: string) => void;
488 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
489 readonly renderImage: MarkdownImageRenderer;
490 readonly renderMarkdown: (text: string) => ReactNode;
491 readonly onPressPreview: (source: FilePreviewSource) => void;
492}
493
494export function ThreadWorkLog(props: ThreadWorkLogProps) {
495 const renderRow = useCallback(
496 (row: ThreadFeedActivity) => (
497 <ThreadWorkLogRow
498 key={row.id}
499 row={row}
500 anchorKey={props.anchorKey}
501 copied={props.copiedRowId === row.id}
502 expanded={props.expandedRows[row.id] ?? false}
503 environmentId={props.environmentId}
504 iconSubtleColor={props.iconSubtleColor}
505 onCopyRow={props.onCopyRow}
506 onToggleRow={props.onToggleRow}
507 renderImage={props.renderImage}
508 renderMarkdown={props.renderMarkdown}
509 onPressPreview={props.onPressPreview}
510 themeAppearance={props.themeAppearance}
511 />
512 ),
513 [
514 props.anchorKey,
515 props.copiedRowId,
516 props.expandedRows,
517 props.environmentId,
518 props.iconSubtleColor,
519 props.onCopyRow,
520 props.onToggleRow,
521 props.renderImage,
522 props.renderMarkdown,
523 props.onPressPreview,
524 props.themeAppearance,
525 ],
526 );
527
528 if (props.activities.length === 0) {
529 return null;
530 }
531
532 if (props.activities[0]?.projectedItem.item.type === "subagent") {
533 return <ThreadSubagentGroup {...props} />;
534 }
535
536 if (
537 props.activities[0]?.groupedToolDetail &&
538 props.activities.every((row) => row.projectedItem.item.type === "reasoning")
539 ) {
540 return (
541 <ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1">
542 {props.activities.map((row) => (
543 <View key={row.id}>{props.renderMarkdown(row.workEntry.detail ?? "")}</View>
544 ))}
545 </ScrollView>
546 );
547 }
548
549 return (
550 <WorkLogBlock continues={props.continuesWorkLog}>
551 {props.activities[0]?.groupedToolDetail ? (
552 <ThreadWorkGroupList
553 activities={props.activities}
554 edgeFadeColor={props.edgeFadeColor}
555 expandedRows={props.expandedRows}
556 groupId={props.anchorKey}
557 rowSizing={props.rowSizing}
558 scrollPositions={props.scrollPositions}
559 renderRow={renderRow}
560 />
561 ) : (
562 <WorkLogRows>{props.activities.map(renderRow)}</WorkLogRows>
563 )}
564 </WorkLogBlock>
565 );
566}
567
568function ThreadWorkGroupList(props: {
569 readonly activities: ReadonlyArray<ThreadFeedActivity>;
570 readonly edgeFadeColor: string;
571 readonly expandedRows: Readonly<Record<string, boolean>>;
572 readonly groupId: string;
573 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
574 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
575 readonly renderRow: (row: ThreadFeedActivity) => ReactNode;
576}) {
577 const estimatedRowsHeight = workLogRowsHeight(
578 props.activities,
579 props.rowSizing.estimatedRowHeight,
580 );
581 const [initialPosition] = useState(() => {
582 const position = props.scrollPositions.get(props.groupId);
583 return props.activities.some((row) => row.id === position?.rowId) ? position : undefined;
584 });
585 const [initialScrollIndex] = useState(() =>
586 resolveThreadWorkGroupInitialScroll(props.activities, initialPosition),
587 );
588 const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined);
589 const listRef = useRef<LegendListRef>(null);
590 const loadedRef = useRef(false);
591 const userScrollingRef = useRef(false);
592 const pendingAppendHeightRef = useRef<number | null>(null);
593 const previousContent = useRef({
594 rows: props.activities,
595 height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0),
596 expandedRows: props.expandedRows,
597 });
598 const [measuredContent, setMeasuredContent] = useState(() => ({
599 height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0),
600 rowCount: props.activities.length,
601 }));
602 const contentHeight = Math.max(
603 1,
604 measuredContent.height +
605 Math.max(0, props.activities.length - measuredContent.rowCount) *
606 (props.rowSizing.estimatedRowHeight + WORK_ROW_GAP),
607 );
608 const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT);
609 const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0);
610 const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]);
611
612 // Each edge fades only while content continues past it. Scroll offset stays
613 // on the UI thread; only content-size changes update React state.
614 const topFadeStyle = useAnimatedStyle(() => ({
615 opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT),
616 }));
617 const bottomFadeStyle = useAnimatedStyle(() => ({
618 opacity: Math.min(
619 1,
620 Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT,
621 ),
622 }));
623 const rememberPosition = useCallback(() => {
624 if (!loadedRef.current) return;
625 const state = listRef.current?.getState();
626 const position = state && resolveWorkGroupScrollAnchor(state);
627 if (!state || !position) return;
628 props.scrollPositions.set(props.groupId, {
629 ...position,
630 contentHeight: state.contentLength,
631 });
632 }, [props.groupId, props.scrollPositions]);
633 const finishPendingAppend = useCallback(() => {
634 const targetHeight = pendingAppendHeightRef.current;
635 const state = listRef.current?.getState();
636 if (
637 targetHeight !== null &&
638 state &&
639 !userScrollingRef.current &&
640 Math.abs(state.scrollLength - targetHeight) <= 1
641 ) {
642 pendingAppendHeightRef.current = null;
643 void listRef.current?.scrollToEnd({ animated: false });
644 }
645 }, []);
646 const onContentSizeChange = useCallback(
647 (nextHeight: number) => {
648 const previous = previousContent.current;
649 const detailsChanged = previous.expandedRows !== props.expandedRows;
650 const followAppend =
651 loadedRef.current &&
652 shouldFollowThreadWorkGroupAppend({
653 previousRows: previous.rows,
654 rows: props.activities,
655 previousContentHeight: previous.height,
656 contentHeight: nextHeight,
657 viewportHeight: Math.min(previous.height, WORK_GROUP_MAX_HEIGHT),
658 scrollOffset: scrollOffset.value,
659 detailsChanged,
660 userScrolling: userScrollingRef.current,
661 });
662 previousContent.current = {
663 rows: props.activities,
664 height: nextHeight,
665 expandedRows: props.expandedRows,
666 };
667 setMeasuredContent((current) =>
668 current.height === nextHeight && current.rowCount === props.activities.length
669 ? current
670 : { height: nextHeight, rowCount: props.activities.length },
671 );
672 // Follow new calls only, never a detail toggle or a growing tool result.
673 if (followAppend) {
674 pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT);
675 } else if (detailsChanged || userScrollingRef.current || previous.rows !== props.activities) {
676 pendingAppendHeightRef.current = null;
677 } else if (pendingAppendHeightRef.current !== null) {
678 pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT);
679 }
680 // A short group can grow its viewport on this append. Wait for that
681 // layout before calculating the end offset, rather than jumping twice.
682 finishPendingAppend();
683 rememberPosition();
684 },
685 [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition],
686 );
687 // The native ScrollView reports its content size a frame or more after
688 // LegendList has laid the rows out, so a detail toggle rendered the group
689 // at its old height while the rows below already moved. Read the size
690 // LegendList computes on the JS thread instead; it settles in the same
691 // commit as the row measurement that changed it.
692 const onContentSizeChangeRef = useRef(onContentSizeChange);
693 useLayoutEffect(() => {
694 onContentSizeChangeRef.current = onContentSizeChange;
695 }, [onContentSizeChange]);
696 const subscribeToContentSize = useCallback((list: LegendListRef | null) => {
697 listRef.current = list;
698 if (!list) return;
699 const unsubscribe = list.getState().listen("totalSize", () => {
700 onContentSizeChangeRef.current(list.getState().contentLength);
701 });
702 onContentSizeChangeRef.current(list.getState().contentLength);
703 return unsubscribe;
704 }, []);
705 const getFixedItemSize = useCallback(
706 (row: ThreadFeedActivity, index: number) =>
707 props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined
708 ? undefined
709 : props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0),
710 [props.activities.length, props.expandedRows, props.rowSizing.fixedRowHeight],
711 );
712 const renderItem = useCallback(
713 ({ item, index }: { item: ThreadFeedActivity; index: number }) => (
714 <View className={index < props.activities.length - 1 ? "pb-px" : undefined}>
715 {props.renderRow(item)}
716 </View>
717 ),
718 [props.activities.length, props.renderRow],
719 );
720
721 return (
722 <View style={{ height, overflow: "hidden" }}>
723 <AnimatedLegendList
724 ref={subscribeToContentSize}
725 data={props.activities}
726 keyExtractor={workLogRowKey}
727 estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP}
728 getFixedItemSize={getFixedItemSize}
729 initialScrollIndex={initialScrollIndex}
730 // Bootstrap overscan is only 50px. An offset inside expanded detail can
731 // otherwise leave its own row unmeasured until after scroll restoration.
732 alwaysRender={
733 restoringPosition && initialPosition ? { keys: [initialPosition.rowId] } : undefined
734 }
735 recycleItems={false}
736 extraData={props.renderRow}
737 renderItem={renderItem}
738 sharedValues={sharedValues}
739 onLayout={finishPendingAppend}
740 onLoad={() => {
741 loadedRef.current = true;
742 setRestoringPosition(false);
743 rememberPosition();
744 }}
745 onScroll={rememberPosition}
746 onScrollBeginDrag={() => {
747 userScrollingRef.current = true;
748 pendingAppendHeightRef.current = null;
749 }}
750 onScrollEndDrag={() => {
751 userScrollingRef.current = false;
752 }}
753 onMomentumScrollBegin={() => {
754 userScrollingRef.current = true;
755 pendingAppendHeightRef.current = null;
756 }}
757 onMomentumScrollEnd={() => {
758 userScrollingRef.current = false;
759 rememberPosition();
760 }}
761 maintainVisibleContentPosition
762 nestedScrollEnabled
763 directionalLockEnabled
764 showsVerticalScrollIndicator
765 scrollsToTop={false}
766 bounces={false}
767 keyboardShouldPersistTaps="handled"
768 style={{ height }}
769 />
770 <Animated.View
771 pointerEvents="none"
772 className="absolute inset-x-0 top-0"
773 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topFadeStyle]}
774 >
775 <EdgeFade color={props.edgeFadeColor} direction="down" />
776 </Animated.View>
777 <Animated.View
778 pointerEvents="none"
779 className="absolute inset-x-0 bottom-0"
780 style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomFadeStyle]}
781 >
782 <EdgeFade color={props.edgeFadeColor} direction="up" />
783 </Animated.View>
784 </View>
785 );
786}
787
788/** A screen-colored gradient painted over the list edge that still has content past it. */
789function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) {
790 const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`;
791 return (
792 <Svg width="100%" height="100%">
793 <Defs>
794 <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
795 <Stop
796 offset={0}
797 stopColor={props.color}
798 stopOpacity={props.direction === "down" ? 1 : 0}
799 />
800 <Stop
801 offset={1}
802 stopColor={props.color}
803 stopOpacity={props.direction === "down" ? 0 : 1}
804 />
805 </LinearGradient>
806 </Defs>
807 <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
808 </Svg>
809 );
810}
811
812function workLogRowKey(row: ThreadFeedActivity): string {
813 return row.id;
814}
815
816/** Shown while the thread's run still ends in this failed preparation. */
817function WorkspacePreparationRetryButton(props: {
818 readonly environmentId: EnvironmentId;
819 readonly threadId: ThreadId;
820 readonly runId: RunId;
821}) {
822 const retryable = useAtomValue(
823 environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, props.threadId)),
824 (thread) => {
825 const run = thread?.projection.runs.find((candidate) => candidate.id === props.runId);
826 return run?.status === "failed" && run.workspacePreparation !== undefined;
827 },
828 );
829 const retry = useAtomCommand(threadEnvironment.retryWorkspacePreparation, "retry setup");
830 const [busy, setBusy] = useState(false);
831 if (!retryable) return null;
832 return (
833 <Pressable
834 accessibilityRole="button"
835 accessibilityLabel="Retry workspace preparation"
836 disabled={busy}
837 onPress={() => {
838 setBusy(true);
839 void Haptics.selectionAsync();
840 void retry({
841 environmentId: props.environmentId,
842 input: { threadId: props.threadId, runId: props.runId },
843 }).finally(() => setBusy(false));
844 }}
845 className="ml-7 mt-2 min-h-11 flex-row items-center gap-1.5 self-start rounded-full border border-border px-4"
846 style={{ opacity: busy ? 0.5 : 1 }}
847 >
848 <SymbolView name="arrow.clockwise" size={13} tintColorClassName="accent-icon" />
849 <Text className="font-t3-medium text-sm text-foreground">Retry</Text>
850 </Pressable>
851 );
852}
853
854function ReplCodeText({
855 code,
856 theme,
857}: {
858 readonly code: string;
859 readonly theme: "light" | "dark";
860}) {
861 const lines = useMarkdownCodeHighlight({ code, language: "typescript", theme, enabled: true });
862 let offset = 0;
863 const highlighted = lines?.flatMap((line, lineIndex) => {
864 const separator = lineIndex > 0 ? "\n" : "";
865 offset += separator.length;
866 return [
867 separator,
868 ...line.map((token) => {
869 const start = offset;
870 offset += token.content.length;
871 return (
872 <Text key={start} style={token.color ? { color: token.color } : undefined}>
873 {token.content}
874 </Text>
875 );
876 }),
877 ];
878 });
879 return (
880 <Text selectable className="font-mono text-2xs leading-normal text-foreground">
881 {highlighted ?? code}
882 </Text>
883 );
884}
885
886const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
887 props: Omit<
888 ThreadWorkLogProps,
889 | "continuesWorkLog"
890 | "activities"
891 | "copiedRowId"
892 | "edgeFadeColor"
893 | "expandedRows"
894 | "rowSizing"
895 | "scrollPositions"
896 > & {
897 readonly row: ThreadFeedActivity;
898 readonly copied: boolean;
899 readonly expanded: boolean;
900 },
901) {
902 const { row, expanded } = props;
903 const navigation = useNavigation();
904 const fetchedDetail = useTurnItemDetail(
905 expanded && row.fetchesDetail
906 ? { environmentId: props.environmentId, row: row.projectedItem }
907 : null,
908 );
909 const failureItem = row.projectedItem.item;
910 if (failureItem.type === "notification" && failureItem.source.kind === "system") {
911 const failed = failureItem.outcome === "failed";
912 return (
913 <View accessibilityLiveRegion="polite" className="py-1">
914 <WorkLogPressable onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}>
915 <WorkLogIconSlot>
916 <WorkLogIcon
917 icon={failed ? "exclamationmark.circle" : "t3-code"}
918 color={props.iconSubtleColor}
919 colorClassName={failed ? "accent-danger-foreground" : undefined}
920 />
921 </WorkLogIconSlot>
922 <Text
923 selectable
924 className={cn(
925 "min-w-0 flex-1 font-t3-medium text-sm text-foreground-muted",
926 failed && "text-adaptive-rose-600-400",
927 )}
928 >
929 {failureItem.summary}
930 </Text>
931 </WorkLogPressable>
932 {failureItem.detail ? (
933 <View className="ml-7 pt-1">{props.renderMarkdown(failureItem.detail)}</View>
934 ) : null}
935 </View>
936 );
937 }
938 if (failureItem.type === "error" && failureItem.status === "failed") {
939 const warning = failureItem.failure.class === "usage_limit";
940 const timestamp = new Date(row.createdAt);
941 const resetAt = failureItem.failure.resetAt;
942 const resetTime = resetAt
943 ? new Date(resetAt).toLocaleString(undefined, {
944 month: "short",
945 day: "numeric",
946 hour: "numeric",
947 minute: "2-digit",
948 })
949 : null;
950 const label = warning
951 ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
952 : row.summary;
953 return (
954 <WorkLogPressable
955 accessibilityLabel={warning ? label : `${row.summary}: ${failureItem.failure.message}`}
956 accessibilityHint="Long press to copy."
957 onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
958 >
959 <View className="flex-1 py-1">
960 <View className="flex-row items-center gap-1.5">
961 <WorkLogIconSlot>
962 <WorkLogIcon
963 icon="exclamationmark.circle"
964 color={props.iconSubtleColor}
965 colorClassName={warning ? "accent-warning-foreground" : "accent-danger-foreground"}
966 />
967 </WorkLogIconSlot>
968 <Text
969 className={
970 warning
971 ? "min-w-0 flex-1 font-t3-medium text-sm text-warning-foreground"
972 : "min-w-0 flex-1 font-t3-medium text-sm text-adaptive-rose-600-400"
973 }
974 >
975 {label}
976 </Text>
977 {props.copied ? (
978 <Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400">
979 Copied
980 </Text>
981 ) : null}
982 <Text
983 accessibilityLabel={timestamp.toLocaleString()}
984 className="shrink-0 text-xs text-foreground-subtle"
985 >
986 {timestamp.toLocaleString(undefined, {
987 month: "short",
988 day: "numeric",
989 hour: "numeric",
990 minute: "2-digit",
991 })}
992 </Text>
993 </View>
994 {!warning ? (
995 <Text selectable className="ml-7 text-sm text-foreground">
996 {failureItem.failure.message}
997 </Text>
998 ) : null}
999 {failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE &&
1000 failureItem.runId !== null ? (
1001 <WorkspacePreparationRetryButton
1002 environmentId={props.environmentId}
1003 threadId={failureItem.threadId}
1004 runId={failureItem.runId}
1005 />
1006 ) : null}
1007 </View>
1008 </WorkLogPressable>
1009 );
1010 }
1011 // A subagent's notification opens its thread, like the subagent's own row.
1012 const notifiedSubagentThreadId =
1013 row.projectedItem.item.type === "notification"
1014 ? notificationChildThreadId(row.projectedItem.item.source)
1015 : undefined;
1016 const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
1017 const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
1018 const fetchedItem = fetchedDetail.data?.item ?? null;
1019 // Reads keep their path list; the fetched file contents show as output.
1020 const isRead = toolGroupAction(row.workEntry) === "read";
1021 // Tool calls show the call in the foreground and the result muted below it.
1022 const shownItem = fetchedItem ?? row.projectedItem.item;
1023 const call =
1024 expanded && !isRead && shownItem.type === "command_execution"
1025 ? toolCallLines({ command: shownItem.input })
1026 : expanded && !isRead && shownItem.type === "dynamic_tool"
1027 ? toolCallLines({ args: shownItem.input })
1028 : expanded && shownItem.type === "file_search"
1029 ? toolCallLines({ args: { pattern: shownItem.pattern } })
1030 : expanded && shownItem.type === "web_search"
1031 ? toolCallLines({ args: { query: shownItem.patterns?.join(", ") } })
1032 : null;
1033 const failedExitCode =
1034 call && shownItem.type === "command_execution" && shownItem.exitCode
1035 ? shownItem.exitCode
1036 : null;
1037 const fullDetail =
1038 expanded && !reasoning && !call
1039 ? fetchedItem && !isRead
1040 ? formatItemFullDetail(row.projectedItem, fetchedItem)
1041 : row.getFullDetail()
1042 : null;
1043 const outputImages = expanded && fetchedItem ? turnItemOutputImages(fetchedItem) : [];
1044 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
1045 const fetchedOutput = !expanded
1046 ? null
1047 : shownItem.type === "file_search" || shownItem.type === "web_search"
1048 ? turnItemOutputText(shownItem)
1049 : fetchedItem
1050 ? (turnItemOutputText(fetchedItem) ??
1051 (outputImages.length > 0 || viewedImagePath ? null : "No output."))
1052 : fetchedDetail.error
1053 ? `Couldn't load output: ${fetchedDetail.error}`
1054 : row.fetchesDetail
1055 ? fetchedDetail.data
1056 ? "Output is no longer available."
1057 : "Loading output…"
1058 : null;
1059 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
1060 const previewText = workEntryRowLabel(row.workEntry);
1061 const answerPreview = row.workEntry.questionAnswer
1062 ? getQuestionAnswerPreview(row.workEntry.questionAnswer)
1063 : null;
1064 const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
1065 const displayText = workEntryRowLabel(row.workEntry, expanded);
1066 const isSystemNotice = row.projectedItem.item.type === "system_notice";
1067 const isUsageLimit =
1068 row.projectedItem.item.type === "error" &&
1069 row.projectedItem.item.failure.class === "usage_limit" &&
1070 row.projectedItem.item.status !== "completed";
1071 const iconIsDestructive =
1072 !isSystemNotice && !isUsageLimit && (row.icon === "alert" || row.icon === "warning");
1073 const failed = row.status === "failure";
1074 const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon;
1075 const icon = reasoning ? "brain" : (toolPresentation?.icon ?? workRowSymbolName(row.icon));
1076
1077 return (
1078 <Animated.View
1079 layout={WORK_LOG_LAYOUT_TRANSITION}
1080 className="overflow-hidden"
1081 {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
1082 >
1083 <WorkLogPressable
1084 accessibilityRole={
1085 notifiedSubagentThreadId !== undefined ? "link" : canExpand ? "button" : undefined
1086 }
1087 accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview}
1088 accessibilityHint={
1089 notifiedSubagentThreadId !== undefined
1090 ? "Opens this agent's thread. Long press to copy."
1091 : canExpand
1092 ? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
1093 : "Long press to copy."
1094 }
1095 accessibilityState={canExpand ? { expanded } : undefined}
1096 onPress={() => {
1097 if (notifiedSubagentThreadId !== undefined) {
1098 // Push, not navigate: navigate reuses this Thread route, so back
1099 // would skip the parent thread and land on Home (matches #15068).
1100 navigation.dispatch(
1101 StackActions.push("Thread", {
1102 environmentId: String(props.environmentId),
1103 threadId: String(notifiedSubagentThreadId),
1104 }),
1105 );
1106 return;
1107 }
1108 if (canExpand) {
1109 void Haptics.selectionAsync();
1110 props.onToggleRow(row.id, props.anchorKey);
1111 }
1112 }}
1113 onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
1114 >
1115 {row.live && !expanded ? (
1116 <ShimmeringWorkContent
1117 environmentId={props.environmentId}
1118 icon={icon}
1119 iconSubtleColor={props.iconSubtleColor}
1120 label={displayText}
1121 showIcon
1122 themeAppearance={props.themeAppearance}
1123 toolIcon={toolIcon}
1124 />
1125 ) : (
1126 <>
1127 <WorkLogIconSlot>
1128 {toolIcon ? (
1129 <ToolActivityIconView
1130 environmentId={props.environmentId}
1131 icon={toolIcon}
1132 fallback={icon}
1133 fallbackColor={props.iconSubtleColor}
1134 themeAppearance={props.themeAppearance}
1135 />
1136 ) : (
1137 <WorkLogIcon
1138 icon={icon}
1139 color={props.iconSubtleColor}
1140 colorClassName={
1141 isUsageLimit
1142 ? "accent-warning-foreground"
1143 : iconIsDestructive
1144 ? "accent-adaptive-rose-600-400"
1145 : failed
1146 ? "accent-danger-foreground/40"
1147 : undefined
1148 }
1149 />
1150 )}
1151 </WorkLogIconSlot>
1152 <WorkLogLabel
1153 tone={isUsageLimit ? "warning" : iconIsDestructive ? "danger" : "default"}
1154 >
1155 {isSystemNotice ? row.summary : displayText}
1156 {answerPreview ? (
1157 <Text
1158 className={
1159 !expanded &&
1160 row.workEntry.questionAnswer &&
1161 hasQuestionAnswer(row.workEntry.questionAnswer)
1162 ? "text-foreground"
1163 : "text-foreground-subtle"
1164 }
1165 >{` ${answerPreview}`}</Text>
1166 ) : null}
1167 </WorkLogLabel>
1168 </>
1169 )}
1170
1171 <View className="shrink-0 flex-row items-center gap-px">
1172 {props.copied ? (
1173 <Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400">
1174 Copied
1175 </Text>
1176 ) : null}
1177 {failed && toolIcon !== undefined ? (
1178 <View
1179 className="h-4 w-4 items-center justify-center"
1180 accessibilityElementsHidden
1181 importantForAccessibility="no-hide-descendants"
1182 >
1183 <SymbolView
1184 name="xmark"
1185 size={11}
1186 tintColorClassName="accent-danger-foreground/40"
1187 type="monochrome"
1188 />
1189 </View>
1190 ) : null}
1191 <View className="h-4 w-4 items-center justify-center">
1192 {canExpand ? (
1193 <ThreadDisclosureChevron
1194 expanded={expanded}
1195 collapsedDirection="down"
1196 size={11}
1197 tintColor={props.iconSubtleColor}
1198 />
1199 ) : null}
1200 </View>
1201 </View>
1202 </WorkLogPressable>
1203
1204 {expanded &&
1205 (reasoning ||
1206 fullDetail ||
1207 call ||
1208 fetchedOutput ||
1209 viewedImagePath ||
1210 outputImages.length > 0 ||
1211 row.workEntry.questionAnswer) ? (
1212 <Animated.View
1213 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
1214 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
1215 layout={WORK_LOG_LAYOUT_TRANSITION}
1216 className={reasoning ? "ml-7 py-1" : "pb-1 pt-0.5"}
1217 >
1218 {row.workEntry.questionAnswer ? (
1219 <QuestionAnswerHistory
1220 environmentId={props.environmentId}
1221 answer={row.workEntry.questionAnswer}
1222 />
1223 ) : null}
1224 {viewedImagePath ? (
1225 <View className="pb-1.5">
1226 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
1227 </View>
1228 ) : null}
1229 <FilePreviewGallery
1230 value={outputImages.map((resource) => ({
1231 kind: "image",
1232 environmentId: props.environmentId,
1233 resource,
1234 }))}
1235 >
1236 {outputImages.map((resource) => (
1237 <View key={resource.index} className="pb-1.5">
1238 <ThreadMarkdownImage
1239 environmentId={props.environmentId}
1240 resource={resource}
1241 alt={null}
1242 onPressPreview={props.onPressPreview}
1243 />
1244 </View>
1245 ))}
1246 </FilePreviewGallery>
1247 <ScrollView
1248 nestedScrollEnabled
1249 directionalLockEnabled
1250 showsVerticalScrollIndicator
1251 className="max-h-60"
1252 contentContainerStyle={{ paddingRight: 8 }}
1253 >
1254 {reasoning ? (
1255 props.renderMarkdown(reasoning.text)
1256 ) : call ? (
1257 [
1258 call.command,
1259 ...(call.args ?? []).map(([key, value]) =>
1260 key === "code" &&
1261 shownItem.type === "dynamic_tool" &&
1262 isJavaScriptReplTool(shownItem.toolName) ? (
1263 <View key={key}>
1264 <Text className="font-mono text-2xs leading-normal text-foreground-muted">
1265 {key}
1266 </Text>
1267 <ReplCodeText code={value} theme={props.themeAppearance} />
1268 </View>
1269 ) : (
1270 `${key} ${value}`
1271 ),
1272 ),
1273 call.argsText,
1274 ]
1275 .filter((line) => Boolean(line))
1276 .map((line, index) =>
1277 typeof line === "string" ? (
1278 <Text
1279 key={`${index}:${line}`}
1280 selectable
1281 className="font-mono text-2xs leading-normal text-foreground"
1282 >
1283 {line}
1284 </Text>
1285 ) : (
1286 line
1287 ),
1288 )
1289 ) : fullDetail ? (
1290 <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted">
1291 {fullDetail}
1292 </Text>
1293 ) : null}
1294 {fetchedOutput ? (
1295 <Text
1296 selectable
1297 className={cn(
1298 "font-mono text-2xs leading-normal text-foreground-muted",
1299 (!call || call.command || call.args || call.argsText) && "mt-1.5",
1300 )}
1301 >
1302 {fetchedOutput}
1303 </Text>
1304 ) : null}
1305 {failedExitCode !== null ? (
1306 <Text className="mt-1.5 font-mono text-2xs leading-normal text-danger-foreground">
1307 exit {failedExitCode}
1308 </Text>
1309 ) : null}
1310 </ScrollView>
1311 </Animated.View>
1312 ) : null}
1313 </Animated.View>
1314 );
1315});
1316
1317export function ThreadWorkGroupToggle(props: {
1318 readonly environmentId: EnvironmentId;
1319 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1320 readonly expanded: boolean;
1321 readonly hiddenCount: number;
1322 readonly iconSubtleColor: import("react-native").ColorValue;
1323 readonly summary: string;
1324 readonly summaryKind: ToolGroupSummaryKind;
1325 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
1326 readonly themeAppearance: "light" | "dark";
1327 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
1328 readonly toolIcon?: ToolActivityIcon;
1329 readonly hasFailure: boolean;
1330 readonly shimmer: boolean;
1331 readonly thought?: string | undefined;
1332 readonly onToggle: () => void;
1333}) {
1334 const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary;
1335 const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel;
1336 const icon =
1337 props.summaryToolIcon ??
1338 (props.toolSurface
1339 ? workRowSymbolName(props.toolSurface)
1340 : toolGroupSummarySymbolName(props.summaryKind));
1341
1342 return (
1343 <WorkLogBlock layout="group-header">
1344 {props.thought ? (
1345 // The latest thought sits above the status line as an ordinary work-log
1346 // row, wrapped up to four lines.
1347 <Pressable
1348 accessible={false}
1349 onPress={props.onToggle}
1350 className="flex-row items-start gap-1.5 rounded-md px-0.5 active:bg-subtle"
1351 >
1352 <WorkLogIconSlot>
1353 <WorkLogIcon icon="brain" color={props.iconSubtleColor} />
1354 </WorkLogIconSlot>
1355 <Text
1356 key={props.rowSizing.textSizeKey}
1357 selectable={false}
1358 numberOfLines={4}
1359 ellipsizeMode="tail"
1360 className="min-w-0 flex-1 py-0.5 text-sm text-foreground-muted"
1361 >
1362 {props.thought}
1363 </Text>
1364 </Pressable>
1365 ) : null}
1366 <WorkLogPressable
1367 accessibilityRole="button"
1368 accessibilityState={{ expanded: props.expanded }}
1369 accessibilityLabel={accessibilityLabel}
1370 accessibilityHint={`Double tap to ${props.expanded ? "hide" : "show"} ${props.hiddenCount} tool ${props.hiddenCount === 1 ? "call" : "calls"}.`}
1371 onPress={() => {
1372 void Haptics.selectionAsync();
1373 props.onToggle();
1374 }}
1375 rowSizing={props.rowSizing}
1376 >
1377 {props.shimmer ? (
1378 <ShimmeringWorkContent
1379 key={props.rowSizing.textSizeKey}
1380 environmentId={props.environmentId}
1381 icon={icon}
1382 iconSubtleColor={props.iconSubtleColor}
1383 label={props.summary}
1384 showIcon
1385 themeAppearance={props.themeAppearance}
1386 toolIcon={props.toolIcon}
1387 />
1388 ) : (
1389 <>
1390 <WorkLogIconSlot>
1391 <ToolActivityIconView
1392 environmentId={props.environmentId}
1393 icon={props.toolIcon}
1394 fallback={icon}
1395 fallbackColor={props.iconSubtleColor}
1396 themeAppearance={props.themeAppearance}
1397 />
1398 </WorkLogIconSlot>
1399 <WorkLogLabel key={props.rowSizing.textSizeKey}>{props.summary}</WorkLogLabel>
1400 </>
1401 )}
1402 <ThreadDisclosureChevron
1403 expanded={props.expanded}
1404 collapsedDirection="down"
1405 size={11}
1406 tintColor={props.iconSubtleColor}
1407 />
1408 </WorkLogPressable>
1409 </WorkLogBlock>
1410 );
1411}
1412
1413/**
1414 * A batch of spawned subagents. The status line updates in place as members
1415 * report progress; expanding lists each member. Text nodes carry keys tied to
1416 * the row identity only, so a progress tick re-renders the labels without
1417 * remounting the card (see the batch key in appendActivityGroupRows).
1418 */
1419export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: {
1420 readonly summary: AgentSpawnSummary;
1421 readonly expanded: boolean;
1422 readonly iconSubtleColor: ColorValue;
1423 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1424 readonly onToggle: () => void;
1425 readonly onCopy: () => void;
1426}) {
1427 const { summary, expanded } = props;
1428 const working = summary.tone === "working";
1429 const memberCount = summary.members.length;
1430 const canExpand = memberCount > 0;
1431 return (
1432 <Animated.View layout={WORK_LOG_LAYOUT_TRANSITION} className="-mx-1 mb-1 px-1">
1433 <Pressable
1434 accessibilityRole={canExpand ? "button" : undefined}
1435 accessibilityState={canExpand ? { expanded } : undefined}
1436 accessibilityLabel={`${summary.title}, ${summary.status}`}
1437 accessibilityHint={
1438 canExpand
1439 ? `Double tap to ${expanded ? "hide" : "show"} ${memberCount} ${memberCount === 1 ? "subagent" : "subagents"}. Long press to copy.`
1440 : "Long press to copy."
1441 }
1442 hitSlop={4}
1443 onPress={() => {
1444 if (!canExpand) return;
1445 void Haptics.selectionAsync();
1446 props.onToggle();
1447 }}
1448 onLongPress={props.onCopy}
1449 className="rounded-xl border border-border-subtle bg-card px-2.5 py-2 active:bg-subtle"
1450 >
1451 <View className="flex-row items-center gap-2">
1452 <View className="h-6 w-6 shrink-0 items-center justify-center">
1453 <SymbolView
1454 name={{ ios: "sparkles", android: "auto_awesome" }}
1455 size={14}
1456 weight="medium"
1457 tintColor={props.iconSubtleColor}
1458 type="monochrome"
1459 />
1460 </View>
1461 <View className="min-w-0 flex-1 gap-0.5">
1462 <Text
1463 key={props.rowSizing.textSizeKey}
1464 className="font-t3-medium text-sm text-foreground"
1465 numberOfLines={1}
1466 >
1467 {summary.title}
1468 </Text>
1469 <View className="flex-row items-center gap-1.5">
1470 <SubagentStatusDot tone={summary.tone} />
1471 {working ? (
1472 <ShimmeringWorkContent
1473 key={props.rowSizing.textSizeKey}
1474 compact
1475 icon="brain"
1476 iconSubtleColor={props.iconSubtleColor}
1477 label={summary.status}
1478 showIcon={false}
1479 />
1480 ) : (
1481 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
1482 {summary.status}
1483 </Text>
1484 )}
1485 </View>
1486 </View>
1487 {canExpand ? (
1488 <ThreadDisclosureChevron
1489 expanded={expanded}
1490 collapsedDirection="down"
1491 size={11}
1492 tintColor={props.iconSubtleColor}
1493 />
1494 ) : null}
1495 </View>
1496 {expanded && canExpand ? (
1497 <Animated.View
1498 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
1499 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
1500 layout={WORK_LOG_LAYOUT_TRANSITION}
1501 className="ml-8 mt-1.5 gap-1.5 border-l border-border pl-3"
1502 >
1503 {summary.members.map((member) => (
1504 <View key={member.title} className="gap-px">
1505 <View className="flex-row items-center gap-1.5">
1506 <SubagentStatusDot tone={member.tone} />
1507 <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>
1508 {member.title}
1509 </Text>
1510 <Text className="shrink-0 text-2xs text-foreground-muted">{member.status}</Text>
1511 </View>
1512 {member.detail ? (
1513 <Text
1514 selectable
1515 className="pl-3 font-mono text-2xs leading-normal text-foreground-muted"
1516 numberOfLines={expanded ? 6 : 1}
1517 >
1518 {member.detail}
1519 </Text>
1520 ) : null}
1521 </View>
1522 ))}
1523 </Animated.View>
1524 ) : null}
1525 </Pressable>
1526 </Animated.View>
1527 );
1528});
1529
1530export function ThreadThinkingRow(props: {
1531 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1532 readonly iconSubtleColor: ColorValue;
1533}) {
1534 return (
1535 <View
1536 accessible
1537 accessibilityLabel="Thinking"
1538 className="-mx-1 min-h-8 flex-row items-center px-1.5 py-0"
1539 style={{ minHeight: props.rowSizing.estimatedRowHeight }}
1540 >
1541 <ShimmeringWorkContent
1542 key={props.rowSizing.textSizeKey}
1543 icon="brain"
1544 iconSubtleColor={props.iconSubtleColor}
1545 label="Thinking"
1546 showIcon
1547 />
1548 </View>
1549 );
1550}
1551
1552function ToolActivityIconView(props: {
1553 readonly environmentId: EnvironmentId;
1554 readonly icon?: ToolActivityIcon;
1555 readonly fallback: WorkContentIcon;
1556 readonly fallbackColor: ColorValue;
1557 readonly themeAppearance: "light" | "dark";
1558}) {
1559 if (!props.icon) {
1560 return <WorkLogIcon icon={props.fallback} color={props.fallbackColor} />;
1561 }
1562 if (props.icon._tag === "website") {
1563 const source = toolActivityFaviconUrl(props.icon, props.themeAppearance, 32);
1564 return source ? (
1565 <ToolActivityImage
1566 key={source}
1567 source={source}
1568 fallback={props.fallback}
1569 color={props.fallbackColor}
1570 themeAppearance={props.themeAppearance}
1571 />
1572 ) : (
1573 <WorkLogIcon icon={props.fallback} color={props.fallbackColor} />
1574 );
1575 }
1576 if (props.icon._tag === "themed-logo") {
1577 const source =
1578 props.themeAppearance === "dark"
1579 ? (props.icon.logoUrlDark ?? props.icon.logoUrl)
1580 : props.icon.logoUrl;
1581 return (
1582 <ToolActivityImage
1583 key={source}
1584 source={source}
1585 fallback={props.fallback}
1586 color={props.fallbackColor}
1587 themeAppearance={props.themeAppearance}
1588 />
1589 );
1590 }
1591 return (
1592 <NativeAppToolActivityIcon
1593 environmentId={props.environmentId}
1594 app={props.icon.app}
1595 fallback={props.fallback}
1596 color={props.fallbackColor}
1597 themeAppearance={props.themeAppearance}
1598 />
1599 );
1600}
1601
1602function NativeAppToolActivityIcon(props: {
1603 readonly environmentId: EnvironmentId;
1604 readonly app: Extract<ToolActivityIcon, { readonly _tag: "native-app" }>["app"];
1605 readonly fallback: WorkContentIcon;
1606 readonly color: ColorValue;
1607 readonly themeAppearance: "light" | "dark";
1608}) {
1609 const source = useAssetUrl(props.environmentId, {
1610 _tag: "native-app-icon",
1611 app: props.app,
1612 });
1613 return source ? (
1614 <ToolActivityImage
1615 key={source}
1616 source={source}
1617 fallback={props.fallback}
1618 color={props.color}
1619 themeAppearance={props.themeAppearance}
1620 />
1621 ) : (
1622 <WorkLogIcon icon={props.fallback} color={props.color} />
1623 );
1624}
1625
1626function ToolActivityImage(props: {
1627 readonly source: string;
1628 readonly fallback: WorkContentIcon;
1629 readonly color: ColorValue;
1630 readonly themeAppearance: "light" | "dark";
1631}) {
1632 const [loaded, setLoaded] = useState(false);
1633 const [failed, setFailed] = useState(false);
1634 return (
1635 <View className="h-4 w-4 items-center justify-center overflow-hidden rounded-[3px] bg-screen">
1636 {!loaded || failed ? <WorkLogIcon icon={props.fallback} color={props.color} /> : null}
1637 {!failed ? (
1638 <View
1639 style={[
1640 StyleSheet.absoluteFill,
1641 props.themeAppearance === "light" ? { filter: [{ brightness: 0.6 }] } : undefined,
1642 ]}
1643 >
1644 <Image
1645 source={props.source}
1646 cachePolicy="memory-disk"
1647 contentFit="contain"
1648 style={[StyleSheet.absoluteFill, { opacity: loaded ? 0.7 : 0 }]}
1649 onLoad={() => setLoaded(true)}
1650 onError={() => setFailed(true)}
1651 />
1652 </View>
1653 ) : null}
1654 </View>
1655 );
1656}
1657
1658function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
1659 switch (kind) {
1660 case "pull-request":
1661 case "link-pr":
1662 case "unlink-pr":
1663 case "list-prs":
1664 case "watch-pr":
1665 case "unwatch-pr":
1666 return "arrow.triangle.pull";
1667 case "read":
1668 return { ios: "eye", android: "visibility" };
1669 case "edit":
1670 return { ios: "square.and.pencil", android: "edit" };
1671 case "thread-create":
1672 return { ios: "bubble.left", android: "chat" };
1673 case "command":
1674 return { ios: "terminal", android: "terminal" };
1675 case "device":
1676 return { ios: "iphone", android: "smartphone" };
1677 case "browser":
1678 case "search":
1679 return { ios: "globe", android: "public" };
1680 case "code-search":
1681 return "magnifyingglass";
1682 case "other":
1683 return { ios: "wrench", android: "build" };
1684 case "reasoning":
1685 return { ios: "brain", android: "psychology" };
1686 case "agent-tool":
1687 return { ios: "sparkles", android: "auto_awesome" };
1688 case "tone-tool":
1689 return { ios: "bolt", android: "bolt" };
1690 case "dynamic-tool":
1691 case "update":
1692 case "mixed":
1693 return { ios: "hammer", android: "construction" };
1694 }
1695}