import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { TaskListRow } from "@/components/task-list-row"; import { View, Text, Image, Pressable, type GestureResponderEvent, type LayoutChangeEvent, StyleProp, ViewStyle, type TextStyle, } from "react-native"; import { useTranslation } from "react-i18next"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownTableCellText } from "@/components/markdown-text-selection"; import * as React from "react"; import { useState, useEffect, useRef, memo, useMemo, useCallback, createContext, useContext, } from "react"; import type { ComponentType, ReactNode } from "react"; import type MarkdownIt from "markdown-it"; import { type ASTNode, type RenderRules } from "react-native-markdown-display"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, Check, CheckSquare, CircleDot, Copy, Plus, RotateCcw, TriangleAlertIcon, Scissors, MicVocal, FileSymlink, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ICON_SIZE, type Theme } from "@/styles/theme"; 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 { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; import { markdownNodeContainsType } from "@/utils/markdown-ast"; import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownFenceBlock } from "@/components/markdown/fence"; import type { MarkdownPhase } from "@/components/markdown/fence/types"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { colorMarkdownLinkChildren } from "@/components/markdown/link-children"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { AssistantInlineCodePathLink, type AssistantFileLinkSource, AssistantMarkdownCodeLink, AssistantMarkdownLink, type InlinePathTarget, useAssistantFileLinkActions, useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAssistantImage } from "@/assistant-image/use-assistant-image"; import { AttachmentFrame, AttachmentLabel, AttachmentThumbnail, } from "@/components/attachment-pill"; import { AttachmentLightbox } from "@/components/attachment-lightbox"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { markdownCopyDataSet, markdownCopyOrderedListDataSet, markdownCopyTableCellDataSet, type MarkdownCopyInlineTag, } from "@/assistant-selection-copy/markup"; export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; interface UserMessageProps { serverId?: string; agentId?: string; messageId?: string; message: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; timestamp: number; capabilities?: AgentCapabilityFlags; client?: DaemonClient | null; isFirstInGroup?: boolean; isLastInGroup?: boolean; isPending?: 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 MARKDOWN_ALLOWED_IMAGE_HANDLERS = [ "data:image/png;base64", "data:image/gif;base64", "data:image/jpeg;base64", "https://", "http://", ] as const; const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = ...; const ThemedMicVocal = withUnistyles(MicVocal); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const mutedForegroundColorMapping = (theme: Theme) => ({ color: theme.colors.mutedForeground, }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); 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; // Font size for stream metadata (timestamps, durations, live elapsed timer). // Lives between theme.fontSize.xs (12) and theme.fontSize.sm (14); no token. export const STREAM_METADATA_FONT_SIZE = 13; 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; let horizontalDelta: number; if (event.deltaX !== 0) horizontalDelta = event.deltaX; else if (event.shiftKey) horizontalDelta = event.deltaY; else horizontalDelta = 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", ...(isWeb ? { userSelect: "text" as const } : {}), }, content: { alignItems: "flex-end", maxWidth: "100%", cursor: "auto", }, containerSpacing: { marginBottom: theme.spacing[1], }, containerFirstInGroup: { marginTop: theme.spacing[4], }, containerLastInGroup: { marginBottom: theme.spacing[4], }, bubble: { backgroundColor: theme.colors.surface3, 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, ...(isWeb ? { lineHeight: 22, overflowWrap: "anywhere" as const } : {}), }, imagePreviewContainer: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", }, attachmentPreviewContainer: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", }, imagePreviewSpacing: { marginBottom: theme.spacing[2], }, copyButton: { alignSelf: "center", padding: theme.spacing[1], paddingTop: theme.spacing[1], marginTop: 0, marginRight: -theme.spacing[1], }, trailingRow: { alignSelf: "flex-end", flexDirection: "row", alignItems: "center", height: 24, gap: theme.spacing[2], marginTop: theme.spacing[2], }, trailingRowHidden: { opacity: 0, }, trailingRowVisible: { opacity: 1, }, timestampText: { color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, }, })); interface UserMessageImagePillProps { image: UserMessageImageAttachment; onOpen: (image: UserMessageImageAttachment) => void; accessibilityLabel: string; } function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) { const handlePress = useCallback(() => { onOpen(image); }, [onOpen, image]); return ( ); } export const UserMessage = memo(function UserMessage({ serverId, agentId, messageId, message, images = [], attachments = [], timestamp, capabilities, client, isFirstInGroup = true, isLastInGroup = true, isPending = false, disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const [lightboxMetadata, setLightboxMetadata] = useState(null); const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; const hasAttachments = attachments.length > 0; const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered); const formattedTimestamp = useMemo( () => formatMessageTimestamp(new Date(timestamp)), [timestamp], ); const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId }); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const getMessageContent = useCallback(() => message, [message]); const handleRewind = useCallback( (input: { mode: RewindMode; rewoundText: string }) => { return rewindMutation.rewindAgent(input); }, [rewindMutation], ); const containerStyle = useMemo( () => [ userMessageStylesheet.container, !resolvedDisableOuterSpacing && [ isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null, isLastInGroup ? userMessageStylesheet.containerLastInGroup : null, !isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null, ], ], [resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup], ); const imagePreviewContainerStyle = useMemo( () => [ userMessageStylesheet.imagePreviewContainer, hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined, ], [hasAttachments, hasText], ); const attachmentPreviewContainerStyle = useMemo( () => [ userMessageStylesheet.attachmentPreviewContainer, hasText ? userMessageStylesheet.imagePreviewSpacing : undefined, ], [hasText], ); const trailingRowStyle = useMemo( () => [ userMessageStylesheet.trailingRow, showTrailingRow ? userMessageStylesheet.trailingRowVisible : userMessageStylesheet.trailingRowHidden, ], [showTrailingRow], ); return ( {hasImages ? ( {images.map((image) => ( ))} ) : null} {hasAttachments ? ( {attachments.map((attachment, index) => { const content = getAgentAttachmentPillContent(attachment, t); return ( ); })} ) : null} {hasText ? ( {message} ) : null} {hasText ? ( {formattedTimestamp} {capabilities && messageId ? ( ) : null} ) : null} ); }); interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; durationMs?: number; onFork?: (target: AssistantForkTarget) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, copyButton: { alignSelf: "center", padding: theme.spacing[1], paddingTop: theme.spacing[1], marginTop: 0, marginLeft: -theme.spacing[1], }, labelWrapper: { position: "relative", }, labelSizer: { color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, opacity: 0, }, labelOverlay: { position: "absolute", top: 0, left: 0, color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, }, })); const TIMESTAMP_REVEAL_MS = 3000; /** * Footer rendered next to the copy button at the end of an assistant turn. * Always shows the turn duration; swaps to the end timestamp on hover (web) * or tap (native). The hidden sizer keeps the label width stable while the * visible text swaps. */ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); const [pressedReveal, setPressedReveal] = useState(false); const revealTimerRef = useRef | null>(null); useEffect(() => { return () => { if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null; } }; }, []); const durationLabel = useMemo( () => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""), [durationMs], ); const timestampLabel = useMemo( () => (completedAt ? formatMessageTimestamp(completedAt) : ""), [completedAt], ); const canSwap = Boolean(timestampLabel); const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal); const handleHoverIn = useCallback(() => setHovered(true), []); const handleHoverOut = useCallback(() => setHovered(false), []); const handlePress = useCallback(() => { if (isWeb || !canSwap) return; if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); } setPressedReveal((prev) => !prev); revealTimerRef.current = setTimeout(() => { setPressedReveal(false); revealTimerRef.current = null; }, TIMESTAMP_REVEAL_MS); }, [canSwap]); const handleFork = useCallback( (target: AssistantForkTarget) => { return onFork?.(target); }, [onFork], ); const canFork = Boolean(onFork); return ( {canFork ? : null} {durationLabel ? ( {/* Sizer reserves space for whichever label is longer so the container width is stable across hover transitions. */} {durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel} {showTimestamp ? timestampLabel : durationLabel} ) : null} ); }); interface LiveElapsedProps { startedAt: Date; active?: boolean; style?: StyleProp; testID?: string; } /** * Ticks every second to render an elapsed duration. Isolated from parents so * only this component re-renders on each tick. */ export const LiveElapsed = memo(function LiveElapsed({ startedAt, active = true, style, testID, }: LiveElapsedProps) { const startedAtMs = startedAt.getTime(); const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs)); const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs; useEffect(() => { if (!active) { return; } setElapsedMs(Math.max(0, Date.now() - startedAtMs)); const handle = setInterval(() => { setElapsedMs(Math.max(0, Date.now() - startedAtMs)); }, 1000); return () => clearInterval(handle); }, [active, startedAtMs]); return ( {formatDuration(visibleElapsedMs)} ); }); interface AssistantMessageProps { occurrenceKey: string; message: string; timestamp: number; workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; phase: MarkdownPhase; } export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ container: { paddingVertical: theme.spacing[3], ...(isWeb ? { userSelect: "text" as const } : {}), }, containerCompactTop: { paddingTop: 0, }, containerCompactBottom: { paddingBottom: 0, }, imageFrame: { width: "100%", minHeight: 160, marginHorizontal: -theme.spacing[1], }, imageSurface: { width: "100%", overflow: "hidden", position: "relative", }, image: { width: "100%", height: "100%", }, imageLoadingOverlay: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, alignItems: "center", justifyContent: "center", }, 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; function AssistantMarkdownImage({ source, occurrenceKey, alt, hasLeadingContent, client, workspaceRoot, serverId, }: { source: string; occurrenceKey: string; alt?: string; hasLeadingContent: boolean; client?: DaemonClient | null; workspaceRoot?: string; serverId?: string; }) { const containerStyle = useMemo>( () => ({ marginTop: hasLeadingContent ? 16 : 0, marginBottom: 0, }), [hasLeadingContent], ); const image = useAssistantImage({ source, occurrenceKey, client, workspaceRoot, serverId, }); const binding = image.status === "failed" ? null : image.binding; const aspectRatio = image.status === "failed" ? null : image.aspectRatio; const imageUri = binding?.uri ?? ""; const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); const frameStyle = useMemo>( () => [assistantMessageStylesheet.imageFrame, containerStyle], [containerStyle], ); const imageSizeStyle = useMemo(() => { if (aspectRatio) { return { aspectRatio }; } return { height: ASSISTANT_IMAGE_MIN_HEIGHT }; }, [aspectRatio]); const surfaceStyle = useMemo>( () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], [imageSizeStyle], ); const stateFrameStyle = useMemo>( () => [ assistantMessageStylesheet.imageFrame, containerStyle, { height: ASSISTANT_IMAGE_MIN_HEIGHT }, assistantMessageStylesheet.imageState, ], [containerStyle], ); if (image.status === "failed") { return ( {image.message} ); } if (!binding) { return ( ); } return ( {image.status === "loading" ? ( ) : null} ); } function getInlineCodeAutoLinkUrl(markdownParser: MarkdownIt, content: string): string | null { const trimmed = content.trim(); if (!trimmed) { return null; } const matches: | { index: number; lastIndex: number; url: string; }[] | null = markdownParser.linkify.match(trimmed); 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 getInlineCodeAutoLinkSource(input: { href: string; content: string; }): AssistantFileLinkSource { return { href: input.href, text: input.content, markup: "linkify", sourceInfo: "auto", }; } interface AssistantMarkdownAstNode extends ASTNode { sourceInfo?: string; } function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource { return { href: typeof node.attributes?.href === "string" ? node.attributes.href : "", text: getMarkdownNodeText(node), title: typeof node.attributes?.title === "string" ? node.attributes.title : undefined, markup: node.markup, sourceInfo: node.sourceInfo, sourceType: node.sourceType === "inline-code" ? "inline-code" : undefined, }; } function getMarkdownNodeText(node: ASTNode): string { if (!node.children.length) { return node.content ?? ""; } return node.children.map(getMarkdownNodeText).join(""); } 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 Record<"type", unknown>)["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; } export const TurnCopyButton = memo(function TurnCopyButton({ getContent, containerStyle, accessibilityLabel, copiedAccessibilityLabel, }: TurnCopyButtonProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef | null>(null); const handleCopy = useCallback(async () => { const content = getContent(); if (!content) { return; } await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment()); 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); } }; }, []); const pressableStyle = useMemo( () => [turnCopyButtonStylesheet.container, containerStyle], [containerStyle], ); return ( {({ hovered }) => { const iconColor = hovered ? turnCopyButtonStylesheet.iconHoveredColor.color : turnCopyButtonStylesheet.iconColor.color; return copied ? ( ) : ( ); }} ); }); const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ container: { marginHorizontal: -13, }, 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: { flexShrink: 1, minWidth: 0, 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, }, spacer: { flex: 1, }, chevron: { flexShrink: 0, }, openFileButton: { marginLeft: theme.spacing[1], padding: theme.spacing[1], borderRadius: theme.borderRadius.md, flexShrink: 0, }, openFileButtonPlaceholderIcon: { width: 14, height: 14, }, 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", ...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}), }, pressableExpanded: { backgroundColor: theme.colors.surface1, }, pressableExpandedAttached: { borderColor: theme.colors.border, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, detailWrapperBorderless: { borderWidth: 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 NativeExpandableBadgeShimmerProps { label: string; secondaryLabel?: string; rowWidth: number; rowHeight: number; peakWidth: number; durationSeconds: number; gradientId: string; } const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({ label, secondaryLabel, rowWidth, rowHeight, peakWidth, durationSeconds, gradientId, }: NativeExpandableBadgeShimmerProps) { const isPanelActive = useRetainedPanelActive(); const shimmerTranslateX = useSharedValue(0); useEffect(() => { if (!isPanelActive) { cancelAnimation(shimmerTranslateX); return; } const startPosition = -peakWidth; const endPosition = rowWidth + peakWidth; shimmerTranslateX.value = startPosition; shimmerTranslateX.value = withRepeat( withTiming(endPosition, { duration: durationSeconds * 1000, easing: Easing.linear, }), -1, false, ); return () => { cancelAnimation(shimmerTranslateX); }; }, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]); const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], })); const nativeShimmerTrackStyle = useMemo( () => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }], [rowHeight, rowWidth], ); const nativeShimmerMaskStyle = useMemo( () => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }], [rowHeight, rowWidth], ); 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: peakWidth, height: rowHeight }, ], [nativeShimmerPeakStyle, peakWidth, rowHeight], ); const maskElement = useMemo( () => ( {label} {secondaryLabel ? ( {secondaryLabel} ) : ( )} ), [nativeShimmerMaskStyle, nativeLabelMaskStyle, nativeSecondaryMaskStyle, label, secondaryLabel], ); return ( ); }); function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) { return ( ); } interface AssistantMessageBlockContainerProps { block: string; marginBottom: number; children: ReactNode; } function AssistantMessageBlockContainer({ block, marginBottom, children, }: AssistantMessageBlockContainerProps) { const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setAssistantMarkdownBlockHeight({ block, width, height }); }, [block], ); return ( {children} ); } interface MemoizedMarkdownBlockProps { text: string; rules: RenderRules; parser: MarkdownIt; onLinkPress: (url: string) => boolean; } const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ text, rules, parser, onLinkPress, }: MemoizedMarkdownBlockProps) { return ( ); }); interface MarkdownInheritedTextProps { inheritedStyles: TextStyle; textStyle: TextStyle; style?: StyleProp; monoSurface?: boolean; copyTag?: MarkdownCopyInlineTag; children: ReactNode; } function MarkdownInheritedText({ inheritedStyles, textStyle, style: overrideStyle, monoSurface, copyTag, children, }: MarkdownInheritedTextProps) { const style = useMemo( () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); // When this span renders link label text on iOS, pick up the link's press // handler from context and hand it to MarkdownTextSpan, which forwards it to // the leaf string children react-native-uitextview makes tappable. Null // outside a link (and on every other platform, where no provider mounts), so // ordinary text is unaffected. See assistant-file-links/link-press-context. const linkPress = useAssistantLinkPress(); return ( {children} ); } interface MarkdownListItemContentProps { contentStyle: ViewStyle; children: ReactNode; } const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 }; function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) { const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]); return {children}; } interface MarkdownListViewProps { baseStyle: ViewStyle; copyTag: "ol" | "ul"; orderedStart?: unknown; spacing: { marginTop: number; marginBottom: number }; children: ReactNode; } function MarkdownListView({ baseStyle, copyTag, orderedStart, spacing, children, }: MarkdownListViewProps) { const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]); const copyDataSet = copyTag === "ol" ? markdownCopyOrderedListDataSet(orderedStart) : markdownCopyDataSet.ul; return ( {children} ); } export const AssistantMessage = memo(function AssistantMessage({ occurrenceKey, message, timestamp: _timestamp, workspaceRoot, serverId, client, spacing = "default", phase, }: AssistantMessageProps) { const markdownParser = useMemo(createAssistantMarkdownParser, []); const fileLinkActions = useAssistantFileLinkActions(); const handleMarkdownLinkPress = useStableEvent((url: string) => { fileLinkActions.open({ href: url }, "main"); // 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; }); const markdownRules = useMemo(() => { return { heading1: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), heading2: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), heading3: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), heading4: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), heading5: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), heading6: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), blockquote: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), hr: (node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( ), table: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), thead: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), tbody: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), tr: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), text: ( node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( {node.content} ), textgroup: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( {children} ), // strong/em/s have no custom rule in react-native-markdown-display's // defaults beyond wrapping children in a plain RN . On iOS the // paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx), // and a plain nested inside one is not hoisted into a // UITextViewChild, so its content renders invisibly. Route these inline // marks through MarkdownTextSpan (same path as text/textgroup) so the // styled content composes and stays visible + selectable on iOS. strong: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( {children} ), em: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( {children} ), s: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( {children} ), // hardbreak/softbreak fall back to react-native-markdown-display's // default, a plain RN {"\n"}. Inside the paragraph UITextView that // plain is not hoisted into a UITextViewChild and is dropped (same // root cause as strong/em/s) — so on iOS a hard line break vanished, and // a softbreak between words jammed them together ("one\ntwo" -> "onetwo"). // Emit the break through MarkdownTextSpan so it composes on iOS. Keep // the resolved break styles: hardbreak is a full-width flex-row child on // Android, and dropping that width joins the surrounding text spans. hardbreak: ( node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {"\n"} ), softbreak: ( node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {"\n"} ), code_block: ( node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( ), fence: ( node: ASTNode, _children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( ), code_inline: ( node: ASTNode, _children: ReactNode[], parent: ASTNode[], styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link"); const inlineCodeSource: AssistantFileLinkSource = { href: content, text: content, sourceType: "inline-code", }; const shouldResolveInlinePath = !isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource); if (shouldResolveInlinePath) { return ( ); } const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content); if (inlineCodeLinkUrl) { const source = getInlineCodeAutoLinkSource({ href: inlineCodeLinkUrl, content, }); return ( {content} ); } return ( {content} ); }, bullet_list: ( node: ASTNode, children: ReactNode[], parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), ordered_list: ( node: ASTNode, children: ReactNode[], parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), list_item: ( node: ASTNode, children: ReactNode[], parent: ASTNode[], styles: MarkdownStyles, ) => { 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} ); }, th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {children} ), paragraph: ( node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles, ) => ( {children} ), link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {colorMarkdownLinkChildren(children, styles.link.color)} ), image: ( node: ASTNode, _children: ReactNode[], parent: ASTNode[], _styles: MarkdownStyles, ) => { const paragraphNode = Array.isArray(parent) ? parent.find((ancestor) => ancestor?.type === "paragraph") : null; const paragraphChildren = Array.isArray(paragraphNode?.children) ? paragraphNode.children : []; const imageIndex = paragraphChildren.findIndex((child: ASTNode) => child?.key === node.key); const hasLeadingContent = imageIndex > 0; return ( ); }, }; }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( () => blocks.map((block, index) => ({ key: `block:${index}`, block })), [blocks], ); const assistantContainerStyle = useMemo( () => [ assistantMessageStylesheet.container, (spacing === "compactTop" || spacing === "compactBoth") && assistantMessageStylesheet.containerCompactTop, (spacing === "compactBottom" || spacing === "compactBoth") && assistantMessageStylesheet.containerCompactBottom, ], [spacing], ); return ( {keyedBlocks.map(({ key, block }, index) => ( ))} ); }); interface SpeakMessageProps { message: string; timestamp: number; disableOuterSpacing?: boolean; } const speakMessageStylesheet = StyleSheet.create((theme) => ({ container: { paddingVertical: theme.spacing[3], }, containerSpacing: { marginBottom: theme.spacing[4], }, header: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginBottom: theme.spacing[2], }, headerLabel: { fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, text: { fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, lineHeight: 22, color: theme.colors.foreground, }, })); export const SpeakMessage = memo(function SpeakMessage({ message, timestamp: _timestamp, disableOuterSpacing, }: SpeakMessageProps) { const { t } = useTranslation(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const containerStyle = useMemo( () => [ speakMessageStylesheet.container, !resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing, ], [resolvedDisableOuterSpacing], ); return ( {t("message.speak.header")} {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: { 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: {}, 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, height: 20, justifyContent: "center", }, 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.code, fontFamily: theme.fontFamily.mono, lineHeight: 16, }, })); export const ActivityLog = memo(function ActivityLog({ type, message, timestamp: _timestamp, metadata, artifactId, artifactType, title, onArtifactClick, disableOuterSpacing, }: ActivityLogProps) { const { t } = useTranslation(); 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 = useCallback(() => { if (type === "artifact" && artifactId && onArtifactClick) { onArtifactClick(artifactId); } else if (metadata) { setIsExpanded((prev) => !prev); } }, [type, artifactId, onArtifactClick, metadata]); const displayMessage = type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message; const isInteractive = type === "artifact" || metadata; const pressableStyle = useMemo( () => [ activityLogStylesheet.pressable, !resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing, config.bg, isInteractive && activityLogStylesheet.pressableActive, ], [resolvedDisableOuterSpacing, config.bg, isInteractive], ); const messageTextStyle = useMemo( () => [activityLogStylesheet.messageText, { color: config.color }], [config.color], ); return ( {displayMessage} {metadata && ( {t("message.activity.details")} {isExpanded ? ( ) : ( )} )} {isExpanded && metadata && ( {JSON.stringify(metadata, null, 2)} )} ); }); interface CompactionMarkerProps { status: "loading" | "completed"; trigger?: "auto" | "manual"; 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: theme.fontFamily.ui, fontSize: 13, color: theme.colors.foregroundMuted, }, })); export const CompactionMarker = memo(function CompactionMarker({ status, trigger, preTokens, }: CompactionMarkerProps) { const label = getCompactionMarkerLabel({ status, trigger, preTokens }); return ( {status === "loading" ? ( ) : ( )} {label} ); }); interface TodoListCardProps { items: TodoEntry[]; activity: TaskActivity; disableOuterSpacing?: boolean; } function taskActivityIcon(activity: TaskActivity) { switch (activity.type) { case "added": return Plus; case "started": return CircleDot; case "completed": return Check; case "reopened": return RotateCcw; default: return CheckSquare; } } const todoListCardStylesheet = StyleSheet.create((theme) => ({ detailsWrapper: { padding: theme.spacing[2], }, list: { gap: theme.spacing[1], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, })); export const TodoListCard = memo(function TodoListCard({ items, activity, disableOuterSpacing, }: TodoListCardProps) { const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(false); const activityDisplay = useMemo(() => { if (activity.type === "created") { return { label: t("message.todo.activity.created", { count: activity.count }), secondaryLabel: undefined, }; } return { label: t(`message.todo.activity.${activity.type}`), secondaryLabel: activity.task, }; }, [activity, t]); const handleToggle = useCallback(() => { setIsExpanded((prev) => !prev); }, []); const renderDetails = useCallback(() => { return ( {items.length === 0 ? ( {t("message.todo.empty")} ) : ( items.map((item) => ) )} ); }, [items, t]); return ( ); }); interface ExpandableBadgeProps { label: string; secondaryLabel?: string; icon?: ComponentType<{ size?: number; color?: string }>; isExpanded: boolean; style?: StyleProp; onToggle?: () => void; onOpenFile?: () => void; onDetailHoverChange?: (hovered: boolean) => void; renderDetails?: () => ReactNode; isLoading?: boolean; isError?: boolean; isLastInSequence?: boolean; disableOuterSpacing?: boolean; borderlessWhenExpanded?: boolean; testID?: string; } interface ExpandableBadgeSecondaryLabelProps { secondaryLabel?: string; secondaryLabelStyle: StyleProp; shouldMeasureWebShimmer: boolean; onSecondaryLayout: (event: LayoutChangeEvent) => void; } function ExpandableBadgeSecondaryLabel({ secondaryLabel, secondaryLabelStyle, shouldMeasureWebShimmer, onSecondaryLayout, }: ExpandableBadgeSecondaryLabelProps) { if (!secondaryLabel) { return null; } return ( {secondaryLabel} ); } interface ExpandableBadgeWebShimmerOverlayProps { label: string; secondaryLabel?: string; shimmerLabelTextStyle: StyleProp; shimmerSecondaryTextStyle: StyleProp; showOpenFileButton: boolean; } function ExpandableBadgeWebShimmerOverlay({ label, secondaryLabel, shimmerLabelTextStyle, shimmerSecondaryTextStyle, showOpenFileButton, }: ExpandableBadgeWebShimmerOverlayProps) { return ( {label} {secondaryLabel ? ( {secondaryLabel} ) : null} {showOpenFileButton ? ( ) : null} {!secondaryLabel && !showOpenFileButton ? ( ) : null} ); } interface ExpandableBadgeLabelRowProps { label: string; labelStyle: StyleProp; secondaryLabel?: string; secondaryLabelStyle: StyleProp; shouldMeasureWebShimmer: boolean; shouldMeasureNativeShimmer: boolean; isWebShimmer: boolean; isNativeShimmer: boolean; shimmerLabelTextStyle: StyleProp; shimmerSecondaryTextStyle: StyleProp; labelRowWidth: number; labelRowHeight: number; nativeShimmerPeakWidth: number; shimmerDuration: number; nativeGradientId: string; onLabelRowLayout: (event: LayoutChangeEvent) => void; onLabelLayout: (event: LayoutChangeEvent) => void; onSecondaryLayout: (event: LayoutChangeEvent) => void; showOpenFileButton: boolean; isOpenFileHovered: boolean; onOpenFilePress: (event: GestureResponderEvent) => void; onOpenFileHoverIn: () => void; onOpenFileHoverOut: () => void; } function ExpandableBadgeLabelRow({ label, labelStyle, secondaryLabel, secondaryLabelStyle, shouldMeasureWebShimmer, shouldMeasureNativeShimmer, isWebShimmer, isNativeShimmer, shimmerLabelTextStyle, shimmerSecondaryTextStyle, labelRowWidth, labelRowHeight, nativeShimmerPeakWidth, shimmerDuration, nativeGradientId, onLabelRowLayout, onLabelLayout, onSecondaryLayout, showOpenFileButton, isOpenFileHovered, onOpenFilePress, onOpenFileHoverIn, onOpenFileHoverOut, }: ExpandableBadgeLabelRowProps) { const { t } = useTranslation(); return ( {label} {showOpenFileButton ? ( ) : null} {isWebShimmer ? ( ) : null} {isNativeShimmer ? ( ) : null} ); } // HACK: lucide ships every icon inside a 24×24 viewBox where the path // doesn't touch the edges — there's per-icon internal padding. The layout // already places the SVG element's box on the rail, but the visible glyph // inside the SVG sits inset by a few pixels (and the inset amount differs // per icon — chevron-right paints only in the right half of its viewBox, // regular tool icons paint roughly the full viewBox minus ~1 unit margin). // // Lucide has no viewBox knob, so the only way to nudge the visible glyph // flush with the rail is a per-icon negative margin. Cosmetic; not exact — // every lucide icon has slightly different padding and we're not measuring // each one. Two buckets is the compromise: // - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills // the viewBox); needs ~1px left shift. // - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of // viewBox, and we scale it 1.3×); needs ~4px left shift. // If we ever want this exact, the principled fix is a custom wrapper // with a tight viewBox per icon — see option (2) in the design discussion. const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 }; const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 }; function renderExpandableBadgeIcon({ isError, isActive, ThemedIcon, }: { isError: boolean; isActive: boolean; ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null; }): ReactNode { if (isError) { return ( ); } if (ThemedIcon) { return ( ); } return null; } function renderExpandableBadgeIconSlot({ showChevron, chevronStyle, iconNode, }: { showChevron: boolean; chevronStyle: StyleProp; iconNode: ReactNode; }): ReactNode { if (showChevron) { return ( ); } return iconNode; } function computeShimmerMetrics(input: { label: string; secondaryLabel: string | undefined; isLoading: boolean; labelRowWidth: number; labelRowHeight: number; labelOffsetX: number; labelWidth: number; secondaryOffsetX: number; secondaryWidth: number; }) { const totalShimmerChars = input.label.trim().length + (input.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, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0), ); const isWebShimmer = input.isLoading && isWeb; // React Native Web only observes a node when onLayout exists at mount. Keep // measuring while idle so a retained badge has dimensions when it starts loading. const shouldMeasureWebShimmer = isWeb; const shouldMeasureNativeShimmer = input.isLoading && isNative; const isNativeShimmer = shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0; const webShimmerSpanStartX = input.labelOffsetX; const webShimmerSpanEndX = input.secondaryLabel ? input.secondaryOffsetX + input.secondaryWidth : input.labelOffsetX + input.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; return { shimmerDuration, nativeShimmerPeakWidth, isWebShimmer, shouldMeasureWebShimmer, shouldMeasureNativeShimmer, isNativeShimmer, webShimmerPeakWidth, webShimmerTrackStart, webShimmerTrackEnd, }; } function useDetailWheelPropagationBlocker(input: { detailWrapperRef: React.RefObject; enabled: boolean; }): void { const { detailWrapperRef, enabled } = input; useEffect(() => { if (!enabled) { return () => {}; } const rawRef: unknown = detailWrapperRef.current; if (!(rawRef instanceof HTMLElement)) { return () => {}; } const node = rawRef; const stopWheelPropagation = (event: WheelEvent) => { if (shouldStopDetailWheelPropagation(node, event)) { event.stopPropagation(); } }; node.addEventListener("wheel", stopWheelPropagation, { passive: true }); return () => { node.removeEventListener("wheel", stopWheelPropagation); }; }, [detailWrapperRef, enabled]); } const SHIMMER_GRADIENT = "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%)"; function buildShimmerTextStyle(input: { isWebShimmer: boolean; webShimmerPeakWidth: number; shimmerDuration: number; webShimmerTrackStart: number; webShimmerTrackEnd: number; offsetX: number; }): object | null { if (!input.isWebShimmer) return null; return inlineUnistylesStyle({ opacity: 1, color: "transparent", backgroundImage: SHIMMER_GRADIENT, backgroundSize: `${input.webShimmerPeakWidth}px 100%`, backgroundRepeat: "no-repeat", backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`, "--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`, "--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`, }); } export const ExpandableBadge = memo(function ExpandableBadge({ label, style, secondaryLabel, icon, isExpanded, onToggle, onOpenFile, onDetailHoverChange, renderDetails, isLoading = false, isError = false, isLastInSequence = false, disableOuterSpacing, borderlessWhenExpanded = false, testID, }: ExpandableBadgeProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isHovered, setIsHovered] = useState(false); const [isOpenFileHovered, setIsOpenFileHovered] = 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 handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => { setIsHovered(false); setIsPressed(false); }, []); const handlePressIn = useCallback(() => setIsPressed(true), []); const handlePressOut = useCallback(() => setIsPressed(false), []); const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]); const handleDetailHoverOut = useCallback( () => onDetailHoverChange?.(false), [onDetailHoverChange], ); const handleOpenFilePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation?.(); onOpenFile?.(); }, [onOpenFile], ); const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []); const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []); 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 { shimmerDuration, nativeShimmerPeakWidth, isWebShimmer, shouldMeasureWebShimmer, shouldMeasureNativeShimmer, isNativeShimmer, webShimmerPeakWidth, webShimmerTrackStart, webShimmerTrackEnd, } = computeShimmerMetrics({ label, secondaryLabel, isLoading, labelRowWidth, labelRowHeight, labelOffsetX, labelWidth, secondaryOffsetX, secondaryWidth, }); 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]); useDetailWheelPropagationBlocker({ detailWrapperRef, enabled: !isNative && isExpanded && hasDetailContent, }); const shimmerLabelStyle = useMemo>( () => buildShimmerTextStyle({ isWebShimmer, webShimmerPeakWidth, shimmerDuration, webShimmerTrackStart, webShimmerTrackEnd, offsetX: labelOffsetX, }), [ isWebShimmer, webShimmerPeakWidth, shimmerDuration, webShimmerTrackStart, webShimmerTrackEnd, labelOffsetX, ], ); const shimmerSecondaryStyle = useMemo>( () => buildShimmerTextStyle({ isWebShimmer, webShimmerPeakWidth, shimmerDuration, webShimmerTrackStart, webShimmerTrackEnd, offsetX: secondaryOffsetX, }), [ isWebShimmer, webShimmerPeakWidth, shimmerDuration, webShimmerTrackStart, webShimmerTrackEnd, secondaryOffsetX, ], ); 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 && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached, ], [borderlessWhenExpanded, isExpanded, isInteractive, isPressed], ); const detailWrapperStyle = useMemo( () => [ expandableBadgeStylesheet.detailWrapper, borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless, ], [borderlessWhenExpanded], ); 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 chevronStyle = useMemo( () => [ expandableBadgeStylesheet.chevron, LUCIDE_CHEVRON_NUDGE_LEFT, inlineUnistylesStyle({ transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }], }), ], [isExpanded], ); const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]); const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon }); const iconSlotNode = renderExpandableBadgeIconSlot({ showChevron: isInteractive && (isHovered || isExpanded), chevronStyle, iconNode, }); const pressHandlers = isInteractive ? { onPress: onToggle, onPressIn: handlePressIn, onPressOut: handlePressOut, accessibilityRole: "button" as const, } : {}; return ( {iconSlotNode} {detailContent ? ( {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.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false; if (previous.testID !== next.testID) return false; if (previous.onToggle !== next.onToggle) return false; if (previous.onOpenFile !== next.onOpenFile) return false; if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false; if (previous.renderDetails !== next.renderDetails) return false; return true; } interface ToolCallProps { toolName: string; args?: unknown; result?: unknown; error?: unknown; status: "executing" | "running" | "completed" | "failed" | "canceled"; detail?: ToolCallDetail; cwd?: string; metadata?: Record; isLastInSequence?: boolean; disableOuterSpacing?: boolean; onInlineDetailsHoverChange?: (hovered: boolean) => void; onInlineDetailsExpandedChange?: (expanded: boolean) => void; onOpenFilePath?: (filePath: string) => void; defaultExpanded?: boolean; forceInline?: boolean; maxDetailHeight?: number; } export const ToolCall = memo(function ToolCall({ toolName, args, result, error, status, detail, cwd, metadata, isLastInSequence = false, disableOuterSpacing, onInlineDetailsHoverChange, onInlineDetailsExpandedChange, onOpenFilePath, defaultExpanded, forceInline = false, maxDetailHeight = 400, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false); const isMobile = useIsCompactFormFactor(); const shouldRenderInline = !isMobile || forceInline; 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 presentation = useMemo( () => buildToolCallPresentation({ toolName, status, error: error ?? null, detail: effectiveDetail, metadata, cwd, resolveIcon: resolveToolCallIcon, }), [toolName, status, error, effectiveDetail, metadata, cwd], ); const handleOpenFile = useMemo(() => { const openFilePath = presentation.openFilePath; if (!openFilePath || !onOpenFilePath) { return undefined; } return () => onOpenFilePath(openFilePath); }, [presentation.openFilePath, onOpenFilePath]); const handleToggle = useCallback(() => { if (!shouldRenderInline) { openToolCall({ displayName: presentation.displayName, summary: presentation.summary, detail: effectiveDetail, errorText: presentation.errorText, icon: presentation.icon, showLoadingSkeleton: presentation.isLoadingDetails, }); } else { setIsExpanded((prev) => !prev); } }, [ shouldRenderInline, openToolCall, presentation.displayName, presentation.summary, presentation.errorText, presentation.icon, presentation.isLoadingDetails, effectiveDetail, ]); useEffect(() => { if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) { return; } onInlineDetailsHoverChange(false); }, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]); useEffect(() => { if (!onInlineDetailsExpandedChange) { return; } if (!shouldRenderInline) { onInlineDetailsExpandedChange(false); return; } onInlineDetailsExpandedChange(isExpanded); }, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]); useEffect(() => { if (!onInlineDetailsExpandedChange) { return () => {}; } return () => { onInlineDetailsExpandedChange(false); }; }, [onInlineDetailsExpandedChange]); // Render inline details for desktop const renderDetails = useCallback(() => { if (!shouldRenderInline) return null; return ( ); }, [ shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails, maxDetailHeight, ]); if (presentation.isPlan && 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; if (previous.onOpenFilePath !== next.onOpenFilePath) return false; if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; if (previous.maxDetailHeight !== next.maxDetailHeight) return false; return true; }