import { View, Text, Image, Pressable, ActivityIndicator, type LayoutChangeEvent, StyleProp, ViewStyle, } from "react-native"; import * as React from "react"; import { useState, useEffect, useRef, memo, useMemo, useCallback, createContext, useContext, isValidElement, Children, cloneElement, } from "react"; import type { ReactNode, ComponentType } from "react"; import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display"; import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, Check, CheckSquare, X, Copy, TriangleAlertIcon, Scissors, MicVocal, } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; 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"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { getAssistantImageMetadata, setAssistantImageMetadata, } from "@/utils/assistant-image-metadata"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; export type { InlinePathTarget } from "@/utils/inline-path"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import type { DaemonClient } from "@server/client/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; 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 (isNative) { 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 isCompact = useIsCompactFormFactor(); 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 && (isCompact || messageHovered || copyButtonHovered); return ( setMessageHovered(true)} onHoverOut={() => setMessageHovered(false)} > {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; serverId?: string; client?: DaemonClient | null; 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, userSelect: isWeb ? "text" : "auto", }, imageFrame: { width: "100%", minHeight: 160, marginHorizontal: -theme.spacing[1], }, imageSurface: { width: "100%", overflow: "hidden", }, image: { width: "100%", height: "100%", }, imageState: { alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[6], gap: theme.spacing[2], }, imageErrorText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, })); const ASSISTANT_IMAGE_MIN_HEIGHT = 160; const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({ uri, alt, containerStyle, source, workspaceRoot, serverId, }: { uri: string; alt?: string; containerStyle?: StyleProp; source: string; workspaceRoot?: string; serverId?: string; }) { const cachedMetadata = useMemo( () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), [serverId, source, workspaceRoot], ); const [aspectRatio, setAspectRatio] = useState( cachedMetadata?.aspectRatio ?? null, ); useEffect(() => { if (cachedMetadata) { setAspectRatio(cachedMetadata.aspectRatio); return; } setAspectRatio(null); let cancelled = false; Image.getSize( uri, (width, height) => { if (cancelled) { return; } if (width > 0 && height > 0) { const metadata = setAssistantImageMetadata( { source, workspaceRoot, serverId }, { width, height }, ); setAspectRatio(metadata?.aspectRatio ?? width / height); } }, () => { if (cancelled) { return; } setAspectRatio(null); }, ); return () => { cancelled = true; }; }, [cachedMetadata, serverId, source, uri, workspaceRoot]); const surfaceStyle = useMemo>( () => [ assistantMessageStylesheet.imageSurface, aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT }, ], [aspectRatio], ); return ( ); }); function AssistantMarkdownImage({ source, alt, hasLeadingContent, client, workspaceRoot, serverId, }: { source: string; alt?: string; hasLeadingContent: boolean; client?: DaemonClient | null; workspaceRoot?: string; serverId?: string; }) { const { theme } = useUnistyles(); const resolution = useMemo( () => resolveAssistantImageSource({ source, workspaceRoot }), [source, workspaceRoot], ); const containerStyle = useMemo>( () => ({ marginTop: hasLeadingContent ? theme.spacing[4] : 0, marginBottom: 0, }), [hasLeadingContent, theme], ); const query = useQuery({ queryKey: [ "assistantMarkdownImage", serverId ?? "unknown-server", resolution?.kind === "file_rpc" ? resolution.cwd : null, resolution?.kind === "file_rpc" ? resolution.path : null, ], enabled: Boolean(client && resolution?.kind === "file_rpc"), staleTime: 30_000, queryFn: async () => { if (!client || !resolution || resolution.kind !== "file_rpc") { return null; } const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file"); if (payload.error) { throw new Error(payload.error); } if (!payload.file || payload.file.kind !== "image" || !payload.file.content) { throw new Error("Image preview unavailable."); } return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`; }, }); const directUri = resolution?.kind === "direct" ? resolution.uri : null; const resolvedUri = directUri ?? query.data ?? null; if (resolvedUri) { return ( ); } if (query.isLoading) { return ( ); } return ( {query.error instanceof Error ? query.error.message : "Unable to load image preview."} ); } function MarkdownLink({ href, style, onPress, children, }: { href: string; style: any; onPress: (url: string) => void; children: ReactNode; }) { const [hovered, setHovered] = useState(false); if (isNative) { 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)} onHoverOut={() => onHoverChange?.(false)} 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, }, })); interface MemoizedMarkdownBlockProps { text: string; styles: ReturnType; rules: RenderRules; parser: MarkdownIt; onLinkPress: (url: string) => boolean; } const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ text, styles, rules, parser, onLinkPress, }: MemoizedMarkdownBlockProps) { return ( {text} ); }); export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, onInlinePathPress, workspaceRoot, serverId, client, 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={isWeb ? undefined : 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) => ( {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, )} ), image: (node: any, _children: ReactNode[], parent: any, styles: any) => { const paragraphNode = Array.isArray(parent) ? parent.find((ancestor: any) => ancestor?.type === "paragraph") : null; const paragraphChildren = Array.isArray(paragraphNode?.children) ? paragraphNode.children : []; const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key); const hasLeadingContent = imageIndex > 0; return ( ); }, }; }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); return ( {blocks.map((block, index) => ( ))} ); }); interface SpeakMessageProps { message: string; timestamp: number; disableOuterSpacing?: boolean; } const speakMessageStylesheet = StyleSheet.create((theme) => ({ container: { paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[3], }, containerSpacing: { marginBottom: theme.spacing[4], }, header: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginBottom: theme.spacing[2], }, headerLabel: { fontFamily: Fonts.sans, fontSize: 12, fontWeight: "500", color: theme.colors.foregroundMuted, }, text: { fontFamily: Fonts.sans, fontSize: theme.fontSize.base, lineHeight: 22, color: theme.colors.foreground, }, })); export const SpeakMessage = memo(function SpeakMessage({ message, timestamp, disableOuterSpacing, }: SpeakMessageProps) { const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); return ( Spoke {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 && isWeb; const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureNativeShimmer = isLoading && isNative; 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 (isNative || !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 = useIsCompactFormFactor(); 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]); if (effectiveDetail?.type === "plan") { return ( ); } 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; }