import { Fragment, createElement, forwardRef, isValidElement, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import type { ComponentType, ReactElement, ReactNode } from "react"; import { View, Text, Pressable, FlatList, ScrollView, ListRenderItemInfo, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, InteractionManager, Platform, ActivityIndicator, } from "react-native"; import Markdown from "react-native-markdown-display"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; import Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, } from "react-native-reanimated"; import { Check, ChevronDown, X } from "lucide-react-native"; import { usePanelStore } from "@/stores/panel-store"; import { AssistantMessage, UserMessage, ActivityLog, ToolCall, TodoListCard, CompactionMarker, TurnCopyButton, MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import type { DaemonClient } from "@server/client/daemon-client"; import { ToolCallDetailsContent } from "./tool-call-details"; import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, } from "./web-desktop-scrollbar"; import { collectAssistantTurnContentForStreamRenderStrategy, getStreamEdgeSlotProps, getStreamNeighborItem, isNearBottomForStreamRenderStrategy, orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, resolveStreamRenderStrategy, type StreamEdgeSlotProps, } from "./agent-stream-render-strategy"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { getMarkdownListMarker } from "@/utils/markdown-list"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; const AGENT_STREAM_LOG_TAG = "[AgentStreamView]"; const STREAM_ITEM_LOG_MIN_COUNT = 200; const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50; const NOOP_SEPARATORS: ListRenderItemInfo["separators"] = { highlight: () => {}, unhighlight: () => {}, updateProps: () => {}, }; function renderStreamEdgeComponent( component: ReactElement | ComponentType | null | undefined ): ReactNode { if (!component) { return null; } if (isValidElement(component)) { return component; } return createElement(component); } export interface AgentStreamViewHandle { scrollToBottom(): void; } export interface AgentStreamViewProps { agentId: string; serverId?: string; agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; } export const AgentStreamView = forwardRef(function AgentStreamView({ agentId, serverId, agent, streamItems, pendingPermissions, }, ref) { const flatListRef = useRef>(null); const scrollViewRef = useRef(null); const bottomAnchorRef = useRef(null); const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const streamRenderStrategy = useMemo( () => resolveStreamRenderStrategy({ platform: Platform.OS, isMobileBreakpoint: isMobile, }), [isMobile] ); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const insets = useSafeAreaInsets(); const [isNearBottom, setIsNearBottom] = useState(true); const hasScrolledInitially = useRef(false); const hasAutoScrolledOnce = useRef(false); const isNearBottomRef = useRef(true); const pendingAnchorRequestRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollAnimatedRef = useRef(false); const scrollOffsetYRef = useRef(0); const streamItemCountRef = useRef(0); const streamViewportMetricsRef = useRef({ contentHeight: 0, viewportHeight: 0, }); const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const streamRenderRefs = useMemo( () => ({ flatListRef, scrollViewRef, bottomAnchorRef }), [] ); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; const client = useSessionStore( (state) => state.sessions[resolvedServerId]?.client ?? null ); const streamHead = useSessionStore((state) => state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) ); const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } = useFileExplorerActions(resolvedServerId); // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200); const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200); useEffect(() => { hasScrolledInitially.current = false; hasAutoScrolledOnce.current = false; isNearBottomRef.current = true; pendingAnchorRequestRef.current = false; setExpandedInlineToolCallIds(new Set()); }, [agentId]); const handleInlinePathPress = useCallback( (target: InlinePathTarget) => { if (!target.path) { return; } const normalized = normalizeInlinePath(target.path, agent.cwd); if (!normalized) { return; } requestDirectoryListing(agentId, normalized.directory, { recordHistory: false, setCurrentPath: false, }); if (normalized.file) { selectExplorerEntry(agentId, normalized.file); requestFilePreview(agentId, normalized.file); } setExplorerTabForCheckout({ serverId: resolvedServerId, cwd: agent.cwd, isGit: agent.projectPlacement?.checkout?.isGit ?? true, tab: "files", }); openFileExplorer(); }, [ agent.cwd, agentId, requestDirectoryListing, requestFilePreview, selectExplorerEntry, setExplorerTabForCheckout, openFileExplorer, ] ); const updateNearBottom = useCallback((value: boolean) => { if (isNearBottomRef.current === value) return; isNearBottomRef.current = value; setIsNearBottom(value); }, []); const requestAnchorToBottom = useCallback(() => { pendingAnchorRequestRef.current = true; }, []); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; const previousContentHeight = streamViewportMetricsRef.current.contentHeight; scrollOffsetYRef.current = contentOffset.y; streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), viewportHeight: Math.max(0, layoutMeasurement.height), }; const offsetDelta = contentOffset.y - previousOffsetY; const contentHeightDelta = streamViewportMetricsRef.current.contentHeight - previousContentHeight; const threshold = Math.max(insets.bottom, 32); const nearBottom = isNearBottomForStreamRenderStrategy({ strategy: streamRenderStrategy, offsetY: contentOffset.y, threshold, contentHeight: streamViewportMetricsRef.current.contentHeight, viewportHeight: streamViewportMetricsRef.current.viewportHeight, }); const pendingAnchorBefore = pendingAnchorRequestRef.current; const shouldSuppressFalseNearBottom = pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) <= 1 && contentHeightDelta > 0; if (shouldSuppressFalseNearBottom) { updateNearBottom(true); } else { updateNearBottom(nearBottom); } const shouldClearPendingAnchor = pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1; if (shouldClearPendingAnchor) { pendingAnchorRequestRef.current = false; } if (showDesktopWebScrollbar) { streamScrollbarMetrics.onScroll(event); } }, [ insets.bottom, showDesktopWebScrollbar, streamRenderStrategy, streamScrollbarMetrics, updateNearBottom, ] ); const handleListLayout = useCallback( (event: LayoutChangeEvent) => { streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, viewportHeight: Math.max(0, event.nativeEvent.layout.height), }; if (showDesktopWebScrollbar) { streamScrollbarMetrics.onLayout(event); } }, [showDesktopWebScrollbar, streamScrollbarMetrics] ); const scrollToBottomInternal = useCallback( ({ animated }: { animated: boolean }) => { const targetOffset = streamRenderStrategy.getBottomOffset( streamViewportMetricsRef.current ); streamRenderStrategy.scrollToBottom({ refs: streamRenderRefs, metrics: streamViewportMetricsRef.current, animated, }); scrollOffsetYRef.current = targetOffset; updateNearBottom(true); }, [updateNearBottom, streamRenderRefs, streamRenderStrategy] ); useImperativeHandle(ref, () => ({ scrollToBottom() { requestAnchorToBottom(); }, }), [requestAnchorToBottom]); const handleContentSizeChange = useCallback( (width: number, height: number) => { const previousMetrics = streamViewportMetricsRef.current; const threshold = Math.max(insets.bottom, 32); const wasNearBottom = isNearBottomForStreamRenderStrategy({ strategy: streamRenderStrategy, offsetY: scrollOffsetYRef.current, threshold, contentHeight: previousMetrics.contentHeight, viewportHeight: previousMetrics.viewportHeight, }); streamViewportMetricsRef.current = { ...previousMetrics, contentHeight: Math.max(0, height), }; if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) { if (!hasAutoScrolledOnce.current) { scrollToBottomInternal({ animated: false }); hasAutoScrolledOnce.current = true; hasScrolledInitially.current = true; } else if ( wasNearBottom || isNearBottomRef.current || pendingAnchorRequestRef.current ) { scrollToBottomInternal({ animated: false }); } } if (showDesktopWebScrollbar) { streamScrollbarMetrics.onContentSizeChange(width, height); } }, [ insets.bottom, scrollToBottomInternal, showDesktopWebScrollbar, streamRenderStrategy, streamScrollbarMetrics, ] ); const scheduleAutoScroll = useCallback( ({ animated }: { animated: boolean }) => { pendingAutoScrollAnimatedRef.current = pendingAutoScrollAnimatedRef.current || animated; if (pendingAutoScrollFrameRef.current !== null) { return; } pendingAutoScrollFrameRef.current = requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; const shouldAnimate = pendingAutoScrollAnimatedRef.current; pendingAutoScrollAnimatedRef.current = false; scrollToBottomInternal({ animated: shouldAnimate }); }); }, [scrollToBottomInternal] ); useEffect(() => { return () => { if (pendingAutoScrollFrameRef.current !== null) { cancelAnimationFrame(pendingAutoScrollFrameRef.current); pendingAutoScrollFrameRef.current = null; } pendingAutoScrollAnimatedRef.current = false; }; }, []); useEffect(() => { if (streamItems.length === 0) { return; } if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) { // Forward streams anchor from measurement updates in handleContentSizeChange. return; } if (!hasAutoScrolledOnce.current) { const handle = InteractionManager.runAfterInteractions(() => { scrollToBottomInternal({ animated: false }); hasAutoScrolledOnce.current = true; hasScrolledInitially.current = true; }); return () => handle.cancel(); } if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) { return; } const shouldAnimate = hasScrolledInitially.current; scheduleAutoScroll({ animated: shouldAnimate }); hasScrolledInitially.current = true; }, [ scheduleAutoScroll, scrollToBottomInternal, streamItems, streamRenderStrategy, ]); function scrollToBottom() { const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom(); scrollToBottomInternal({ animated }); } const flatListData = useMemo(() => { return orderTailForStreamRenderStrategy({ strategy: streamRenderStrategy, streamItems, }); }, [streamItems, streamRenderStrategy]); const orderedStreamHead = useMemo(() => { return orderHeadForStreamRenderStrategy({ strategy: streamRenderStrategy, streamHead: streamHead ?? [], }); }, [streamHead, streamRenderStrategy]); const tightGap = theme.spacing[1]; // 4px const looseGap = theme.spacing[4]; // 16px const getGapBelow = useCallback( (item: StreamItem, index: number, items: StreamItem[]) => { const belowItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); if (!belowItem) { return 0; } // Same type groups get tight gap (4px) if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return tightGap; } if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return tightGap; } // Give user messages more breathing room before tool sequences. if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { return looseGap; } // Keep tool sequences visually connected to the preceding user/assistant message. if ( (item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem) ) { return tightGap; } // Keep todo lists visually connected to the following tool sequence (symmetry). if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { return tightGap; } // Keep tool sequences visually connected to the assistant response (symmetry). if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { return tightGap; } // Different types get loose gap (16px) return looseGap; }, [looseGap, streamRenderStrategy, tightGap] ); const renderStreamItemContent = useCallback( (item: StreamItem, index: number, items: StreamItem[]) => { const handleInlineDetailsExpandedChange = (expanded: boolean) => { if ( !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ) { return; } setExpandedInlineToolCallIds((previous) => { const next = new Set(previous); if (expanded) { next.add(item.id); } else { next.delete(item.id); } return next; }); }; switch (item.kind) { case "user_message": { const aboveItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "above", }); const belowItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); const isFirstInGroup = aboveItem?.kind !== "user_message"; const isLastInGroup = belowItem?.kind !== "user_message"; return ( ); } case "assistant_message": return ( ); case "thought": { const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; return ( ); } case "tool_call": { const { payload } = item; const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; if (payload.source === "agent") { const data = payload.data; return ( ); } const data = payload.data; return ( ); } case "activity_log": return ( ); case "todo_list": return ( ); case "compaction": return ( ); default: return null; } }, [handleInlinePathPress, agent.cwd, streamRenderStrategy] ); const renderStreamItem = useCallback( ({ item, index }: ListRenderItemInfo) => { const content = renderStreamItemContent(item, index, flatListData); if (!content) { return null; } const gapBelow = getGapBelow(item, index, flatListData); const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items: flatListData, index, relation: "below", }); const isEndOfAssistantTurn = item.kind === "assistant_message" && (nextItem?.kind === "user_message" || (nextItem === undefined && agent.status !== "running")); const getTurnContent = () => collectAssistantTurnContentForStreamRenderStrategy({ strategy: streamRenderStrategy, items: flatListData, startIndex: index, }); return ( {content} {isEndOfAssistantTurn ? ( ) : null} ); }, [ getGapBelow, renderStreamItemContent, flatListData, agent.status, streamRenderStrategy, ] ); const pendingPermissionItems = useMemo( () => Array.from(pendingPermissions.values()).filter( (perm) => perm.agentId === agentId ), [pendingPermissions, agentId] ); useEffect(() => { if (!isPerfLoggingEnabled()) { return; } const totalCount = streamItems.length; const prevCount = streamItemCountRef.current; if (totalCount === prevCount) { return; } const delta = Math.abs(totalCount - prevCount); streamItemCountRef.current = totalCount; if ( totalCount < STREAM_ITEM_LOG_MIN_COUNT && delta < STREAM_ITEM_LOG_DELTA_THRESHOLD ) { return; } let userCount = 0; let assistantCount = 0; let toolCallCount = 0; let thoughtCount = 0; let activityCount = 0; let todoCount = 0; for (const item of streamItems) { switch (item.kind) { case "user_message": userCount += 1; break; case "assistant_message": assistantCount += 1; break; case "tool_call": toolCallCount += 1; break; case "thought": thoughtCount += 1; break; case "activity_log": activityCount += 1; break; case "todo_list": todoCount += 1; break; default: break; } } const metrics = totalCount >= STREAM_ITEM_LOG_MIN_COUNT ? measurePayload(streamItems) : null; perfLog(AGENT_STREAM_LOG_TAG, { event: "stream_items", agentId, totalCount, userCount, assistantCount, toolCallCount, thoughtCount, activityCount, todoCount, pendingPermissionCount: pendingPermissionItems.length, streamHeadCount: streamHead?.length ?? 0, payloadApproxBytes: metrics?.approxBytes ?? 0, payloadFieldCount: metrics?.fieldCount ?? 0, }); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]); const showWorkingIndicator = agent.status === "running"; const showBottomBar = showWorkingIndicator; const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList(); const listEdgeSlotComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; const hasHeadItems = orderedStreamHead.length > 0; if (!hasPermissions && !showBottomBar && !hasHeadItems) { return null; } const leftContent = showWorkingIndicator ? : null; return ( {hasPermissions ? ( {pendingPermissionItems.map((permission) => ( ))} ) : null} {hasHeadItems ? orderedStreamHead.map((item, index) => { const rendered = renderStreamItemContent( item, index, orderedStreamHead ); return rendered ? ( {rendered} ) : null; }) : null} {showBottomBar ? {leftContent} : null} ); }, [ pendingPermissionItems, showWorkingIndicator, client, orderedStreamHead, renderStreamItemContent, showBottomBar, tightGap, ]); const flatListExtraData = useMemo( () => ({ pendingPermissionCount: pendingPermissionItems.length, showWorkingIndicator, showBottomBar, }), [ pendingPermissionItems.length, showWorkingIndicator, showBottomBar, ] ); const listEdgeSlotProps = useMemo(() => { if (!listEdgeSlotComponent) { return {}; } return getStreamEdgeSlotProps({ strategy: streamRenderStrategy, component: listEdgeSlotComponent, gapSize: tightGap, }); }, [listEdgeSlotComponent, streamRenderStrategy, tightGap]); const listEmptyComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; const hasHeadItems = orderedStreamHead.length > 0; if (hasPermissions || hasHeadItems) { return null; } const shouldShowWorking = agent.status === "running"; if (shouldShowWorking) { return ( Working… ); } return ( Start chatting with this agent... ); }, [ agent.status, pendingPermissionItems.length, orderedStreamHead, theme.colors.foregroundMuted, ]); const streamScrollEnabled = !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || expandedInlineToolCallIds.size === 0; const listContentContainerStyle = useMemo( () => usesVirtualizedList ? stylesheet.listContentContainer : [stylesheet.listContentContainer, stylesheet.forwardListContentContainer], [usesVirtualizedList] ); const headerEdgeContent = renderStreamEdgeComponent( listEdgeSlotProps.ListHeaderComponent ); const footerEdgeContent = renderStreamEdgeComponent( listEdgeSlotProps.ListFooterComponent ); const nonVirtualizedItems = useMemo(() => { if (flatListData.length === 0) { return null; } return flatListData.map((item, index) => { const rendered = renderStreamItem({ item, index, separators: NOOP_SEPARATORS, }); if (!rendered) { return null; } return {rendered}; }); }, [flatListData, renderStreamItem]); return ( {usesVirtualizedList ? ( item.id} testID="agent-chat-scroll" {...listEdgeSlotProps} contentContainerStyle={listContentContainerStyle} style={stylesheet.list} onLayout={handleListLayout} onScroll={handleScroll} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} ListEmptyComponent={listEmptyComponent} extraData={flatListExtraData} maintainVisibleContentPosition={ streamRenderStrategy.getMaintainVisibleContentPosition() } initialNumToRender={12} windowSize={10} scrollEnabled={streamScrollEnabled} showsVerticalScrollIndicator={!showDesktopWebScrollbar} inverted={streamRenderStrategy.getFlatListInverted()} /> ) : ( {headerEdgeContent ? ( {headerEdgeContent} ) : null} {nonVirtualizedItems} {flatListData.length === 0 ? listEmptyComponent : null} {footerEdgeContent ? ( {footerEdgeContent} ) : null} )} { streamRenderStrategy.scrollToOffset({ refs: streamRenderRefs, offset: nextOffset, animated: false, }); }} /> {/* Scroll to bottom button */} {!isNearBottom && ( )} ); }); function normalizeInlinePath( rawPath: string, cwd?: string ): { directory: string; file?: string } | null { if (!rawPath) { return null; } const normalizedInput = normalizePathInput(rawPath); if (!normalizedInput) { return null; } let normalized = normalizedInput; const cwdRelative = resolvePathAgainstCwd(normalized, cwd); if (cwdRelative) { normalized = cwdRelative; } if (normalized.startsWith("./")) { normalized = normalized.slice(2) || "."; } if (!normalized.length) { normalized = "."; } if (normalized === ".") { return { directory: "." }; } if (normalized.endsWith("/")) { const dir = normalized.replace(/\/+$/, ""); return { directory: dir.length > 0 ? dir : "." }; } const lastSlash = normalized.lastIndexOf("/"); const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : "."; return { directory: directory.length > 0 ? directory : ".", file: normalized, }; } function normalizePathInput(value: string | undefined): string | null { if (!value) { return null; } const trimmed = value .trim() .replace(/^['"`]/, "") .replace(/['"`]$/, ""); if (!trimmed) { return null; } return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); } function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null { const normalizedCwd = normalizePathInput(cwd); if ( !normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd) ) { return null; } const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/"; const comparePath = normalizePathForCompare(pathValue); const compareCwd = normalizePathForCompare(normalizedCwdBase); const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`; const comparePrefix = normalizePathForCompare(prefix); if (comparePath === compareCwd) { return "."; } if (comparePath.startsWith(comparePrefix)) { return pathValue.slice(prefix.length) || "."; } return null; } function normalizePathForCompare(value: string): string { return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value; } function isAbsolutePath(value: string): boolean { return value.startsWith("/") || /^[A-Za-z]:\//.test(value); } function WorkingIndicator() { const dotOne = useSharedValue(0); const dotTwo = useSharedValue(0); const dotThree = useSharedValue(0); const bounceDuration = 600; const bounceDelayOffset = 160; useEffect(() => { const sharedValues = [dotOne, dotTwo, dotThree]; sharedValues.forEach((value, index) => { value.value = withDelay( index * bounceDelayOffset, withRepeat( withSequence( withTiming(1, { duration: bounceDuration }), withTiming(0, { duration: bounceDuration }) ), -1 ) ); }); return () => { sharedValues.forEach((value) => { cancelAnimation(value); value.value = 0; }); }; }, [dotOne, dotTwo, dotThree]); const translateDistance = -2; const dotOneStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotOne.value * 0.7, transform: [{ translateY: dotOne.value * translateDistance }], })); const dotTwoStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotTwo.value * 0.7, transform: [{ translateY: dotTwo.value * translateDistance }], })); const dotThreeStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotThree.value * 0.7, transform: [{ translateY: dotThree.value * translateDistance }], })); return ( ); } // Permission Request Card Component function PermissionRequestCard({ permission, client, }: { permission: PendingPermission; client: DaemonClient | null; }) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const { request } = permission; const isPlanRequest = request.kind === "plan"; const title = isPlanRequest ? "Plan" : request.title ?? request.name ?? "Permission Required"; const description = request.description ?? ""; const planMarkdown = useMemo(() => { if (!request) { return undefined; } const planFromMetadata = typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined; if (planFromMetadata) { return planFromMetadata; } const candidate = request.input?.["plan"]; if (typeof candidate === "string") { return candidate; } return undefined; }, [request]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownRules = useMemo(() => { return { text: ( node: any, _children: React.ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), textgroup: ( node: any, children: React.ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {children} ), code_block: ( node: any, _children: React.ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), fence: ( node: any, _children: React.ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), code_inline: ( node: any, _children: React.ReactNode[], _parent: any, styles: any, inheritedStyles: any = {} ) => ( {node.content} ), bullet_list: ( node: any, children: React.ReactNode[], _parent: any, styles: any ) => ( {children} ), ordered_list: ( node: any, children: React.ReactNode[], _parent: any, styles: any ) => ( {children} ), list_item: ( node: any, children: React.ReactNode[], parent: any, styles: any ) => { const { isOrdered, marker } = getMarkdownListMarker(node, parent); const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( {marker} {children} ); }, }; }, []); const permissionMutation = useMutation({ mutationFn: async (input: { agentId: string; requestId: string; response: AgentPermissionResponse; }) => { if (!client) { throw new Error("Daemon client unavailable"); } return client.respondToPermissionAndWait( input.agentId, input.requestId, input.response, 15000 ); }, }); const { reset: resetPermissionMutation, mutateAsync: respondToPermission, isPending: isResponding, } = permissionMutation; const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null); useEffect(() => { resetPermissionMutation(); setRespondingAction(null); }, [permission.request.id, resetPermissionMutation]); const handleResponse = useCallback( (response: AgentPermissionResponse) => { respondToPermission({ agentId: permission.agentId, requestId: permission.request.id, response, }).catch((error) => { console.error( "[PermissionRequestCard] Failed to respond to permission:", error ); }); }, [permission.agentId, permission.request.id, respondToPermission] ); if (request.kind === "question") { return ( ); } return ( {title} {description ? ( {description} ) : null} {planMarkdown ? ( {!isPlanRequest ? ( Proposed plan ) : null} {planMarkdown} ) : null} {!isPlanRequest ? ( ) : null} How would you like to proceed? [ permissionStyles.optionButton, { backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed ? permissionStyles.optionButtonPressed : null, ]} onPress={() => { setRespondingAction("deny"); handleResponse({ behavior: "deny", message: "Denied by user", }); }} disabled={isResponding} > {respondingAction === "deny" ? ( ) : ( Deny )} [ permissionStyles.optionButton, { backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed ? permissionStyles.optionButtonPressed : null, ]} onPress={() => { setRespondingAction("accept"); handleResponse({ behavior: "allow" }); }} disabled={isResponding} > {respondingAction === "accept" ? ( ) : ( Accept )} ); } const stylesheet = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, contentWrapper: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, alignSelf: "center", }, listContentContainer: { paddingVertical: 0, flexGrow: 1, paddingHorizontal: { xs: theme.spacing[2], md: theme.spacing[4], }, }, forwardListContentContainer: { paddingTop: theme.spacing[4], paddingBottom: theme.spacing[4], }, list: { flex: 1, }, streamItemWrapper: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, alignSelf: "center", }, emptyState: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[12], }, permissionsContainer: { gap: theme.spacing[2], }, listHeaderContent: { gap: theme.spacing[3], }, bottomBarWrapper: { flexDirection: "row", alignItems: "center", justifyContent: "flex-start", paddingLeft: 3, paddingRight: 3, paddingTop: theme.spacing[3], paddingBottom: theme.spacing[2], gap: theme.spacing[2], }, workingIndicatorBubble: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: 0, paddingVertical: theme.spacing[1], paddingLeft: theme.spacing[2], borderRadius: theme.borderRadius.full, backgroundColor: "transparent", borderWidth: 0, alignSelf: "flex-start", }, workingDotsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, workingDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: theme.colors.foregroundMuted, }, syncingIndicator: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[1], paddingLeft: theme.spacing[2], }, syncingIndicatorText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, invertedWrapper: { transform: [{ scaleY: -1 }], width: "100%", }, emptyStateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, scrollToBottomContainer: { position: "absolute", bottom: 16, left: 0, right: 0, alignItems: "center", pointerEvents: "box-none", }, scrollToBottomInner: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, alignSelf: "center", alignItems: "center", }, scrollToBottomButton: { width: 48, height: 48, borderRadius: 24, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, }, scrollToBottomIcon: { color: theme.colors.foreground, }, })); const permissionStyles = StyleSheet.create((theme) => ({ container: { marginVertical: theme.spacing[3], padding: theme.spacing[3], borderRadius: theme.spacing[2], borderWidth: 1, gap: theme.spacing[2], }, title: { fontSize: theme.fontSize.base, lineHeight: 22, }, description: { fontSize: theme.fontSize.sm, lineHeight: 20, }, section: { gap: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.xs, }, question: { fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], marginBottom: theme.spacing[1], }, optionsContainer: { gap: theme.spacing[2], }, optionsContainerDesktop: { flexDirection: "row", justifyContent: "flex-start", alignItems: "center", width: "100%", }, optionButton: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, alignItems: "center", borderWidth: theme.borderWidth[1], }, optionButtonPressed: { opacity: 0.9, }, optionContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, }));