import { View, Text, Image, Pressable, ActivityIndicator, type LayoutChangeEvent, StyleProp, ViewStyle, Platform, } from "react-native"; import { useState, useEffect, useRef, memo, useMemo, useCallback, createContext, useContext, isValidElement, Children, cloneElement, } from "react"; import type { ReactNode, ComponentType } from "react"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, Check, CheckSquare, X, Copy, TriangleAlertIcon, Scissors, } from "lucide-react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime, } from "react-native-unistyles"; import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, } from "react-native-svg"; import { 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, UserMessageImageAttachment } from "@/types/stream"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallDisplayModel, } from "@/utils/tool-call-display"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { hasMeaningfulToolCallDetail, isPendingToolCallDetail, } from "@/utils/tool-call-detail-state"; import { parseAssistantFileLink, parseInlinePathToken, type InlinePathTarget, } from "@/utils/inline-path"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; export type { InlinePathTarget } from "@/utils/inline-path"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; interface UserMessageProps { message: string; images?: UserMessageImageAttachment[]; 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 WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes"; const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer"; const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { 0% { background-position: var(--paseo-shimmer-start, -200px) 0; } 100% { background-position: var(--paseo-shimmer-end, 200px) 0; } } `; let webToolCallShimmerRegistered = false; const SCROLL_EDGE_EPSILON = 0.5; type ScrollAxis = "x" | "y"; function ensureWebToolCallShimmerKeyframes() { if (Platform.OS !== "web") { return; } if (typeof document === "undefined") { return; } const existing = document.getElementById(WEB_TOOLCALL_SHIMMER_KEYFRAME_ID); if (existing) { if (existing.textContent !== WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS) { existing.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS; } webToolCallShimmerRegistered = true; return; } if (webToolCallShimmerRegistered) { return; } const styleElement = document.createElement("style"); styleElement.id = WEB_TOOLCALL_SHIMMER_KEYFRAME_ID; styleElement.textContent = WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS; document.head.appendChild(styleElement); webToolCallShimmerRegistered = true; } function getWheelEventElementTarget( event: WheelEvent, fallback: HTMLElement ): HTMLElement { const { target } = event; if (target instanceof HTMLElement) { return target; } if (target instanceof Node && target.parentElement) { return target.parentElement; } return fallback; } function canElementScrollInDirection( element: HTMLElement, axis: ScrollAxis, delta: number ): boolean { if (delta === 0) { return false; } const computedStyle = window.getComputedStyle(element); const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY; const isScrollableOverflow = overflow === "auto" || overflow === "scroll" || overflow === "overlay"; if (!isScrollableOverflow) { return false; } const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop; const scrollSize = axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight; if (scrollSize <= SCROLL_EDGE_EPSILON) { return false; } if (delta > 0) { return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON; } return scrollPosition > SCROLL_EDGE_EPSILON; } function canScrollInsideDetailFromTarget( detailRoot: HTMLElement, startElement: HTMLElement, axis: ScrollAxis, delta: number ): boolean { if (delta === 0) { return false; } let current: HTMLElement | null = startElement; while (current) { if (canElementScrollInDirection(current, axis, delta)) { return true; } if (current === detailRoot) { break; } current = current.parentElement; } return false; } function shouldStopDetailWheelPropagation( detailRoot: HTMLElement, event: WheelEvent ): boolean { const startElement = getWheelEventElementTarget(event, detailRoot); const verticalDelta = event.deltaY; const horizontalDelta = event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0); const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; if (!hasVerticalIntent && !hasHorizontalIntent) { return false; } const canScrollVertically = hasVerticalIntent ? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta) : false; const canScrollHorizontally = hasHorizontalIntent ? canScrollInsideDetailFromTarget( detailRoot, startElement, "x", horizontalDelta ) : false; if (hasVerticalIntent && hasHorizontalIntent) { const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta); return isVerticalDominant ? canScrollVertically || canScrollHorizontally : canScrollHorizontally || canScrollVertically; } if (hasVerticalIntent) { return canScrollVertically; } return canScrollHorizontally; } 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", }, imagePreviewContainer: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", }, imagePreviewSpacing: { marginBottom: theme.spacing[2], }, imagePill: { borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.borderAccent, overflow: "hidden", }, imageThumbnail: { width: 48, height: 48, }, imageThumbnailPlaceholder: { width: 48, height: 48, backgroundColor: theme.colors.surface1, }, copyButton: { alignSelf: "flex-end", padding: theme.spacing[1], marginTop: theme.spacing[2], }, copyButtonHidden: { opacity: 0, }, copyButtonVisible: { opacity: 1, }, })); function UserMessageAttachmentThumbnail({ image, }: { image: UserMessageImageAttachment; }) { const uri = useAttachmentPreviewUrl(image); if (!uri) { return ; } return ; } export const UserMessage = memo(function UserMessage({ message, images = [], timestamp, isFirstInGroup = true, isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); return ( setMessageHovered(true) : undefined } onHoverOut={ Platform.OS === "web" ? () => setMessageHovered(false) : undefined } > {hasImages ? ( {images.map((image, index) => ( ))} ) : null} {hasText ? ( {message} ) : null} {hasText ? ( message} containerStyle={[ userMessageStylesheet.copyButton, showCopyButton ? userMessageStylesheet.copyButtonVisible : userMessageStylesheet.copyButtonHidden, ]} accessibilityLabel="Copy message" onHoverChange={setCopyButtonHovered} /> ) : null} ); }); interface AssistantMessageProps { message: string; timestamp: number; onInlinePathPress?: (target: InlinePathTarget) => void; workspaceRoot?: string; 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 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, }, })); function MarkdownLink({ href, style, onPress, children, }: { href: string; style: any; onPress: (url: string) => void; children: ReactNode; }) { const [hovered, setHovered] = useState(false); if (Platform.OS !== "web") { return ( onPress(href)} style={style} > {children} ); } return ( onPress(href)} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} > {children} ); } function getInlineCodeAutoLinkUrl( markdownParser: ReturnType, content: string ): string | null { const trimmed = content.trim(); if (!trimmed) { return null; } const matches = markdownParser.linkify.match(trimmed) as | Array<{ index: number; lastIndex: number; url: string }> | null; if (!matches || matches.length !== 1) { return null; } const [match] = matches; if (!match || match.index !== 0 || match.lastIndex !== trimmed.length) { return null; } return match.url; } function nodeHasParentType(parent: unknown, type: string): boolean { if (Array.isArray(parent)) { return parent.some((entry) => entry?.type === type); } return ( typeof parent === "object" && parent !== null && "type" in parent && (parent as { type?: string }).type === type ); } const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({ container: { alignSelf: "flex-start", padding: theme.spacing[2], paddingTop: 0, marginTop: theme.spacing[2], }, 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: -6, }, containerSpacing: { marginBottom: theme.spacing[1], }, containerLastInSequence: { marginBottom: theme.spacing[4], }, pressable: { borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: "transparent", 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[1], backgroundColor: "transparent", }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flexShrink: 0, }, labelActive: { color: theme.colors.foreground, }, labelLoading: { color: theme.colors.foreground, opacity: 0.72, }, secondaryLabel: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, marginLeft: theme.spacing[2], }, secondaryLabelActive: { color: theme.colors.foreground, }, shimmerText: { color: "transparent", fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flexShrink: 1, }, spacer: { flex: 1, }, chevron: { marginLeft: theme.spacing[1], flexShrink: 0, }, chevronExpanded: { transform: [{ rotate: "90deg" }], }, detailWrapper: { borderBottomLeftRadius: theme.borderRadius.lg, borderBottomRightRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderTopWidth: 0, borderColor: theme.colors.border, padding: 0, gap: 0, flexShrink: 1, minWidth: 0, overflow: "hidden", }, pressableExpanded: { borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, shimmerOverlay: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, flexDirection: "row", alignItems: "center", overflow: "hidden", }, shimmerMaskRow: { flexDirection: "row", alignItems: "center", width: "100%", height: "100%", }, nativeShimmerTrack: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, overflow: "hidden", }, nativeShimmerPeak: { position: "absolute", top: 0, bottom: 0, left: 0, }, })); export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, onInlinePathPress, workspaceRoot, disableOuterSpacing, }: AssistantMessageProps) { const { theme, rt } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); const markdownParser = useMemo( () => { const parser = MarkdownIt({ typographer: true, linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); parser.validateLink = (url: string) => { if (url.trim().toLowerCase().startsWith("file://")) { return true; } return defaultValidateLink(url); }; return parser; }, [] ); const handleLinkPress = useCallback((url: string) => { const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null; if (fileTarget) { onInlinePathPress?.(fileTarget); return false; } void openExternalUrl(url); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; }, [onInlinePathPress, workspaceRoot]); 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 isLinkedInlineCode = nodeHasParentType(parent, "link") || (!Array.isArray(parent) && typeof parent?.attributes?.href === "string"); const parsed = onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null; if (parsed) { return ( parsed && onInlinePathPress?.(parsed)} selectable={false} style={[ assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText, ]} > {content} ); } const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content); if (inlineCodeLinkUrl) { return ( {content} ); } return ( {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, marker } = getMarkdownListMarker(node, parent); const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( {marker} {children} ); }, paragraph: ( node: any, children: ReactNode[], parent: any, styles: any, ) => { const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; return ( {children} ); }, link: ( node: any, children: ReactNode[], _parent: any, styles: any, ) => ( {Children.map(children, (child) => isValidElement(child) ? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any) : child )} ), }; }, [handleLinkPress, markdownParser, 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 [isHovered, setIsHovered] = useState(false); const [isPressed, setIsPressed] = useState(false); const isInteractive = Boolean(onToggle); const hasDetailContent = Boolean(renderDetails); const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null; const detailWrapperRef = useRef(null); const wheelInvestigationComponentId = `ExpandableBadgeWheel:${ testID ?? label }`; const nativeGradientIdRef = useRef( `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` ); const [labelRowWidth, setLabelRowWidth] = useState(0); const [labelRowHeight, setLabelRowHeight] = useState(0); const [labelOffsetX, setLabelOffsetX] = useState(0); const [labelWidth, setLabelWidth] = useState(0); const [secondaryOffsetX, setSecondaryOffsetX] = useState(0); const [secondaryWidth, setSecondaryWidth] = useState(0); const shimmerTranslateX = useSharedValue(0); const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0); const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; const shimmerDuration = Math.max( 1, Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment) ); const nativeShimmerPeakWidth = Math.max( 32, Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0) ); const isWebShimmer = isLoading && Platform.OS === "web"; const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanEndX = secondaryLabel ? secondaryOffsetX + secondaryWidth : labelOffsetX + labelWidth; const webShimmerSpanWidth = Math.max( 1, webShimmerSpanEndX - webShimmerSpanStartX ); const webShimmerPeakWidth = Math.max( 42, Math.min(120, webShimmerSpanWidth * 0.22) ); const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth; const webShimmerTrackEnd = webShimmerSpanEndX; const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => { if (!shouldMeasureNativeShimmer) { return; } const { width, height } = event.nativeEvent.layout; setLabelRowWidth((previous) => Math.abs(previous - width) > 0.5 ? width : previous ); setLabelRowHeight((previous) => Math.abs(previous - height) > 0.5 ? height : previous ); }, [shouldMeasureNativeShimmer]); const handleLabelLayout = useCallback((event: LayoutChangeEvent) => { if (!shouldMeasureWebShimmer) { return; } const { x, width } = event.nativeEvent.layout; setLabelOffsetX((previous) => Math.abs(previous - x) > 0.5 ? x : previous ); setLabelWidth((previous) => Math.abs(previous - width) > 0.5 ? width : previous ); }, [shouldMeasureWebShimmer]); const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => { if (!shouldMeasureWebShimmer || !secondaryLabel) { return; } const { x, width } = event.nativeEvent.layout; setSecondaryOffsetX((previous) => Math.abs(previous - x) > 0.5 ? x : previous ); setSecondaryWidth((previous) => Math.abs(previous - width) > 0.5 ? width : previous ); }, [shouldMeasureWebShimmer, secondaryLabel]); useEffect(() => { if (!isWebShimmer) { return; } ensureWebToolCallShimmerKeyframes(); }, [isWebShimmer]); useEffect(() => { if (!isNativeShimmer) { cancelAnimation(shimmerTranslateX); shimmerTranslateX.value = -nativeShimmerPeakWidth; return; } const startPosition = -nativeShimmerPeakWidth; const endPosition = labelRowWidth + nativeShimmerPeakWidth; shimmerTranslateX.value = startPosition; shimmerTranslateX.value = withRepeat( withTiming(endPosition, { duration: shimmerDuration * 1000, easing: Easing.linear, }), -1, false ); return () => { cancelAnimation(shimmerTranslateX); }; }, [ isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX, ]); useEffect(() => { if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { return; } const node = detailWrapperRef.current as unknown as HTMLElement | null; if (!node || typeof node.addEventListener !== "function") { return; } const stopWheelPropagation = (event: WheelEvent) => { if (shouldStopDetailWheelPropagation(node, event)) { event.stopPropagation(); } }; markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach"); node.addEventListener("wheel", stopWheelPropagation, { passive: true }); return () => { markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach"); node.removeEventListener("wheel", stopWheelPropagation); }; }, [hasDetailContent, isExpanded, wheelInvestigationComponentId]); const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], })); const shimmerGradient = "linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)"; const shimmerLabelStyle = isWebShimmer ? ({ opacity: 1, color: "transparent", backgroundImage: shimmerGradient, backgroundSize: `${webShimmerPeakWidth}px 100%`, backgroundRepeat: "no-repeat", backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${shimmerDuration}s linear infinite`, "--paseo-shimmer-start": `${webShimmerTrackStart - labelOffsetX}px`, "--paseo-shimmer-end": `${webShimmerTrackEnd - labelOffsetX}px`, } as never) : null; const shimmerSecondaryStyle = isWebShimmer ? ({ opacity: 1, color: "transparent", backgroundImage: shimmerGradient, backgroundSize: `${webShimmerPeakWidth}px 100%`, backgroundRepeat: "no-repeat", backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${shimmerDuration}s linear infinite`, "--paseo-shimmer-start": `${webShimmerTrackStart - secondaryOffsetX}px`, "--paseo-shimmer-end": `${webShimmerTrackEnd - secondaryOffsetX}px`, } as never) : null; const containerStyle = useMemo( () => [ expandableBadgeStylesheet.container, !resolvedDisableOuterSpacing && (isLastInSequence ? expandableBadgeStylesheet.containerLastInSequence : expandableBadgeStylesheet.containerSpacing), style, ], [isLastInSequence, resolvedDisableOuterSpacing, style] ); const pressableStyle = useMemo( () => [ expandableBadgeStylesheet.pressable, isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, ], [isExpanded, isInteractive, isPressed] ); const accessibilityState = useMemo( () => (isInteractive ? { expanded: isExpanded } : undefined), [isExpanded, isInteractive] ); const isActive = isHovered || isExpanded; const labelStyle = useMemo( () => [ expandableBadgeStylesheet.label, isActive && expandableBadgeStylesheet.labelActive, isLoading && expandableBadgeStylesheet.labelLoading, ], [isActive, isLoading] ); const secondaryLabelStyle = useMemo( () => [ expandableBadgeStylesheet.secondaryLabel, isActive && expandableBadgeStylesheet.secondaryLabelActive, ], [isActive] ); const shimmerLabelTextStyle = useMemo( () => [ expandableBadgeStylesheet.label, isLoading && expandableBadgeStylesheet.labelLoading, expandableBadgeStylesheet.shimmerText, shimmerLabelStyle, ], [isLoading, shimmerLabelStyle] ); const shimmerSecondaryTextStyle = useMemo( () => [ expandableBadgeStylesheet.secondaryLabel, expandableBadgeStylesheet.shimmerText, shimmerSecondaryStyle, ], [shimmerSecondaryStyle] ); const nativeShimmerTrackStyle = useMemo( () => [ expandableBadgeStylesheet.nativeShimmerTrack, { width: labelRowWidth, height: labelRowHeight }, ], [labelRowHeight, labelRowWidth] ); const nativeShimmerMaskStyle = useMemo( () => [ expandableBadgeStylesheet.shimmerMaskRow, { width: labelRowWidth, height: labelRowHeight }, ], [labelRowHeight, labelRowWidth] ); const nativeLabelMaskStyle = useMemo( () => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }], [] ); const nativeSecondaryMaskStyle = useMemo( () => [ expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }, ], [] ); const nativeShimmerPeakCombinedStyle = useMemo( () => [ expandableBadgeStylesheet.nativeShimmerPeak, nativeShimmerPeakStyle, { width: nativeShimmerPeakWidth, height: labelRowHeight }, ], [labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth] ); const chevronStyle = useMemo( () => [ expandableBadgeStylesheet.chevron, isExpanded && expandableBadgeStylesheet.chevronExpanded, ], [isExpanded] ); const IconComponent = icon; const iconColor = isError ? theme.colors.destructive : isActive ? theme.colors.foreground : theme.colors.mutedForeground; let iconNode: ReactNode = null; if (isError) { iconNode = ; } else if (IconComponent) { iconNode = ; } return ( setIsHovered(true) : undefined} onHoverOut={ isInteractive ? () => { setIsHovered(false); setIsPressed(false); } : undefined } onPressIn={isInteractive ? () => setIsPressed(true) : undefined} onPressOut={isInteractive ? () => setIsPressed(false) : undefined} disabled={!isInteractive} accessibilityRole={isInteractive ? "button" : undefined} accessibilityState={accessibilityState} style={pressableStyle} > {iconNode} {label} {secondaryLabel ? ( {secondaryLabel} ) : ( )} {isWebShimmer ? ( {label} {secondaryLabel ? ( {secondaryLabel} ) : ( )} ) : null} {isNativeShimmer ? ( {label} {secondaryLabel ? ( {secondaryLabel} ) : ( )} } > ) : null} {isInteractive && isHovered ? ( ) : null} {detailContent ? ( onDetailHoverChange?.(true)} onHoverOut={() => onDetailHoverChange?.(false)} > {detailContent} ) : null} ); }, areExpandableBadgePropsEqual); function areExpandableBadgePropsEqual( previous: ExpandableBadgeProps, next: ExpandableBadgeProps ) { if (previous.label !== next.label) return false; if (previous.secondaryLabel !== next.secondaryLabel) return false; if (previous.icon !== next.icon) return false; if (previous.isExpanded !== next.isExpanded) return false; if (previous.style !== next.style) return false; if (previous.isLoading !== next.isLoading) return false; if (previous.isError !== next.isError) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; if (previous.testID !== next.testID) return false; if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false; if (previous.isExpanded && previous.renderDetails !== next.renderDetails) { return false; } return true; } interface ToolCallProps { toolName: string; args?: unknown | null; result?: unknown | null; error?: unknown | null; status: "executing" | "running" | "completed" | "failed" | "canceled"; detail?: ToolCallDetail; cwd?: string; metadata?: Record; isLastInSequence?: boolean; disableOuterSpacing?: boolean; onInlineDetailsHoverChange?: (hovered: boolean) => void; onInlineDetailsExpandedChange?: (expanded: boolean) => void; } export const ToolCall = memo(function ToolCall({ toolName, args, result, error, status, detail, cwd, metadata, isLastInSequence = false, disableOuterSpacing, onInlineDetailsHoverChange, onInlineDetailsExpandedChange, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); // Check if we're on mobile (use bottom sheet) or desktop (inline expand) const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const effectiveDetail = useMemo(() => { if (detail) { return detail; } if (args !== undefined || result !== undefined) { return { type: "unknown", input: args ?? null, output: result ?? null, }; } return undefined; }, [detail, args, result]); const displayDetail = effectiveDetail ?? { type: "unknown", input: null, output: null, }; const displayModel = useMemo( () => buildToolCallDisplayModel({ name: toolName, status: status === "executing" ? "running" : status, error: error ?? null, detail: displayDetail, metadata, cwd, }), [toolName, status, error, displayDetail, metadata, cwd] ); const displayName = displayModel.displayName; const summary = displayModel.summary; const errorText = displayModel.errorText; const IconComponent = resolveToolCallIcon(toolName, effectiveDetail); const isLoadingDetails = isPendingToolCallDetail({ detail: effectiveDetail, status, error, }); const secondaryLabel = summary; // Check if there's any content to display const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail); const canOpenDetails = hasDetails || isLoadingDetails; const handleToggle = useCallback(() => { if (isMobile) { openToolCall({ toolName, displayName, summary: secondaryLabel, detail: effectiveDetail, errorText, showLoadingSkeleton: isLoadingDetails, }); } else { setIsExpanded((prev) => !prev); } }, [ isMobile, openToolCall, toolName, displayName, secondaryLabel, effectiveDetail, errorText, isLoadingDetails, ]); 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, effectiveDetail, errorText, isLoadingDetails]); return ( ); }, areToolCallPropsEqual); function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.toolName !== next.toolName) return false; if (previous.args !== next.args) return false; if (previous.result !== next.result) return false; if (previous.error !== next.error) return false; if (previous.status !== next.status) return false; if (previous.detail !== next.detail) return false; if (previous.cwd !== next.cwd) return false; if (previous.metadata !== next.metadata) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; return true; }