import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; import { useIsFocused } from "@react-navigation/native"; import { useEffect, useId, useState, type ComponentProps } 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 { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; import Animated, { cancelAnimation, Easing, FadeIn, FadeOut, LinearTransition, ReduceMotion, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, } from "react-native-reanimated"; 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); function ShimmerWorkContent(props: { readonly highlighted: boolean; readonly icon: AppSymbolName; readonly iconSubtleColor: ColorValue; readonly label: string; readonly onTextLayout?: ComponentProps["onTextLayout"]; readonly showIcon: boolean; }) { return ( {props.showIcon ? ( ) : null} {props.label} ); } export function ShimmeringWorkContent(props: { readonly icon: AppSymbolName; readonly iconSubtleColor: ColorValue; readonly label: string; readonly showIcon: boolean; }) { 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, SHIMMER_ICON_AND_GAP_WIDTH + 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 stripShellWrapper(value: string): string { const trimmed = value.trim(); const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); return (match?.[1] ?? trimmed).trim(); } function compactActivityDetail(detail: string | null): string | null { if (!detail) { return null; } const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); return cleaned.length > 0 ? cleaned : 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 "check": return { ios: "checkmark", android: "check" }; case "command": return { ios: "terminal", android: "terminal" }; 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; } // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log // rows are single-line (numberOfLines={1}) inside a min-height that stays // taller than text-sm at every supported base font size, so row height is // deterministic. Values mirror the classNames below. A mismatch only costs a // one-time correction on measure. const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) export function collapsedWorkLogHeight(activities: ReadonlyArray): number { const rows = activities; if (rows.length === 0) { return 0; } return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; } export function ThreadWorkLog(props: { readonly activities: ReadonlyArray; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly iconSubtleColor: import("react-native").ColorValue; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; }) { const rows = props.activities.map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), })); if (rows.length === 0) { return null; } return ( {rows.map((row) => { const expanded = props.expandedRows[row.id] ?? false; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; const displayText = row.detail ?? row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; return ( { if (canExpand) { void Haptics.selectionAsync(); props.onToggleRow(row.id); } }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} className="rounded-md px-0.5 py-0 active:bg-subtle" > {row.live ? ( ) : ( <> {showIcon ? ( ) : null} {displayText} )} {props.copiedRowId === row.id ? ( Copied ) : null} {canExpand ? ( ) : null} {fullDetail ? ( {fullDetail} ) : null} ); })} ); } export function ThreadWorkGroupToggle(props: { readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; readonly hasFailure: boolean; readonly shimmer: boolean; readonly onToggle: () => void; }) { const accessibilityLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; const icon = 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" > {props.shimmer ? ( ) : ( <> {props.summary} )} ); } function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { switch (kind) { case "read": return { ios: "eye", android: "visibility" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "command": return { ios: "terminal", android: "terminal" }; 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" }; } }