import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator, } from "react-native"; import Markdown from "react-native-markdown-display"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import Animated, { FadeIn, FadeOut, cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, 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 { buildAgentStreamRenderModel, collectAssistantTurnContentForStreamRenderStrategy, getStreamNeighborItem, resolveStreamRenderStrategy, type AgentStreamRenderModel, type StreamSegmentRenderers, type StreamViewportHandle, } from "./agent-stream-render-strategy"; import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_OFFSETS, } from "@/utils/working-indicator"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; } export interface AgentStreamViewProps { agentId: string; serverId?: string; agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; onOpenWorkspaceFile?: (input: { filePath: string }) => void; } export const AgentStreamView = forwardRef(function AgentStreamView({ agentId, serverId, agent, streamItems, pendingPermissions, routeBottomAnchorRequest = null, isAuthoritativeHistoryReady = true, onOpenWorkspaceFile, }, ref) { const viewportRef = useRef(null); const { theme } = useUnistyles(); const router = useRouter(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const streamRenderStrategy = useMemo( () => resolveStreamRenderStrategy({ platform: Platform.OS, isMobileBreakpoint: isMobile, }), [isMobile] ); const [isNearBottom, setIsNearBottom] = useState(true); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); // 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 workspaceRoot = agent.cwd?.trim() || ""; const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot; const { requestDirectoryListing } = useFileExplorerActions({ serverId: resolvedServerId, workspaceId, workspaceRoot, }); // 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(() => { setIsNearBottom(true); setExpandedInlineToolCallIds(new Set()); }, [agentId]); const handleInlinePathPress = useCallback( (target: InlinePathTarget) => { if (!target.path) { return; } const normalized = normalizeInlinePathTarget(target.path, agent.cwd); if (!normalized) { return; } if (normalized.file) { if (onOpenWorkspaceFile) { onOpenWorkspaceFile({ filePath: normalized.file }); return; } const route = prepareWorkspaceTab({ serverId: resolvedServerId, workspaceId, target: { kind: "file", path: normalized.file }, }); router.navigate(route as any); return; } void requestDirectoryListing(normalized.directory, { recordHistory: false, setCurrentPath: false, }); setExplorerTabForCheckout({ serverId: resolvedServerId, cwd: agent.cwd, isGit: agent.projectPlacement?.checkout?.isGit ?? true, tab: "files", }); openFileExplorer(); }, [ agent.cwd, openFileExplorer, requestDirectoryListing, resolvedServerId, router, setExplorerTabForCheckout, onOpenWorkspaceFile, workspaceId, ] ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ tail: streamItems, head: streamHead ?? [], platform: Platform.OS === "web" ? "web" : "native", isMobileBreakpoint: isMobile, }); }, [isMobile, streamHead, streamItems]); useImperativeHandle(ref, () => ({ scrollToBottom(reason = "jump-to-bottom") { viewportRef.current?.scrollToBottom(reason); }, prepareForViewportChange() { viewportRef.current?.prepareForViewportChange(); }, }), []); function scrollToBottom() { viewportRef.current?.scrollToBottom("jump-to-bottom"); } const tightGap = theme.spacing[1]; // 4px const looseGap = theme.spacing[4]; // 16px const getGapBetween = useCallback( (item: StreamItem | null, belowItem: StreamItem | null) => { if (!item || !belowItem) { return 0; } if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return tightGap; } if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return tightGap; } if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { return looseGap; } if ( (item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem) ) { return tightGap; } if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { return tightGap; } if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { return tightGap; } return looseGap; }, [looseGap, tightGap] ); const renderStreamItemContent = useCallback( ( item: StreamItem, index: number, items: StreamItem[], seamAboveItem: StreamItem | null = null ) => { 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", }) ?? seamAboveItem ?? undefined; 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: StreamItem, index: number, items: StreamItem[], seamAboveItem: StreamItem | null = null ) => { const content = renderStreamItemContent(item, index, items, seamAboveItem); if (!content) { return null; } const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); const gapBelow = getGapBetween(item, nextItem ?? null); const isEndOfAssistantTurn = item.kind === "assistant_message" && (nextItem?.kind === "user_message" || (nextItem === undefined && agent.status !== "running")); const getTurnContent = () => collectAssistantTurnContentForStreamRenderStrategy({ strategy: streamRenderStrategy, items, startIndex: index, }); return ( {content} {isEndOfAssistantTurn ? ( ) : null} ); }, [ getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy, ] ); const pendingPermissionItems = useMemo( () => Array.from(pendingPermissions.values()).filter( (perm) => perm.agentId === agentId ), [pendingPermissions, agentId] ); const showWorkingIndicator = agent.status === "running"; const renderModel = useMemo(() => { const pendingPermissionsNode = pendingPermissionItems.length > 0 ? ( {pendingPermissionItems.map((permission) => ( ))} ) : null; const workingIndicatorNode = showWorkingIndicator ? ( ) : null; return { ...baseRenderModel, boundary: { ...baseRenderModel.boundary, historyToHeadGap: getGapBetween( baseRenderModel.history.at(-1) ?? null, baseRenderModel.segments.liveHead[0] ?? null ), }, auxiliary: { pendingPermissions: pendingPermissionsNode, workingIndicator: workingIndicatorNode, }, }; }, [ baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator, ]); const listEmptyComponent = useMemo(() => { if ( renderModel.boundary.hasVirtualizedHistory || renderModel.boundary.hasMountedHistory || renderModel.boundary.hasLiveHead || renderModel.auxiliary.pendingPermissions || renderModel.auxiliary.workingIndicator ) { return null; } return ( Start chatting with this agent... ); }, [renderModel]); const historyItems = renderModel.history; const liveHeadItems = renderModel.segments.liveHead; const { boundary, auxiliary } = renderModel; const lastHistoryItem = historyItems.at(-1) ?? null; const historyIndexById = useMemo(() => { const indexById = new Map(); historyItems.forEach((item, index) => { indexById.set(item.id, index); }); return indexById; }, [historyItems]); const renderHistoryRow = useCallback( (item: StreamItem) => { const historyIndex = historyIndexById.get(item.id); if (historyIndex === undefined) { return null; } return renderStreamItem(item, historyIndex, historyItems); }, [historyIndexById, historyItems, renderStreamItem] ); const renderHistoryVirtualizedRow = useCallback( (item) => renderHistoryRow(item), [renderHistoryRow] ); const renderHistoryMountedRow = useCallback( (item) => renderHistoryRow(item), [renderHistoryRow] ); const renderLiveHeadRow = useCallback( (item, index, items) => renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null), [lastHistoryItem, renderStreamItem] ); const renderLiveAuxiliary = useCallback( () => { if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) { return null; } return ( {auxiliary.pendingPermissions} {auxiliary.workingIndicator} ); }, [ auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap, ] ); const renderers = useMemo( () => ({ renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, }), [ renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, ] ); const streamScrollEnabled = !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || expandedInlineToolCallIds.size === 0; return ( {streamRenderStrategy.render({ agentId, segments: renderModel.segments, boundary, renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange: setIsNearBottom, scrollEnabled: streamScrollEnabled, listStyle: stylesheet.list, baseListContentContainerStyle: stylesheet.listContentContainer, forwardListContentContainerStyle: stylesheet.forwardListContentContainer, })} {!isNearBottom && ( )} ); }); function WorkingIndicator() { const progress = useSharedValue(0); useEffect(() => { progress.value = 0; progress.value = withRepeat( withTiming(1, { duration: WORKING_INDICATOR_CYCLE_MS, easing: Easing.linear, }), -1, false ); return () => { cancelAnimation(progress); progress.value = 0; }; }, [progress]); const translateDistance = -2; const dotOneStyle = useAnimatedStyle(() => { const strength = getWorkingIndicatorDotStrength( progress.value, WORKING_INDICATOR_OFFSETS[0] ); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * translateDistance }], }; }); const dotTwoStyle = useAnimatedStyle(() => { const strength = getWorkingIndicatorDotStrength( progress.value, WORKING_INDICATOR_OFFSETS[1] ); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * translateDistance }], }; }); const dotThreeStyle = useAnimatedStyle(() => { const strength = getWorkingIndicatorDotStrength( progress.value, WORKING_INDICATOR_OFFSETS[2] ); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * 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, }, }));