import { View, Text, Pressable, ActivityIndicator, LayoutChangeEvent, StyleProp, ViewStyle, Platform, } from "react-native"; import { useState, useEffect, useRef, memo, useMemo, useCallback, createContext, useContext, } from "react"; import type { ReactNode, ComponentType } from "react"; import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing, cancelAnimation, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient, Stop, Rect, } from "react-native-svg"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; import * as Linking from "expo-linking"; import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, Check, CheckSquare, X, Wrench, Pencil, Eye, SquareTerminal, Search, Brain, Bot, Copy, TriangleAlertIcon, Scissors, } from "lucide-react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime, } from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; import { createMarkdownStyles, } from "@/styles/markdown-styles"; import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry } from "@/types/stream"; import { parseToolCallDisplay, type ToolCallKind } from "@/utils/tool-call-parsers"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path"; import { resolveToolCallPreview } from "./tool-call-preview"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; interface UserMessageProps { message: string; timestamp: number; isFirstInGroup?: boolean; isLastInGroup?: boolean; disableOuterSpacing?: boolean; } const MessageOuterSpacingContext = createContext(false); export function MessageOuterSpacingProvider({ disableOuterSpacing, children, }: { disableOuterSpacing: boolean; children: ReactNode; }) { return ( {children} ); } function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) { const contextValue = useContext(MessageOuterSpacingContext); return disableOuterSpacing ?? contextValue; } const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", justifyContent: "flex-end", paddingHorizontal: theme.spacing[2], }, content: { alignItems: "flex-end", maxWidth: "100%", }, containerSpacing: { marginBottom: theme.spacing[1], }, containerFirstInGroup: { marginTop: theme.spacing[4], }, containerLastInGroup: { marginBottom: theme.spacing[4], }, bubble: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius.sm, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], minWidth: 0, flexShrink: 1, }, text: { color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 22, overflowWrap: "anywhere", }, copyButton: { alignSelf: "flex-end", padding: theme.spacing[1], marginTop: theme.spacing[2], }, copyButtonHidden: { opacity: 0, }, copyButtonVisible: { opacity: 1, }, })); export const UserMessage = memo(function UserMessage({ message, timestamp, isFirstInGroup = true, isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const showCopyButton = Platform.OS !== "web" || messageHovered || copyButtonHovered; return ( setMessageHovered(true) : undefined } onHoverOut={ Platform.OS === "web" ? () => setMessageHovered(false) : undefined } > {message} message} containerStyle={[ userMessageStylesheet.copyButton, showCopyButton ? userMessageStylesheet.copyButtonVisible : userMessageStylesheet.copyButtonHidden, ]} accessibilityLabel="Copy message" onHoverChange={setCopyButtonHovered} /> ); }); interface AssistantMessageProps { message: string; timestamp: number; onInlinePathPress?: (target: InlinePathTarget) => void; disableOuterSpacing?: boolean; } export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ container: { paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[3], }, containerSpacing: { marginBottom: theme.spacing[4], }, // Used in custom markdownRules for inline code styling markdownCodeInline: { backgroundColor: theme.colors.surface2, color: theme.colors.foreground, paddingHorizontal: theme.spacing[2], paddingVertical: 2, borderRadius: theme.borderRadius.sm, fontFamily: Fonts.mono, fontSize: 13, }, // Used in custom markdownRules for path chip styling pathChip: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.full, paddingHorizontal: theme.spacing[2], paddingVertical: 2, marginRight: theme.spacing[1], marginVertical: 2, }, pathChipText: { color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: 13, }, })); const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({ container: { alignSelf: "flex-start", padding: theme.spacing[2], paddingTop: 0, }, iconColor: { color: theme.colors.foregroundMuted, }, iconHoveredColor: { color: theme.colors.foreground, }, })); interface TurnCopyButtonProps { getContent: () => string; containerStyle?: StyleProp; accessibilityLabel?: string; copiedAccessibilityLabel?: string; onHoverChange?: (hovered: boolean) => void; } export const TurnCopyButton = memo(function TurnCopyButton({ getContent, containerStyle, accessibilityLabel, copiedAccessibilityLabel, onHoverChange, }: TurnCopyButtonProps) { const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef | null>(null); const handleCopy = useCallback(async () => { const content = getContent(); if (!content) { return; } await Clipboard.setStringAsync(content); setCopied(true); if (copyTimeoutRef.current) { clearTimeout(copyTimeoutRef.current); } copyTimeoutRef.current = setTimeout(() => { setCopied(false); copyTimeoutRef.current = null; }, 1500); }, [getContent]); useEffect(() => { return () => { if (copyTimeoutRef.current) { clearTimeout(copyTimeoutRef.current); } }; }, []); return ( onHoverChange?.(true) : undefined} onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} style={[turnCopyButtonStylesheet.container, containerStyle]} accessibilityRole="button" accessibilityLabel={ copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn") } > {({ hovered }) => { const iconColor = hovered ? turnCopyButtonStylesheet.iconHoveredColor.color : turnCopyButtonStylesheet.iconColor.color; return copied ? ( ) : ( ); }} ); }); const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ container: { marginHorizontal: theme.spacing[2], }, containerSpacing: { marginBottom: theme.spacing[1], }, containerLastInSequence: { marginBottom: theme.spacing[4], }, pressable: { borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], overflow: "hidden", }, pressablePressed: { opacity: 0.9, }, headerRow: { flexDirection: "row", alignItems: "center", }, labelRow: { flex: 1, flexDirection: "row", alignItems: "center", overflow: "hidden", }, iconBadge: { width: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", marginRight: theme.spacing[2], backgroundColor: "transparent", }, label: { color: theme.colors.foreground, opacity: 0.88, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flexShrink: 0, }, secondaryLabel: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, marginLeft: theme.spacing[2], }, spacer: { flex: 1, }, chevron: { marginLeft: theme.spacing[1], flexShrink: 0, }, detailWrapper: { borderBottomLeftRadius: theme.borderRadius.lg, borderBottomRightRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderTopWidth: 0, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, padding: 0, gap: 0, flexShrink: 1, minWidth: 0, overflow: "hidden", }, pressableExpanded: { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, shimmerOverlay: { position: "absolute", top: 0, bottom: 0, width: 100, }, })); export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, onInlinePathPress, disableOuterSpacing, }: AssistantMessageProps) { const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo( () => MarkdownIt({ typographer: true, linkify: true }), [] ); const handleLinkPress = useCallback((url: string) => { if (Platform.OS === "web") { window.open(url, "_blank", "noopener,noreferrer"); } else { void Linking.openURL(url); } return true; }, []); const markdownRules = useMemo(() => { return { text: ( node: any, _children: ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), textgroup: ( node: any, children: ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {children} ), code_block: ( node: any, _children: ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), fence: ( node: any, _children: ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), code_inline: ( node: any, _children: ReactNode[], _parent: any, _styles: any, inheritedStyles: any = {} ) => { const content = node.content ?? ""; const parsed = onInlinePathPress ? parseInlinePathToken(content) : null; if (!parsed) { return ( {content} ); } return ( parsed && onInlinePathPress?.(parsed)} selectable={false} style={[ assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText, ]} > {content} ); }, bullet_list: ( node: any, children: ReactNode[], _parent: any, styles: any ) => ( {children} ), ordered_list: ( node: any, children: ReactNode[], _parent: any, styles: any ) => ( {children} ), list_item: ( node: any, children: ReactNode[], parent: any, styles: any ) => { const isOrdered = parent?.type === "ordered_list"; const index = parent?.children?.indexOf(node) ?? 0; const bullet = isOrdered ? `${index + 1}.` : "•"; const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( {bullet} {children} ); }, }; }, [onInlinePathPress]); return ( {message} ); }); interface ActivityLogProps { type: "system" | "info" | "success" | "error" | "artifact"; message: string; timestamp: number; metadata?: Record; artifactId?: string; artifactType?: string; title?: string; onArtifactClick?: (artifactId: string) => void; disableOuterSpacing?: boolean; } const activityLogStylesheet = StyleSheet.create((theme) => ({ pressable: { marginHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, overflow: "hidden", }, pressableSpacing: { marginBottom: theme.spacing[1], }, pressableActive: { opacity: 0.7, }, systemBg: { backgroundColor: "rgba(39, 39, 42, 0.5)", }, infoBg: { backgroundColor: "rgba(30, 58, 138, 0.3)", }, successBg: { backgroundColor: "rgba(20, 83, 45, 0.3)", }, errorBg: { backgroundColor: "rgba(127, 29, 29, 0.3)", }, artifactBg: { backgroundColor: "rgba(30, 58, 138, 0.4)", }, content: { paddingHorizontal: theme.spacing[3], paddingVertical: 10, }, row: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], }, iconContainer: { flexShrink: 0, }, textContainer: { flex: 1, }, messageText: { fontSize: theme.fontSize.sm, lineHeight: 20, }, detailsRow: { flexDirection: "row", alignItems: "center", marginTop: theme.spacing[1], }, detailsText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginRight: theme.spacing[1], }, metadataContainer: { marginTop: theme.spacing[2], backgroundColor: "rgba(0, 0, 0, 0.5)", borderRadius: theme.borderRadius.base, padding: theme.spacing[2], borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, }, metadataText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, fontFamily: Fonts.mono, lineHeight: 16, }, })); export const ActivityLog = memo(function ActivityLog({ type, message, timestamp, metadata, artifactId, artifactType, title, onArtifactClick, disableOuterSpacing, }: ActivityLogProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isExpanded, setIsExpanded] = useState(false); const typeConfig = { system: { bg: activityLogStylesheet.systemBg, color: "#a1a1aa", Icon: Circle, }, info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info }, success: { bg: activityLogStylesheet.successBg, color: "#4ade80", Icon: CheckCircle, }, error: { bg: activityLogStylesheet.errorBg, color: "#f87171", Icon: XCircle, }, artifact: { bg: activityLogStylesheet.artifactBg, color: "#93c5fd", Icon: FileText, }, }; const config = typeConfig[type]; const IconComponent = config.Icon; const handlePress = () => { if (type === "artifact" && artifactId && onArtifactClick) { onArtifactClick(artifactId); } else if (metadata) { setIsExpanded(!isExpanded); } }; const displayMessage = type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message; const isInteractive = type === "artifact" || metadata; return ( {displayMessage} {metadata && ( Details {isExpanded ? ( ) : ( )} )} {isExpanded && metadata && ( {JSON.stringify(metadata, null, 2)} )} ); }); interface CompactionMarkerProps { status: "loading" | "completed"; preTokens?: number; } const compactionStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", alignItems: "center", paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], gap: theme.spacing[2], }, line: { flex: 1, height: 1, backgroundColor: theme.colors.border, }, label: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, text: { fontFamily: Fonts.sans, fontSize: 13, color: theme.colors.foregroundMuted, }, })); export const CompactionMarker = memo(function CompactionMarker({ status, preTokens, }: CompactionMarkerProps) { const label = status === "loading" ? "Compacting..." : preTokens ? `Context compacted (${Math.round(preTokens / 1000)}K tokens)` : "Context compacted"; return ( {status === "loading" ? ( ) : ( )} {label} ); }); interface TodoListCardProps { items: TodoEntry[]; disableOuterSpacing?: boolean; } const todoListCardStylesheet = StyleSheet.create((theme) => ({ detailsWrapper: { padding: theme.spacing[2], }, list: { gap: theme.spacing[1], }, itemRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, radioBadge: { width: 16, height: 16, borderRadius: 8, backgroundColor: theme.colors.foregroundMuted, alignItems: "center", justifyContent: "center", }, radioBadgeIncomplete: { opacity: 0.55, }, radioBadgeComplete: { opacity: 0.95, }, itemText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base, }, itemTextCompleted: { color: theme.colors.foregroundMuted, textDecorationLine: "line-through", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, })); export const TodoListCard = memo(function TodoListCard({ items, disableOuterSpacing, }: TodoListCardProps) { const { theme: unistylesTheme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(false); const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); const handleToggle = useCallback(() => { setIsExpanded((prev) => !prev); }, []); const renderDetails = useCallback(() => { return ( {items.length === 0 ? ( No tasks yet. ) : ( items.map((item, idx) => ( {item.completed ? ( ) : null} {item.text} )) )} ); }, [items]); return ( ); }); interface ExpandableBadgeProps { label: string; secondaryLabel?: string; icon?: ComponentType<{ size?: number; color?: string }>; isExpanded: boolean; style?: StyleProp; onToggle?: () => void; onDetailHoverChange?: (hovered: boolean) => void; renderDetails?: () => ReactNode; isLoading?: boolean; isError?: boolean; isLastInSequence?: boolean; disableOuterSpacing?: boolean; testID?: string; } const ExpandableBadge = memo(function ExpandableBadge({ label, style, secondaryLabel, icon, isExpanded, onToggle, onDetailHoverChange, renderDetails, isLoading = false, isError = false, isLastInSequence = false, disableOuterSpacing, testID, }: ExpandableBadgeProps) { const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasDetails = Boolean(renderDetails); const detailContent = hasDetails && isExpanded ? renderDetails?.() : null; const [badgeWidth, setBadgeWidth] = useState(0); const handleLayout = useCallback((e: LayoutChangeEvent) => { setBadgeWidth(e.nativeEvent.layout.width); }, []); const shimmer = useSharedValue(-1); useEffect(() => { if (isLoading) { shimmer.value = -1; shimmer.value = withRepeat( withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }), -1 ); } else { cancelAnimation(shimmer); shimmer.value = -1; } }, [isLoading]); const shimmerBandWidth = 100; const shimmerStyle = useAnimatedStyle(() => { const travel = badgeWidth + shimmerBandWidth; return { transform: [ { translateX: -shimmerBandWidth + ((shimmer.value + 1) / 2) * travel, }, ], }; }); const IconComponent = icon; const iconColor = isError ? theme.colors.destructive : theme.colors.mutedForeground; let iconNode: ReactNode = null; if (isError) { iconNode = ; } else if (IconComponent) { iconNode = ; } return ( [ expandableBadgeStylesheet.pressable, pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, ]} > {({ hovered }) => ( <> {iconNode} {label} {secondaryLabel ? ( {secondaryLabel} ) : ( )} {isLoading && badgeWidth > 0 ? ( ) : null} {hasDetails && hovered ? ( ) : null} )} {detailContent ? ( onDetailHoverChange?.(true)} onHoverOut={() => onDetailHoverChange?.(false)} > {detailContent} ) : null} ); }); interface ToolCallProps { toolName: string; args: any; result?: any; error?: any; status: "executing" | "completed" | "failed"; cwd?: string; metadata?: Record; isLastInSequence?: boolean; disableOuterSpacing?: boolean; onInlineDetailsHoverChange?: (hovered: boolean) => void; onInlineDetailsExpandedChange?: (expanded: boolean) => void; } // Icon mapping for tool kinds const toolKindIcons: Record = { edit: Pencil, read: Eye, execute: SquareTerminal, search: Search, thinking: Brain, agent: Bot, }; const TOOL_CALL_LOG_TAG = "[ToolCall]"; const TOOL_CALL_COMMIT_THRESHOLD_MS = 16; export const ToolCall = memo(function ToolCall({ toolName, args, result, error, status, cwd, metadata, isLastInSequence = false, disableOuterSpacing, onInlineDetailsHoverChange, onInlineDetailsExpandedChange, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); const toggleStartRef = useRef(null); // Check if we're on mobile (use bottom sheet) or desktop (inline expand) const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const displayInfo = useMemo( () => parseToolCallDisplay({ name: toolName, input: args, output: result, error, metadata, cwd }), [toolName, args, result, error, metadata, cwd] ); const { kind, displayName, summary, detail, errorText } = displayInfo; const IconComponent = toolKindIcons[kind] || Wrench; // Check if there's any content to display const hasDetails = args !== undefined || result !== undefined || error !== undefined; const handleToggle = useCallback(() => { if (!isMobile && isPerfLoggingEnabled()) { toggleStartRef.current = getNowMs(); } if (isMobile) { openToolCall(displayInfo); } else { setIsExpanded((prev) => !prev); } }, [isMobile, openToolCall, displayInfo]); useEffect(() => { if (isMobile || !isPerfLoggingEnabled()) { return; } const startMs = toggleStartRef.current; if (startMs === null) { return; } toggleStartRef.current = null; const logCommit = () => { const durationMs = getNowMs() - startMs; if (durationMs >= TOOL_CALL_COMMIT_THRESHOLD_MS) { perfLog(TOOL_CALL_LOG_TAG, { event: isExpanded ? "expand_commit" : "collapse_commit", toolName, kind, durationMs: Math.round(durationMs), }); } }; if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => logCommit()); } else { logCommit(); } }, [isExpanded, isMobile, toolName, kind]); useEffect(() => { if (!onInlineDetailsHoverChange || isMobile || isExpanded) { return; } onInlineDetailsHoverChange(false); }, [isExpanded, isMobile, onInlineDetailsHoverChange]); useEffect(() => { if (!onInlineDetailsExpandedChange) { return; } if (isMobile) { onInlineDetailsExpandedChange(false); return; } onInlineDetailsExpandedChange(isExpanded); }, [isExpanded, isMobile, onInlineDetailsExpandedChange]); useEffect(() => { if (!onInlineDetailsExpandedChange) { return; } return () => { onInlineDetailsExpandedChange(false); }; }, [onInlineDetailsExpandedChange]); // Render inline details for desktop const renderDetails = useCallback(() => { if (isMobile) return null; return ; }, [isMobile, detail, errorText]); return ( null : undefined } isLoading={status === "executing"} isError={status === "failed"} isLastInSequence={isLastInSequence} disableOuterSpacing={disableOuterSpacing} onDetailHoverChange={onInlineDetailsHoverChange} /> ); });