import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import { AttachmentFrame, AttachmentLabel, AttachmentThumbnail, } from "@/components/attachment-pill"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation"; import { TaskListRow } from "@/components/task-list-row"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import { setAssistantMessageHeight } from "@/utils/assistant-message-height-estimate"; import { isRenderProfileEnabled } from "@/utils/render-profiler"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import MaskedView from "@react-native-masked-view/masked-view"; import { Check, CheckSquare, ChevronRight, CircleDot, Copy, FileSymlink, Info, MicVocal, Plus, Scissors, TriangleAlertIcon, XCircle, } from "lucide-react-native"; import type { ComponentType, ReactNode } from "react"; import * as React from "react"; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, StyleProp, Text, View, ViewStyle, type GestureResponderEvent, type LayoutChangeEvent, type TextStyle, } from "react-native"; import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import Svg, { Defs, Rect, Stop, LinearGradient as SvgLinearGradient } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit"; import { getTurnDurationLabel } from "./assistant-turn-footer-label"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { PlanCard } from "./plan-card"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useToolCallSheet } from "./tool-call-sheet"; 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 ThemedMicVocal = withUnistyles(MicVocal); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedNotificationInfo = withUnistyles(Info); const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon); const ThemedNotificationError = withUnistyles(XCircle); 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 infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] }); const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); 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.sm (12) and theme.fontSize.base (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.content, ...(isWeb ? { lineHeight: Math.round(theme.fontSize.content * 1.4), 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 ( ); } const MESSAGE_TEXT_DATASET = { messageText: "true" }; 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 lightboxSource = useMemo( () => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null), [lightboxMetadata], ); 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 | null; 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. * Shows the turn duration and swaps to the end timestamp when both are known. * A turn without a visible start shows its end timestamp directly. */ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, onFork, }: AssistantTurnFooterProps) { const { t } = useTranslation(); 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 && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "", [durationMs, t], ); const timestampLabel = useMemo( () => (completedAt ? formatMessageTimestamp(completedAt) : ""), [completedAt], ); const primaryLabel = durationLabel || timestampLabel; const canSwap = Boolean(durationLabel && 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} {primaryLabel ? ( {/* Sizer reserves space for whichever label is longer so the container width is stable across hover transitions. */} {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} {showTimestamp ? timestampLabel : primaryLabel} ) : 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 { renderFullContent?: boolean; 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, }, cappedNotice: { marginTop: theme.spacing[3], fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, fontStyle: "italic", color: theme.colors.foregroundMuted, }, 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.base, textAlign: "center", }, })); 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 ( ); } export const AssistantMessage = memo(function AssistantMessage({ renderFullContent = false, occurrenceKey, message, timestamp: _timestamp, workspaceRoot, serverId, client, spacing = "default", phase, }: AssistantMessageProps) { const { t } = useTranslation(); const renderedMessage = useMemo( () => renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message), [message, renderFullContent], ); const revealedText = useRevealedText(renderedMessage.text, phase); const revealedMessage = renderFullContent ? renderedMessage.text : revealedText; const fullMessageByteLength = useMemo( () => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null), [message, phase, renderedMessage.capped], ); const assistantContainerStyle = useMemo( () => [ assistantMessageStylesheet.container, (spacing === "compactTop" || spacing === "compactBoth") && assistantMessageStylesheet.containerCompactTop, (spacing === "compactBottom" || spacing === "compactBoth") && assistantMessageStylesheet.containerCompactBottom, ], [spacing], ); const revealDataSet = useMemo( () => isRenderProfileEnabled() ? { ...MESSAGE_TEXT_DATASET, revealKey: occurrenceKey, revealLength: String(revealedMessage.length), } : MESSAGE_TEXT_DATASET, [occurrenceKey, revealedMessage.length], ); const measureMessage = useStableEvent((event: import("react-native").LayoutChangeEvent) => { setAssistantMessageHeight({ markdown: revealedMessage, ...event.nativeEvent.layout }); }); return ( {fullMessageByteLength !== null ? ( {t("agentStream.messageCapped", { bytes: fullMessageByteLength })} ) : null} ); }); 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.content, lineHeight: Math.round(theme.fontSize.content * 1.4), 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 NotificationProps { level: "info" | "warning" | "error"; message: string; disableOuterSpacing?: boolean; } const notificationStylesheet = StyleSheet.create((theme) => ({ container: { borderRadius: theme.borderRadius.md, overflow: "hidden", }, containerSpacing: { marginBottom: theme.spacing[1], }, infoBg: { backgroundColor: "rgba(147, 197, 253, 0.1)", }, warningBg: { backgroundColor: "rgba(245, 158, 11, 0.1)", }, errorBg: { backgroundColor: "rgba(239, 68, 68, 0.1)", }, 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: { color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 20, }, })); export const Notification = memo(function Notification({ level, message, disableOuterSpacing, }: NotificationProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const typeConfig = { info: { bg: notificationStylesheet.infoBg, iconColorMapping: infoColorMapping, Icon: ThemedNotificationInfo, }, warning: { bg: notificationStylesheet.warningBg, iconColorMapping: warningColorMapping, Icon: ThemedNotificationWarning, }, error: { bg: notificationStylesheet.errorBg, iconColorMapping: destructiveColorMapping, Icon: ThemedNotificationError, }, }; const config = typeConfig[level]; const IconComponent = config.Icon; const containerStyle = useMemo( () => [ notificationStylesheet.container, !resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing, config.bg, ], [resolvedDisableOuterSpacing, config.bg], ); return ( {message} ); }); 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; 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 { agentMessage?: AgentMessage; 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({ agentMessage, 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({ agentMessage, toolName, status, error: error ?? null, detail: effectiveDetail, metadata, cwd, resolveIcon: resolveToolCallIcon, }), [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage], ); const handleOpenFile = useMemo(() => { const openFilePath = presentation.openFilePath; if (!openFilePath || !onOpenFilePath) { return undefined; } return () => onOpenFilePath(openFilePath); }, [presentation.openFilePath, onOpenFilePath]); const handleToggle = useCallback(() => { if (!shouldRenderInline) { openToolCall({ toolName, displayName: presentation.displayName, summary: presentation.summary, detail: presentation.detail, errorText: presentation.errorText, icon: presentation.icon, showLoadingSkeleton: presentation.isLoadingDetails, }); } else { setIsExpanded((prev) => !prev); } }, [ shouldRenderInline, openToolCall, toolName, presentation.detail, presentation.displayName, presentation.summary, presentation.errorText, presentation.icon, presentation.isLoadingDetails, ]); 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, toolName, presentation.detail, 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; }