import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { duration: 180, create: { type: LayoutAnimation.Types.easeInEaseOut, property: LayoutAnimation.Properties.opacity, }, update: { type: LayoutAnimation.Types.easeInEaseOut }, delete: { type: LayoutAnimation.Types.easeInEaseOut, property: LayoutAnimation.Properties.opacity, }, } as const; function triggerDisclosureFeedback() { LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); void Haptics.selectionAsync(); } 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; } // Tool-like activities with a neutral status carry no signal worth a row. export function visibleWorkLogActivities( activities: ReadonlyArray, ): ReadonlyArray { return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral")); } // 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 the text at every supported base font size (text-xs reaches // 23px at the 22pt maximum, under the 32px min-h-8), so row height is // deterministic. The "work log" label has no such clamp — its height follows // the scaled text-2xs line height. Values mirror the classNames below — keep // them in sync; 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_HEADER_PADDING = 2; // pb-0.5 under the "work log" label 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, baseFontSize: number, ): number { const rows = visibleWorkLogActivities(activities); if (rows.length === 0) { return 0; } const onlyToolRows = rows.every((row) => row.toolLike); const headerHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING; return ( WORK_LOG_BOTTOM_MARGIN + (onlyToolRows ? 0 : headerHeight) + 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 colorScheme = useColorScheme(); const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), })); if (rows.length === 0) { return null; } const onlyToolRows = rows.every((row) => row.toolLike); return ( {!onlyToolRows ? ( work log ) : null} {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} ${row.detail}` : row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; return ( { if (canExpand) { triggerDisclosureFeedback(); props.onToggleRow(row.id); } }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackground : "transparent", })} className="rounded-md px-0.5 py-0" > {row.summary} {row.detail ? ( {row.detail} ) : null} {props.copiedRowId === row.id ? ( Copied ) : null} {canExpand ? ( ) : null} {row.status ? ( ) : null} {fullDetail ? ( {fullDetail} ) : null} ); })} ); } export function ThreadWorkGroupToggle(props: { readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; readonly onlyToolActivities: boolean; readonly onToggle: () => void; }) { const colorScheme = useColorScheme(); const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; const noun = props.onlyToolActivities ? props.hiddenCount === 1 ? "tool call" : "tool calls" : props.hiddenCount === 1 ? "log entry" : "log entries"; const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`; const expandedLabel = props.onlyToolActivities ? "Show fewer tool calls" : "Show fewer log entries"; return ( { void Haptics.selectionAsync(); props.onToggle(); }} style={({ pressed }) => ({ backgroundColor: pressed ? pressedBackground : "transparent", })} className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0" > {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`} ); }