import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ComponentProps, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable, Platform, type PressableStateCallbackType, type StyleProp, type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, ChevronDown, X } from "lucide-react-native"; import { usePanelStore } from "@/stores/panel-store"; import { AssistantMessage, SpeakMessage, UserMessage, ActivityLog, ToolCall, TodoListCard, CompactionMarker, MessageOuterSpacingProvider, type InlinePathTarget, } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; import type { PendingMessageSubmission } from "@/composer/submission/model"; import type { TurnPresentation } from "@/timeline/turn-liveness"; import type { PendingPermission } from "@/types/shared"; import type { AgentCapabilityFlags, AgentPermissionAction, AgentPermissionResponse, } from "@getpaseo/protocol/agent-types"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; import { useSettings } from "@/hooks/use-settings"; import type { ToastApi } from "@/components/toast-host"; import { returnToTimelineTail } from "./timeline-tail-navigation"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; import { prepareToolCallHistory, projectToolCallDetailLevel, } from "@/tool-calls/detail-level/projection"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail"; import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { CompletedTurnFooterRow, TurnFooter, type AssistantTurnForkHandler, type InFlightTurnForkHandler, type TurnContentStrategy, } from "./turn-footer"; import { layoutStream, type StreamLayoutItem } from "./layout"; import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./bottom-anchor-controller"; import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; import { AssistantFileLinkResolverProvider, normalizeInlinePathTarget, } from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useForkAgent } from "@/hooks/use-fork-agent"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { useRetainedPanelActive } from "@/components/retained-panel"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; turnFooter: ReactNode; }): ReactNode { if (!input.pendingPermissions && !input.turnFooter) { return null; } return ( <> {input.turnFooter} {input.pendingPermissions ? ( {input.pendingPermissions} ) : null} ); } function renderPendingPermissionsNode(input: { pendingPermissions: PendingPermission[]; client: DaemonClient | null; }): ReactNode { if (input.pendingPermissions.length === 0) { return null; } return ( {input.pendingPermissions.map((permission) => ( ))} ); } function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { return null; } const footerHost = input.layoutItem.completedFooter; const footer = footerHost ? ( ) : null; const content = ( {input.content} ); if (input.layoutItem.frameOrder === "footer-then-content") { return ( <> {footer} {content} ); } return ( <> {content} {footer} ); } function renderListEmptyComponent(input: { renderModel: AgentStreamRenderModel; emptyStateStyle: StyleProp; emptyText: string; }): ReactNode { if ( input.renderModel.boundary.hasVirtualizedHistory || input.renderModel.boundary.hasMountedHistory || input.renderModel.boundary.hasLiveHead || input.renderModel.auxiliary.pendingPermissions || input.renderModel.auxiliary.turnFooter ) { return null; } return ( {input.emptyText} ); } function renderHistoryStreamItem(input: { item: StreamItem; layoutItemById: Map; renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; }): ReactNode { const layoutItem = input.layoutItemById.get(input.item.id); if (!layoutItem) { return null; } return input.renderStreamItem(layoutItem); } function renderLiveHeadStreamItem(input: { item: StreamItem; layoutItemById: Map; renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; }): ReactNode { const layoutItem = input.layoutItemById.get(input.item.id); if (!layoutItem) { return null; } return input.renderStreamItem(layoutItem); } export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; } export interface AgentStreamViewProps { agentId: string; serverId?: string; context: AgentScreenAgent; streamItems: StreamItem[]; streamHead?: StreamItem[]; pendingPermissions: Map; pendingMessageSubmissions?: readonly PendingMessageSubmission[]; turnPresentation: TurnPresentation; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; readOnly?: boolean; historyPagination?: { hasOlder: boolean; isLoadingOlder: boolean; progressKey: string | null; onLoadOlder: () => boolean | Promise; }; } const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ "supportsStreaming", "supportsSessionPersistence", "supportsDynamicModes", "supportsMcpServers", "supportsReasoningStream", "supportsToolInvocations", "supportsRewindConversation", "supportsRewindFiles", "supportsRewindBoth", ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; function useRetainedValue(value: T, active: boolean): T { const retainedRef = useRef(value); if (active) { retainedRef.current = value; } return active ? value : retainedRef.current; } const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; const AgentStreamViewComponent = forwardRef( function AgentStreamView( { agentId, serverId, context, streamItems, streamHead: providedStreamHead, pendingPermissions, pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS, turnPresentation, routeBottomAnchorRequest = null, isAuthoritativeHistoryReady = true, toast, onOpenWorkspaceFile, readOnly = false, historyPagination, }, ref, ) { const { t } = useTranslation(); const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled); const viewportRef = useRef(null); const pendingClientMessageIds = useMemo( () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), [pendingMessageSubmissions], ); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => resolveStreamRenderStrategy({ platform: Platform.OS, isMobileBreakpoint: isMobile, }), [isMobile], ); const [isNearBottom, setIsNearBottom] = useState(true); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>( new Set(), ); const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState>( new Set(), ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? context.serverId ?? ""; const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null); const sessionStreamHead = useSessionStore((state) => state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), ); const streamHead = providedStreamHead ?? sessionStreamHead; const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly }); const supportsAgentForkContextCursor = useSessionStore( (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, ); const supportsChatOutline = useSessionStore( (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true, ); const timelineEpoch = useSessionStore( (state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null, ); const isTimelineDetached = useSessionStore( (state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true, ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ serverId: resolvedServerId, workspaceId: context.workspaceId, workspaceRoot, }); const agentHistoryPagination = useLoadOlderAgentHistory({ serverId: resolvedServerId, agentId, toast, }); const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination ? { isLoadingOlder: historyPagination.isLoadingOlder, hasOlder: historyPagination.hasOlder, progressKey: historyPagination.progressKey, loadOlder: historyPagination.onLoadOlder, } : agentHistoryPagination; // 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()); setExpandedToolCallGroupIds(new Set()); }, [agentId]); const handleInlinePathPress = useStableEvent( (target: InlinePathTarget, disposition: OpenFileDisposition) => { if (!target.path) { return; } const normalized = normalizeInlinePathTarget(target.path, context.cwd); if (!normalized) { return; } if (normalized.file) { const location = normalizeWorkspaceFileLocation({ path: normalized.file, lineStart: target.lineStart, lineEnd: target.lineEnd, }); if (!location) { return; } if (onOpenWorkspaceFile) { onOpenWorkspaceFile({ location, disposition, }); return; } if (context.workspaceId) { navigateToWorkspace({ serverId: resolvedServerId, workspaceId: context.workspaceId, target: createWorkspaceFileTabTarget(location), }); } return; } void requestDirectoryListing(normalized.directory, { recordHistory: false, setCurrentPath: false, }); const checkout = { serverId: resolvedServerId, cwd: context.cwd, isGit: context.projectPlacement?.checkout?.isGit ?? true, }; setExplorerTabForCheckout({ ...checkout, tab: "files" }); openFileExplorerForCheckout({ isCompact: isMobile, checkout, }); }, ); const handleToolCallOpenFile = useStableEvent((filePath: string) => { handleInlinePathPress({ raw: filePath, path: filePath }, "main"); }); const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent( async ({ target, boundary }) => { await forkAgent({ agentId, agent: context, workspaceId: context.workspaceId, target, boundary, }); }, ); // The in-flight turn forks with no boundary at all: `selectForkContextRows` // projects the whole timeline when neither boundary field is given, so the // fork carries everything up to now, including the response still streaming // in front of the user. const handleForkInFlightTurn: InFlightTurnForkHandler = useStableEvent(async (target) => { await forkAgent({ agentId, agent: context, workspaceId: context.workspaceId, target, }); }); // Freeze stream presentation while this tab slot is hidden to prevent offscreen // cell-window and turn-lifecycle renders from background agents. // When isActive flips back to true, the context change triggers a re-render and // the component reads the current (fresh) streamItems/streamHead from props. const isActive = useRetainedPanelActive(); const effectiveStreamItems = useRetainedValue(streamItems, isActive); const effectiveStreamHead = useRetainedValue(streamHead, isActive); const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive); const isTurnActive = effectiveTurnPresentation.isActive; // Keep retained history outside the 48ms live-head flush path. const preparedToolCallHistory = useMemo( () => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), [effectiveStreamItems, toolCallDetailLevel], ); const projectedToolCalls = useMemo( () => projectToolCallDetailLevel({ level: toolCallDetailLevel, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, preparedHistory: preparedToolCallHistory, isTurnActive, }), [ effectiveStreamHead, effectiveStreamItems, isTurnActive, preparedToolCallHistory, toolCallDetailLevel, ], ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ isTurnActive, activeTurnStartedAt: effectiveTurnPresentation.startedAt, tail: projectedToolCalls.tail, head: projectedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); }, [ isMobile, isTurnActive, projectedToolCalls.head, projectedToolCalls.tail, effectiveTurnPresentation.startedAt, ]); const streamLayout = useMemo( () => layoutStream({ strategy: streamRenderStrategy, isTurnActive, history: baseRenderModel.history, liveHead: baseRenderModel.segments.liveHead, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, }), [ baseRenderModel.history, baseRenderModel.segments.liveHead, baseRenderModel.turnTiming.byAssistantId, isTurnActive, streamRenderStrategy, ], ); const handleTimelineHistoryLoadError = useCallback(() => { toast?.error(t("agentStream.historyLoadFailed")); }, [t, toast]); const chatOutline = useChatOutline({ agentId, serverId: resolvedServerId, timelineEpoch, tail: effectiveStreamItems, head: effectiveStreamHead, enabled: supportsChatOutline && chatOutlineEnabled, viewportRef, onJumpError: handleTimelineHistoryLoadError, }); useImperativeHandle( ref, () => ({ scrollToBottom(reason = "jump-to-bottom") { viewportRef.current?.scrollToBottom(reason); }, prepareForViewportChange() { viewportRef.current?.prepareForViewportChange(); }, }), [], ); const scrollToBottom = useCallback(() => { if (!isTimelineDetached) { viewportRef.current?.scrollToBottom("jump-to-bottom"); return; } void returnToTimelineTail({ fetchTail: () => getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, { ...planTimelineTailFetch(), }), scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"), onError: handleTimelineHistoryLoadError, }); }, [agentId, handleTimelineHistoryLoadError, isTimelineDetached, resolvedServerId]); const setInlineDetailsExpanded = useCallback( (itemId: string, expanded: boolean) => { if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) { return; } setExpandedInlineToolCallIds((previous) => { const next = new Set(previous); if (expanded) { next.add(itemId); } else { next.delete(itemId); } return next; }); }, [streamRenderStrategy], ); const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => { setExpandedToolCallGroupIds((previous) => { const next = new Set(previous); if (expanded) { next.add(groupId); } else { next.delete(groupId); } return next; }); }, []); const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( ); }, [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( ); }, [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( ); }, [autoExpandReasoning, setInlineDetailsExpanded], ); const renderSingleToolCallItem = useCallback( ( item: Extract, isLastInSequence: boolean, maxDetailHeight?: number, ) => { const { payload } = item; if (payload.source === "agent") { const data = payload.data; if ( data.name === "speak" && data.detail.type === "unknown" && typeof data.detail.input === "string" && data.detail.input.trim() ) { return ( ); } return ( ); } const data = payload.data; return ( ); }, [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], ); const renderToolCallItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { const group = projectedToolCalls.groupsByHostId.get(item.id); if (!group) { return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } const expanded = expandedToolCallGroupIds.has(group.run.id); return ( {expanded ? group.run.calls.map((call, index) => ( {renderSingleToolCallItem( call, index === group.run.calls.length - 1, GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, )} )) : null} ); }, [ projectedToolCalls.groupsByHostId, expandedToolCallGroupIds, renderSingleToolCallItem, setToolCallGroupExpanded, ], ); const renderStreamItemContent = useCallback( (layoutItem: StreamLayoutItem) => { const item = layoutItem.item; switch (item.kind) { case "user_message": return renderUserMessageItem(layoutItem, item); case "assistant_message": return renderAssistantMessageItem(layoutItem, item); case "thought": return renderThoughtItem(layoutItem, item); case "tool_call": return renderToolCallItem(layoutItem, item); case "activity_log": return ( ); case "todo_list": return ; case "compaction": return ( ); default: return null; } }, [renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem], ); const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter; const renderStreamItem = useCallback( (layoutItem: StreamLayoutItem) => { const content = renderStreamItemContent(layoutItem); return renderStreamItemWithTurnFooter({ content, layoutItem, strategy: streamRenderStrategy, supportsTimelineCursor: supportsAgentForkContextCursor, onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn, }); }, [ handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy, supportsAgentForkContextCursor, ], ); const pendingPermissionItems = useMemo( () => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId), [pendingPermissions, agentId], ); const pendingPermissionsNode = useMemo( () => renderPendingPermissionsNode({ pendingPermissions: pendingPermissionItems, client, }), [client, pendingPermissionItems], ); const turnFooterNode = useMemo( () => isTurnActive || bottomTurnFooterHost ? ( ) : null, [ handleForkAssistantTurn, handleForkInFlightTurn, readOnly, isTurnActive, baseRenderModel.turnTiming.runningStartedAt, bottomTurnFooterHost, streamRenderStrategy, supportsAgentForkContextCursor, ], ); const renderModel = useMemo(() => { return { ...baseRenderModel, boundary: baseRenderModel.boundary, auxiliary: { pendingPermissions: pendingPermissionsNode, turnFooter: turnFooterNode, }, }; }, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const listEmptyComponent = useMemo( () => renderListEmptyComponent({ renderModel, emptyStateStyle, emptyText: t("agentStream.empty"), }), [renderModel, emptyStateStyle, t], ); const { boundary, auxiliary } = renderModel; const layoutHistoryItemById = useMemo(() => { const itemById = new Map(); for (const item of streamLayout.history) { itemById.set(item.item.id, item); } return itemById; }, [streamLayout.history]); const layoutLiveHeadItemById = useMemo(() => { const itemById = new Map(); for (const item of streamLayout.liveHead) { itemById.set(item.item.id, item); } return itemById; }, [streamLayout.liveHead]); const renderHistoryRow = useCallback( (item: StreamItem) => renderHistoryStreamItem({ item, layoutItemById: layoutHistoryItemById, renderStreamItem, }), [layoutHistoryItemById, renderStreamItem], ); const renderHistoryVirtualizedRow = useCallback< StreamSegmentRenderers["renderHistoryVirtualizedRow"] >((item) => renderHistoryRow(item), [renderHistoryRow]); const renderHistoryMountedRow = useCallback( (item) => renderHistoryRow(item), [renderHistoryRow], ); // useStableEvent keeps the function reference stable across flushes. // layoutLiveHeadItemById and renderStreamItem are read from the ref at call time, // so the live-head render always uses the latest layout without causing renderers // to be a new object on every text-chunk flush. const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent( (item: StreamItem) => renderLiveHeadStreamItem({ item, layoutItemById: layoutLiveHeadItemById, renderStreamItem, }), ); const renderLiveAuxiliary = useCallback(() => { return renderLiveAuxiliaryNode({ pendingPermissions: auxiliary.pendingPermissions, turnFooter: auxiliary.turnFooter, }); }, [auxiliary.pendingPermissions, auxiliary.turnFooter]); const renderers = useMemo( () => ({ renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, }), [ renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, ], ); const streamScrollEnabled = !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || expandedInlineToolCallIds.size === 0; const historyRowRevision = useMemo( () => ({ contentById: projectedToolCalls.historyGroupUpdatesByHostId, displayStateById: expandedToolCallGroupIds, globalDisplayState: isMobile, }), [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], ); return ( {streamRenderStrategy.render({ agentId, segments: renderModel.segments, historyRowRevision, liveHeadRowRevision: expandedToolCallGroupIds, boundary, renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange: setIsNearBottom, onReadingPositionChange: chatOutline.reportReadingPosition, onNearHistoryStart: loadOlder, isLoadingOlderHistory: isLoadingOlder, hasOlderHistory: hasOlder, olderHistoryProgressKey: progressKey, scrollEnabled: streamScrollEnabled, listStyle: stylesheet.list, baseListContentContainerStyle: stylesheet.listContentContainer, forwardListContentContainerStyle: stylesheet.forwardListContentContainer, })} {(!isNearBottom || isTimelineDetached) && ( )} ); }, ); function agentCapabilityFlagsEqual( left: AgentCapabilityFlags | undefined, right: AgentCapabilityFlags | undefined, ): boolean { return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]); } function collectAgentProjectPlacementDiffs( left: AgentScreenAgent["projectPlacement"], right: AgentScreenAgent["projectPlacement"], ): string[] { const reasons: string[] = []; if (left?.checkout?.cwd !== right?.checkout?.cwd) { reasons.push("agent.projectPlacement.checkout.cwd"); } if (left?.checkout?.isGit !== right?.checkout?.isGit) { reasons.push("agent.projectPlacement.checkout.isGit"); } if (left?.projectName !== right?.projectName) { reasons.push("agent.projectPlacement.projectName"); } if (left?.projectKey !== right?.projectKey) { reasons.push("agent.projectPlacement.projectKey"); } return reasons; } function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { const reasons: string[] = []; if (left.provider !== right.provider) reasons.push("agent.provider"); if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId"); if (left.model !== right.model) reasons.push("agent.model"); if (left.thinkingOptionId !== right.thinkingOptionId) { reasons.push("agent.thinkingOptionId"); } if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) { reasons.push("agent.runtimeInfo.modeId"); } if (left.runtimeInfo?.model !== right.runtimeInfo?.model) { reasons.push("agent.runtimeInfo.model"); } if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { reasons.push("agent.runtimeInfo.thinkingOptionId"); } if (left.features !== right.features) reasons.push("agent.features"); return reasons; } function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { const reasons: string[] = []; if (left.serverId !== right.serverId) reasons.push("agent.serverId"); if (left.id !== right.id) reasons.push("agent.id"); if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId"); if (left.status !== right.status) reasons.push("agent.status"); if (left.cwd !== right.cwd) reasons.push("agent.cwd"); if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) { reasons.push("agent.capabilities"); } if (left.lastError !== right.lastError) reasons.push("agent.lastError"); reasons.push(...collectAgentSetupDiffs(left, right)); reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement)); return reasons; } function bottomAnchorRouteRequestsEqual( left: BottomAnchorRouteRequest | null | undefined, right: BottomAnchorRouteRequest | null | undefined, ): boolean { return ( left?.agentId === right?.agentId && left?.reason === right?.reason && left?.requestKey === right?.requestKey ); } function historyPaginationPropsEqual( left: AgentStreamViewProps["historyPagination"], right: AgentStreamViewProps["historyPagination"], ): boolean { return ( left?.hasOlder === right?.hasOlder && left?.isLoadingOlder === right?.isLoadingOlder && left?.progressKey === right?.progressKey && left?.onLoadOlder === right?.onLoadOlder ); } function agentStreamViewPropsEqual( left: AgentStreamViewProps, right: AgentStreamViewProps, ): boolean { const reasons: string[] = []; if (left.agentId !== right.agentId) reasons.push("agentId"); if (left.serverId !== right.serverId) reasons.push("serverId"); reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context)); if (left.streamItems !== right.streamItems) reasons.push("streamItems"); if (left.streamHead !== right.streamHead) reasons.push("streamHead"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) { reasons.push("pendingMessageSubmissions"); } if (left.turnPresentation !== right.turnPresentation) reasons.push("turnPresentation"); if ( !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) ) { reasons.push("routeBottomAnchorRequest"); } if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) { reasons.push("isAuthoritativeHistoryReady"); } if (left.toast !== right.toast) reasons.push("toast"); if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); if (left.readOnly !== right.readOnly) reasons.push("readOnly"); if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) { reasons.push("historyPagination"); } recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons); return reasons.length === 0; } export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual); AgentStreamView.displayName = "AgentStreamView"; interface ToolCallSlotProps extends Omit< ComponentProps, "onInlineDetailsExpandedChange" > { itemId: string; onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void; } function ToolCallSlot({ itemId, onInlineDetailsExpandedChangeByItemId, ...rest }: ToolCallSlotProps) { const handleExpandedChange = useCallback( (expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded), [onInlineDetailsExpandedChangeByItemId, itemId], ); return ; } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedCheckIcon = withUnistyles(Check); const ThemedXIcon = withUnistyles(X); const primaryColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const pressableStyle = ({ pressed, hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) => [ permissionStyles.optionButton, hovered ? permissionStyles.optionButtonHovered : null, pressed ? permissionStyles.optionButtonPressed : null, ]; interface PermissionActionButtonProps { action: AgentPermissionAction; isRespondingAction: boolean; isResponding: boolean; isPrimary: boolean; Icon: typeof ThemedCheckIcon; testID: string; onPress: (action: AgentPermissionAction) => void; } function PermissionActionButton({ action, isRespondingAction, isResponding, isPrimary, Icon, testID, onPress, }: PermissionActionButtonProps) { const handlePress = useCallback(() => onPress(action), [onPress, action]); const optionTextStyle = isPrimary ? [permissionStyles.optionText, permissionStyles.optionTextPrimary] : permissionStyles.optionText; const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping; return ( {isRespondingAction ? ( ) : ( {action.label} )} ); } function PermissionRequestCard({ permission, client, }: { permission: PendingPermission; client: DaemonClient | null; }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const { request } = permission; const isPlanRequest = request.kind === "plan"; const title = isPlanRequest ? t("agentStream.permission.plan") : (request.title ?? request.name ?? t("agentStream.permission.required")); const description = request.description ?? ""; const resolvedToolCallDetail = useMemo( () => request.detail ?? { type: "unknown" as const, input: request.input ?? null, output: null, }, [request.detail, request.input], ); const resolvedActions = useMemo((): AgentPermissionAction[] => { if (request.kind === "question") { return []; } if (Array.isArray(request.actions) && request.actions.length > 0) { return request.actions; } return [ { id: "reject", label: t("agentStream.permission.deny"), behavior: "deny", variant: "danger", intent: "dismiss", }, { id: "accept", label: isPlanRequest ? t("agentStream.permission.implement") : t("agentStream.permission.accept"), behavior: "allow", variant: "primary", }, ]; }, [isPlanRequest, request, t]); 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 permissionMutation = useMutation({ mutationFn: async (input: { agentId: string; requestId: string; response: AgentPermissionResponse; }) => { if (!client) { throw new Error(t("common.errors.daemonClientUnavailable")); } return client.respondToPermissionAndWait( input.agentId, input.requestId, input.response, 15000, ); }, }); const { reset: resetPermissionMutation, mutateAsync: respondToPermission, isPending: isResponding, } = permissionMutation; const [respondingActionId, setRespondingActionId] = useState(null); useEffect(() => { resetPermissionMutation(); setRespondingActionId(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], ); const handleActionPress = useCallback( (action: AgentPermissionAction) => { setRespondingActionId(action.id); if (action.behavior === "allow") { handleResponse({ behavior: "allow", selectedActionId: action.id, }); return; } handleResponse({ behavior: "deny", selectedActionId: action.id, message: "Denied by user", }); }, [handleResponse], ); const optionsContainerStyle = useMemo( () => [ permissionStyles.optionsContainer, !isMobile && permissionStyles.optionsContainerDesktop, ], [isMobile], ); if (request.kind === "question") { return ( ); } const footer = ( <> {t("agentStream.permission.question")} {resolvedActions.map((action) => { const isPrimary = action.variant === "primary"; const isRespondingAction = respondingActionId === action.id; const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon; let testID: string; if (action.behavior === "deny") testID = "permission-request-deny"; else if (action.id === "accept" || action.id === "implement") testID = "permission-request-accept"; else testID = `permission-request-action-${action.id}`; return ( ); })} ); if (isPlanRequest && planMarkdown) { return ( ); } return ( {title} {description ? {description} : null} {planMarkdown ? ( ) : null} {!isPlanRequest ? ( ) : null} {footer} ); } const stylesheet = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, contentWrapper: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, listContentContainer: { paddingVertical: 0, flexGrow: 1, paddingHorizontal: { xs: theme.spacing[3], 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", paddingHorizontal: theme.spacing[2], }, emptyState: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[12], }, permissionsContainer: { gap: theme.spacing[2], }, listHeaderContent: { gap: theme.spacing[3], }, 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", }, scrollToBottomButton: { width: 48, height: 48, borderRadius: 24, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", ...theme.shadow.sm, }, 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], backgroundColor: theme.colors.surface1, borderColor: theme.colors.border, }, title: { fontSize: theme.fontSize.base, lineHeight: 22, color: theme.colors.foreground, }, description: { fontSize: theme.fontSize.sm, lineHeight: 20, color: theme.colors.foregroundMuted, }, section: { gap: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.xs, }, question: { fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], marginBottom: theme.spacing[1], color: theme.colors.foregroundMuted, }, optionsContainer: { gap: theme.spacing[2], }, optionsContainerDesktop: { flexDirection: "row", flexWrap: "wrap", 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], backgroundColor: theme.colors.surface1, borderColor: theme.colors.borderAccent, }, optionButtonHovered: { backgroundColor: theme.colors.surface2, }, optionButtonPressed: { opacity: 0.9, }, optionContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, optionTextPrimary: { color: theme.colors.foreground, }, })); interface StreamItemWrapperProps { itemId: string; gapBelow: number; children: ReactNode; } function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) { const wrapperStyle = useMemo( () => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }], [gapBelow], ); return {children}; }