import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; import { getQuestionAnswerPreview, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; import type { LegendListRef } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { useIsFocused } from "@react-navigation/native"; import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, } from "react"; import { AccessibilityInfo, AppState, type ColorValue, Pressable, ScrollView, StyleSheet, View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import { type AgentSpawnSummary, type ThreadFeedActivity, workEntryRowLabel, } from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; import { resolveWorkEntryToolPresentation, type ToolGroupSummaryKind, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; import Animated, { cancelAnimation, Easing, FadeIn, FadeOut, LinearTransition, ReduceMotion, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, } from "react-native-reanimated"; import { useAssetUrl } from "../../state/assets"; const SHIMMER_WIDTH = 72; const SHIMMER_SWEEP_MS = 1_350; const SHIMMER_PAUSE_MS = 1_450; const SHIMMER_ICON_AND_GAP_WIDTH = 30; export const THREAD_DISCLOSURE_TRANSITION_MS = 180; const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request"; function WorkLogIcon(props: { readonly icon: WorkContentIcon; readonly color: ColorValue; readonly colorClassName?: string; readonly highlighted?: boolean; }) { const colorClassName = props.highlighted ? "accent-foreground" : props.colorClassName; if (props.icon === "t3-code") { return ( ); } return ( ); } export function ThreadDisclosureChevron(props: { readonly expanded: boolean; readonly collapsedDirection: "right" | "down"; readonly size: number; readonly tintColor: ColorValue; }) { const expandedAngle = props.collapsedDirection === "right" ? 90 : 180; const rotation = useSharedValue(props.expanded ? expandedAngle : 0); useLayoutEffect(() => { rotation.value = withTiming(props.expanded ? expandedAngle : 0, { duration: THREAD_DISCLOSURE_TRANSITION_MS, reduceMotion: ReduceMotion.System, }); }, [expandedAngle, props.expanded, rotation]); const rotationStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); return ( ); } function ShimmerWorkContent(props: { readonly textClassName?: string; readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; readonly iconSubtleColor: ColorValue; readonly label: string; readonly onTextLayout?: ComponentProps["onTextLayout"]; readonly showIcon: boolean; readonly themeAppearance?: "light" | "dark"; readonly toolIcon?: ToolActivityIcon; }) { return ( {props.showIcon ? ( {props.toolIcon && props.environmentId ? ( ) : ( )} ) : null} {props.label} ); } export function ShimmeringWorkContent(props: { readonly className?: string; readonly textClassName?: string; /** Secondary line: no icon slot, caption size. */ readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly icon: WorkContentIcon; readonly iconSubtleColor: ColorValue; readonly label: string; readonly showIcon: boolean; readonly themeAppearance?: "light" | "dark"; readonly toolIcon?: ToolActivityIcon; }) { const [availableWidth, setAvailableWidth] = useState(0); const [textWidth, setTextWidth] = useState(0); const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active"); const [reducedMotion, setReducedMotion] = useState(true); const screenIsFocused = useIsFocused(); const progress = useSharedValue(0); const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`; const contentWidth = Math.min( availableWidth, (props.showIcon ? SHIMMER_ICON_AND_GAP_WIDTH : 0) + Math.ceil(textWidth), ); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => { setAppIsActive(state === "active"); }); return () => subscription.remove(); }, []); useEffect(() => { void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion); const subscription = AccessibilityInfo.addEventListener( "reduceMotionChanged", setReducedMotion, ); return () => subscription.remove(); }, []); useEffect(() => { cancelAnimation(progress); progress.value = 0; if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return; progress.value = withRepeat( withSequence( withTiming(1, { duration: SHIMMER_SWEEP_MS, easing: Easing.linear, reduceMotion: ReduceMotion.Never, }), withDelay( SHIMMER_PAUSE_MS, withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }), ), ), -1, false, undefined, ReduceMotion.Never, ); return () => cancelAnimation(progress); }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]); const sweepStyle = useAnimatedStyle(() => ({ transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }], })); const counterSweepStyle = useAnimatedStyle(() => ({ transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }], })); return ( setAvailableWidth(event.nativeEvent.layout.width)} > setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} /> {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( } > ) : null} ); } function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": return { ios: "sparkles", android: "auto_awesome" }; case "alert": return { ios: "exclamationmark.triangle", android: "error" }; case "browser": return { ios: "safari", android: "public" }; case "check": return { ios: "checkmark", android: "check" }; case "command": return { ios: "terminal", android: "terminal" }; case "computer": return { ios: "desktopcomputer", android: "desktop_windows" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "eye": return { ios: "eye", android: "visibility" }; case "globe": return { ios: "globe", android: "public" }; case "hammer": return { ios: "hammer", android: "construction" }; case "message": return { ios: "bubble.left", android: "chat_bubble" }; case "warning": return { ios: "xmark", android: "close" }; case "wrench": return { ios: "wrench", android: "build" }; case "zap": return { ios: "bolt", android: "bolt" }; } } // Entering fades only for rows created moments ago: rows remount whenever the // list scrolls them back into view, and old rows must not replay an entrance. const FRESH_ROW_WINDOW_MS = 3_000; function isFreshRow(createdAt: string): boolean { const timestamp = Date.parse(createdAt); return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; } // The minimum matches min-h-8 below. Exact sizing is disabled when native // accessibility scaling can make the single-line text taller than that minimum. const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; const WORK_ROW_GAP = 1; // gap-px const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem const WORK_GROUP_MAX_HEIGHT = 256; const WORK_GROUP_EDGE_FADE_HEIGHT = 12; export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; function workLogRowsHeight( activities: ReadonlyArray, rowHeight = WORK_ROW_HEIGHT, ): number { return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP; } export function collapsedWorkLogHeight(activities: ReadonlyArray): number { if (activities.length === 0) { return 0; } const height = workLogRowsHeight(activities); return ( WORK_LOG_BOTTOM_MARGIN + (activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height) ); } interface ThreadWorkLogProps { readonly activities: ReadonlyArray; readonly anchorKey: string; readonly environmentId: EnvironmentId; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; /** Feed background, painted as the scroll-edge fade over a long group. */ readonly edgeFadeColor: string; readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; readonly renderImage: MarkdownImageRenderer; } export function ThreadWorkLog(props: ThreadWorkLogProps) { const renderRow = useCallback( (row: ThreadFeedActivity) => ( ), [ props.anchorKey, props.copiedRowId, props.expandedRows, props.environmentId, props.iconSubtleColor, props.onCopyRow, props.onToggleRow, props.renderImage, props.themeAppearance, ], ); if (props.activities.length === 0) { return null; } return ( {props.activities[0]?.groupedToolDetail ? ( ) : ( {props.activities.map(renderRow)} )} ); } function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray; readonly edgeFadeColor: string; readonly expandedRows: Readonly>; readonly groupId: string; readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly renderRow: (row: ThreadFeedActivity) => ReactNode; }) { const estimatedRowsHeight = workLogRowsHeight( props.activities, props.rowSizing.estimatedRowHeight, ); const [initialPosition] = useState(() => { const position = props.scrollPositions.get(props.groupId); return props.activities.some((row) => row.id === position?.rowId) ? position : undefined; }); const [initialScrollIndex] = useState(() => resolveThreadWorkGroupInitialScroll(props.activities, initialPosition), ); const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); const listRef = useRef(null); const loadedRef = useRef(false); const userScrollingRef = useRef(false); const pendingAppendHeightRef = useRef(null); const previousContent = useRef({ rows: props.activities, height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), expandedRows: props.expandedRows, }); const [measuredContent, setMeasuredContent] = useState(() => ({ height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), rowCount: props.activities.length, })); const contentHeight = Math.max( 1, measuredContent.height + Math.max(0, props.activities.length - measuredContent.rowCount) * (props.rowSizing.estimatedRowHeight + WORK_ROW_GAP), ); const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); // Each edge fades only while content continues past it. Scroll offset stays // on the UI thread; only content-size changes update React state. const topFadeStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), })); const bottomFadeStyle = useAnimatedStyle(() => ({ opacity: Math.min( 1, Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, ), })); const rememberPosition = useCallback(() => { if (!loadedRef.current) return; const state = listRef.current?.getState(); const position = state && resolveWorkGroupScrollAnchor(state); if (!state || !position) return; props.scrollPositions.set(props.groupId, { ...position, contentHeight: state.contentLength, }); }, [props.groupId, props.scrollPositions]); const finishPendingAppend = useCallback(() => { const targetHeight = pendingAppendHeightRef.current; const state = listRef.current?.getState(); if ( targetHeight !== null && state && !userScrollingRef.current && Math.abs(state.scrollLength - targetHeight) <= 1 ) { pendingAppendHeightRef.current = null; void listRef.current?.scrollToEnd({ animated: false }); } }, []); const onContentSizeChange = useCallback( (nextHeight: number) => { const previous = previousContent.current; const detailsChanged = previous.expandedRows !== props.expandedRows; const followAppend = loadedRef.current && shouldFollowThreadWorkGroupAppend({ previousRows: previous.rows, rows: props.activities, previousContentHeight: previous.height, contentHeight: nextHeight, viewportHeight: Math.min(previous.height, WORK_GROUP_MAX_HEIGHT), scrollOffset: scrollOffset.value, detailsChanged, userScrolling: userScrollingRef.current, }); previousContent.current = { rows: props.activities, height: nextHeight, expandedRows: props.expandedRows, }; setMeasuredContent((current) => current.height === nextHeight && current.rowCount === props.activities.length ? current : { height: nextHeight, rowCount: props.activities.length }, ); // Follow new calls only, never a detail toggle or a growing tool result. if (followAppend) { pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); } else if (detailsChanged || userScrollingRef.current || previous.rows !== props.activities) { pendingAppendHeightRef.current = null; } else if (pendingAppendHeightRef.current !== null) { pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); } // A short group can grow its viewport on this append. Wait for that // layout before calculating the end offset, rather than jumping twice. finishPendingAppend(); rememberPosition(); }, [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], ); // The native ScrollView reports its content size a frame or more after // LegendList has laid the rows out, so a detail toggle rendered the group // at its old height while the rows below already moved. Read the size // LegendList computes on the JS thread instead; it settles in the same // commit as the row measurement that changed it. const onContentSizeChangeRef = useRef(onContentSizeChange); useLayoutEffect(() => { onContentSizeChangeRef.current = onContentSizeChange; }, [onContentSizeChange]); const subscribeToContentSize = useCallback((list: LegendListRef | null) => { listRef.current = list; if (!list) return; const unsubscribe = list.getState().listen("totalSize", () => { onContentSizeChangeRef.current(list.getState().contentLength); }); onContentSizeChangeRef.current(list.getState().contentLength); return unsubscribe; }, []); const getFixedItemSize = useCallback( (row: ThreadFeedActivity, index: number) => props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined ? undefined : props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0), [props.activities.length, props.expandedRows, props.rowSizing.fixedRowHeight], ); const renderItem = useCallback( ({ item, index }: { item: ThreadFeedActivity; index: number }) => ( {props.renderRow(item)} ), [props.activities.length, props.renderRow], ); return ( { loadedRef.current = true; setRestoringPosition(false); rememberPosition(); }} onScroll={rememberPosition} onScrollBeginDrag={() => { userScrollingRef.current = true; pendingAppendHeightRef.current = null; }} onScrollEndDrag={() => { userScrollingRef.current = false; }} onMomentumScrollBegin={() => { userScrollingRef.current = true; pendingAppendHeightRef.current = null; }} onMomentumScrollEnd={() => { userScrollingRef.current = false; rememberPosition(); }} maintainVisibleContentPosition nestedScrollEnabled directionalLockEnabled showsVerticalScrollIndicator scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" style={{ height }} /> ); } /** A screen-colored gradient painted over the list edge that still has content past it. */ function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; return ( ); } function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, | "activities" | "copiedRowId" | "edgeFadeColor" | "expandedRows" | "rowSizing" | "scrollPositions" > & { readonly row: ThreadFeedActivity; readonly copied: boolean; readonly expanded: boolean; }, ) { const { row, expanded } = props; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); const answerPreview = row.workEntry.questionAnswer ? getQuestionAnswerPreview(row.workEntry.questionAnswer) : null; const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); const displayText = workEntryRowLabel(row.workEntry, expanded); const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon; const icon = toolPresentation?.icon ?? workRowSymbolName(row.icon); return ( { if (canExpand) { void Haptics.selectionAsync(); props.onToggleRow(row.id, props.anchorKey); } }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} className="rounded-md px-0.5 py-0 active:bg-subtle" > {row.live && !expanded ? ( ) : ( <> {toolIcon ? ( ) : ( )} {displayText} {answerPreview ? ( {` ${answerPreview}`} ) : null} )} {props.copied ? ( Copied ) : null} {failed && toolIcon !== undefined ? ( ) : null} {canExpand ? ( ) : null} {expanded && (fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? ( {row.workEntry.questionAnswer ? ( ) : null} {viewedImagePath ? ( {props.renderImage({ href: viewedImagePath, alt: null, title: null })} ) : null} {fullDetail} ) : null} ); }); export function ThreadWorkGroupToggle(props: { readonly environmentId: EnvironmentId; readonly rowSizing: ReturnType; readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly themeAppearance: "light" | "dark"; readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; readonly onToggle: () => void; }) { const accessibilityLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; const icon = props.summaryToolIcon ?? (props.toolSurface ? workRowSymbolName(props.toolSurface) : toolGroupSummarySymbolName(props.summaryKind)); return ( { void Haptics.selectionAsync(); props.onToggle(); }} className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" style={{ minHeight: props.rowSizing.estimatedRowHeight }} > {props.shimmer ? ( ) : ( <> {props.summary} )} ); } const AGENT_SPAWN_TONE_DOT_CLASS = { working: "bg-adaptive-sky-600-400", completed: "bg-adaptive-emerald-600-400", failed: "bg-adaptive-rose-600-400", stopped: "bg-foreground-muted", } as const satisfies Record; /** * A batch of spawned subagents. The status line updates in place as members * report progress; expanding lists each member. Text nodes carry keys tied to * the row identity only, so a progress tick re-renders the labels without * remounting the card (see the batch key in appendActivityGroupRows). */ export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { readonly summary: AgentSpawnSummary; readonly expanded: boolean; readonly iconSubtleColor: ColorValue; readonly rowSizing: ReturnType; readonly onToggle: () => void; readonly onCopy: () => void; }) { const { summary, expanded } = props; const working = summary.tone === "working"; const memberCount = summary.members.length; const canExpand = memberCount > 0; return ( { if (!canExpand) return; void Haptics.selectionAsync(); props.onToggle(); }} onLongPress={props.onCopy} className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" > {summary.title} {working ? ( ) : ( {summary.status} )} {canExpand ? ( ) : null} {expanded && canExpand ? ( {summary.members.map((member) => ( {member.title} {member.status} {member.detail ? ( {member.detail} ) : null} ))} ) : null} ); }); export function ThreadThinkingRow(props: { readonly rowSizing: ReturnType; readonly iconSubtleColor: ColorValue; }) { return ( ); } function ToolActivityIconView(props: { readonly environmentId: EnvironmentId; readonly icon?: ToolActivityIcon; readonly fallback: WorkContentIcon; readonly fallbackColor: ColorValue; readonly themeAppearance: "light" | "dark"; }) { if (!props.icon) { return ; } if (props.icon._tag === "website") { const source = toolActivityFaviconUrl(props.icon, props.themeAppearance, 32); return source ? ( ) : ( ); } if (props.icon._tag === "themed-logo") { const source = props.themeAppearance === "dark" ? (props.icon.logoUrlDark ?? props.icon.logoUrl) : props.icon.logoUrl; return ( ); } return ( ); } function NativeAppToolActivityIcon(props: { readonly environmentId: EnvironmentId; readonly app: Extract["app"]; readonly fallback: WorkContentIcon; readonly color: ColorValue; readonly themeAppearance: "light" | "dark"; }) { const source = useAssetUrl(props.environmentId, { _tag: "native-app-icon", app: props.app, }); return source ? ( ) : ( ); } function ToolActivityImage(props: { readonly source: string; readonly fallback: WorkContentIcon; readonly color: ColorValue; readonly themeAppearance: "light" | "dark"; }) { const [loaded, setLoaded] = useState(false); const [failed, setFailed] = useState(false); return ( {!loaded || failed ? : null} {!failed ? ( setLoaded(true)} onError={() => setFailed(true)} /> ) : null} ); } function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { switch (kind) { case "pull-request": case "link-pr": case "unlink-pr": case "list-prs": return "arrow.triangle.pull"; case "read": return { ios: "eye", android: "visibility" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "command": return { ios: "terminal", android: "terminal" }; case "device": return { ios: "iphone", android: "smartphone" }; case "browser": case "search": return { ios: "globe", android: "public" }; case "code-search": return "magnifyingglass"; case "other": return { ios: "wrench", android: "build" }; case "agent-tool": return { ios: "sparkles", android: "auto_awesome" }; case "tone-tool": return { ios: "bolt", android: "bolt" }; case "dynamic-tool": case "update": case "mixed": return { ios: "hammer", android: "construction" }; } }