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 ( <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} /> ); } return ( <SymbolView name={ props.icon === "pull-request" ? "arrow.triangle.pull" : props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon === "device" ? { ios: "iphone", android: "smartphone" } : props.icon } size={14} weight="medium" {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })} type="monochrome" /> );} 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 ( <Animated.View accessible={false} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" pointerEvents="none" style={[{ width: props.size, height: props.size }, rotationStyle]} > <SymbolView name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"} size={props.size} tintColor={props.tintColor} type="monochrome" /> </Animated.View> );} 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<typeof Text>["onTextLayout"]; readonly showIcon: boolean; readonly themeAppearance?: "light" | "dark"; readonly toolIcon?: ToolActivityIcon;}) { return ( <View className="flex-row items-center gap-1.5"> {props.showIcon ? ( <View className="h-6 w-6 shrink-0 items-center justify-center"> {props.toolIcon && props.environmentId ? ( <ToolActivityIconView environmentId={props.environmentId} icon={props.toolIcon} fallback={props.icon} fallbackColor={props.iconSubtleColor} themeAppearance={props.themeAppearance ?? "light"} /> ) : ( <WorkLogIcon icon={props.icon} color={props.iconSubtleColor} highlighted={props.highlighted} /> )} </View> ) : null} <Text className={cn( "min-w-0 shrink", props.compact ? "text-xs" : "text-sm", props.highlighted ? "text-foreground" : "text-foreground-muted", props.textClassName, )} numberOfLines={1} onTextLayout={props.onTextLayout} > {props.label} </Text> </View> );} 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 ( <View className={cn("min-w-0 flex-1 overflow-hidden", props.className)} onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > <ShimmerWorkContent textClassName={props.textClassName} compact={props.compact} environmentId={props.environmentId} highlighted={false} icon={props.icon} iconSubtleColor={props.iconSubtleColor} label={props.label} showIcon={props.showIcon} themeAppearance={props.themeAppearance} toolIcon={props.toolIcon} onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} /> {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( <Animated.View className="absolute inset-y-0 left-0 overflow-hidden" pointerEvents="none" accessible={false} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" style={[{ width: SHIMMER_WIDTH }, sweepStyle]} > <MaskedView style={StyleSheet.absoluteFill} maskElement={ <Svg width="100%" height="100%"> <Defs> <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%"> <Stop offset="0" stopColor="white" stopOpacity={0} /> <Stop offset="0.15" stopColor="white" stopOpacity={0.12} /> <Stop offset="0.35" stopColor="white" stopOpacity={0.55} /> <Stop offset="0.5" stopColor="white" stopOpacity={1} /> <Stop offset="0.65" stopColor="white" stopOpacity={0.55} /> <Stop offset="0.85" stopColor="white" stopOpacity={0.12} /> <Stop offset="1" stopColor="white" stopOpacity={0} /> </LinearGradient> </Defs> <Rect width="100%" height="100%" fill={`url(#${gradientId})`} /> </Svg> } > <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}> <ShimmerWorkContent textClassName={props.textClassName} compact={props.compact} environmentId={props.environmentId} highlighted icon={props.icon} iconSubtleColor={props.iconSubtleColor} label={props.label} showIcon={props.showIcon} themeAppearance={props.themeAppearance} toolIcon={props.toolIcon} /> </Animated.View> </MaskedView> </Animated.View> ) : null} </View> );} 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-pxconst WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px remconst 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<ThreadFeedActivity>, rowHeight = WORK_ROW_HEIGHT,): number { return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP;} export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): 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<ThreadFeedActivity>; readonly anchorKey: string; readonly environmentId: EnvironmentId; readonly copiedRowId: string | null; readonly expandedRows: Readonly<Record<string, boolean>>; readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; 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) => ( <ThreadWorkLogRow key={row.id} row={row} anchorKey={props.anchorKey} copied={props.copiedRowId === row.id} expanded={props.expandedRows[row.id] ?? false} environmentId={props.environmentId} iconSubtleColor={props.iconSubtleColor} onCopyRow={props.onCopyRow} onToggleRow={props.onToggleRow} renderImage={props.renderImage} themeAppearance={props.themeAppearance} /> ), [ 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 ( <View className="-mx-1 mb-1 px-1 py-0"> {props.activities[0]?.groupedToolDetail ? ( <ThreadWorkGroupList activities={props.activities} edgeFadeColor={props.edgeFadeColor} expandedRows={props.expandedRows} groupId={props.anchorKey} rowSizing={props.rowSizing} scrollPositions={props.scrollPositions} renderRow={renderRow} /> ) : ( <View className="gap-px">{props.activities.map(renderRow)}</View> )} </View> );} function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray<ThreadFeedActivity>; readonly edgeFadeColor: string; readonly expandedRows: Readonly<Record<string, boolean>>; readonly groupId: string; readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; 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(() =>Lines 1–500 of 1290. Load more
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 ( <T3Wordmark height={10} {...(colorClassName ? { colorClassName } : { color: props.color })} /> ); } return ( <SymbolView name={ props.icon === "pull-request" ? "arrow.triangle.pull" : props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon === "device" ? { ios: "iphone", android: "smartphone" } : props.icon } size={14} weight="medium" {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })} type="monochrome" /> ); } 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 ( <Animated.View accessible={false} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" pointerEvents="none" style={[{ width: props.size, height: props.size }, rotationStyle]} > <SymbolView name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"} size={props.size} tintColor={props.tintColor} type="monochrome" /> </Animated.View> ); } 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<typeof Text>["onTextLayout"]; readonly showIcon: boolean; readonly themeAppearance?: "light" | "dark"; readonly toolIcon?: ToolActivityIcon; }) { return ( <View className="flex-row items-center gap-1.5"> {props.showIcon ? ( <View className="h-6 w-6 shrink-0 items-center justify-center"> {props.toolIcon && props.environmentId ? ( <ToolActivityIconView environmentId={props.environmentId} icon={props.toolIcon} fallback={props.icon} fallbackColor={props.iconSubtleColor} themeAppearance={props.themeAppearance ?? "light"} /> ) : ( <WorkLogIcon icon={props.icon} color={props.iconSubtleColor} highlighted={props.highlighted} /> )} </View> ) : null} <Text className={cn( "min-w-0 shrink", props.compact ? "text-xs" : "text-sm", props.highlighted ? "text-foreground" : "text-foreground-muted", props.textClassName, )} numberOfLines={1} onTextLayout={props.onTextLayout} > {props.label} </Text> </View> ); } 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 ( <View className={cn("min-w-0 flex-1 overflow-hidden", props.className)} onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > <ShimmerWorkContent textClassName={props.textClassName} compact={props.compact} environmentId={props.environmentId} highlighted={false} icon={props.icon} iconSubtleColor={props.iconSubtleColor} label={props.label} showIcon={props.showIcon} themeAppearance={props.themeAppearance} toolIcon={props.toolIcon} onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} /> {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( <Animated.View className="absolute inset-y-0 left-0 overflow-hidden" pointerEvents="none" accessible={false} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" style={[{ width: SHIMMER_WIDTH }, sweepStyle]} > <MaskedView style={StyleSheet.absoluteFill} maskElement={ <Svg width="100%" height="100%"> <Defs> <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%"> <Stop offset="0" stopColor="white" stopOpacity={0} /> <Stop offset="0.15" stopColor="white" stopOpacity={0.12} /> <Stop offset="0.35" stopColor="white" stopOpacity={0.55} /> <Stop offset="0.5" stopColor="white" stopOpacity={1} /> <Stop offset="0.65" stopColor="white" stopOpacity={0.55} /> <Stop offset="0.85" stopColor="white" stopOpacity={0.12} /> <Stop offset="1" stopColor="white" stopOpacity={0} /> </LinearGradient> </Defs> <Rect width="100%" height="100%" fill={`url(#${gradientId})`} /> </Svg> } > <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}> <ShimmerWorkContent textClassName={props.textClassName} compact={props.compact} environmentId={props.environmentId} highlighted icon={props.icon} iconSubtleColor={props.iconSubtleColor} label={props.label} showIcon={props.showIcon} themeAppearance={props.themeAppearance} toolIcon={props.toolIcon} /> </Animated.View> </MaskedView> </Animated.View> ) : null} </View> ); } 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<ThreadFeedActivity>, rowHeight = WORK_ROW_HEIGHT, ): number { return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP; } export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): 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<ThreadFeedActivity>; readonly anchorKey: string; readonly environmentId: EnvironmentId; readonly copiedRowId: string | null; readonly expandedRows: Readonly<Record<string, boolean>>; readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; 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) => ( <ThreadWorkLogRow key={row.id} row={row} anchorKey={props.anchorKey} copied={props.copiedRowId === row.id} expanded={props.expandedRows[row.id] ?? false} environmentId={props.environmentId} iconSubtleColor={props.iconSubtleColor} onCopyRow={props.onCopyRow} onToggleRow={props.onToggleRow} renderImage={props.renderImage} themeAppearance={props.themeAppearance} /> ), [ 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 ( <View className="-mx-1 mb-1 px-1 py-0"> {props.activities[0]?.groupedToolDetail ? ( <ThreadWorkGroupList activities={props.activities} edgeFadeColor={props.edgeFadeColor} expandedRows={props.expandedRows} groupId={props.anchorKey} rowSizing={props.rowSizing} scrollPositions={props.scrollPositions} renderRow={renderRow} /> ) : ( <View className="gap-px">{props.activities.map(renderRow)}</View> )} </View> ); } function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray<ThreadFeedActivity>; readonly edgeFadeColor: string; readonly expandedRows: Readonly<Record<string, boolean>>; readonly groupId: string; readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; 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(() =>