import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { TFunction } from "i18next"; import { SquarePen } from "lucide-react-native"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet as RNStyleSheet, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { ToastViewport, useToastHost, type ToastApi, type ToastState, } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; import { type AgentScreenAgent, type AgentScreenContinuity, type AgentScreenMissingState, type AgentScreenViewState, useAgentScreenStateMachine, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { reconcileReconnectToastState, type ReconnectToastState, } from "@/panels/reconnect-toast-state"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, getHostRuntimeConnectionStatusSince, useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeIsConnected, useHostRuntimeLastError, useHosts, } from "@/runtime/host-runtime"; import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; import { AgentTaskList } from "@/composer/task-list"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { selectAgentTimelineState, selectAgentTurnPresentation, type Agent, useSessionStore, } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; import { useHideFinishedProviderSubagents, useArchiveSubagent, useDetachSubagent, useSubagentsForParent, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; interface ChatAgentStateShape { serverId: string | null; id: string | null; provider?: Agent["provider"]; status: Agent["status"] | null; cwd: string | null; workspaceId?: string; capabilities?: Agent["capabilities"]; currentModeId?: Agent["currentModeId"]; model?: Agent["model"]; thinkingOptionId?: Agent["thinkingOptionId"]; runtimeInfo?: Agent["runtimeInfo"]; features?: Agent["features"]; lastError?: Agent["lastError"] | null; } const RECONNECT_TOAST_DELAY_MS = 1_000; const reconnectToastStateByServerId = new Map(); interface ChatAgentSelectedState extends ChatAgentStateShape { archivedAt: Date | null; requiresAttention: boolean; attentionReason: Agent["attentionReason"] | null; } function resolveChatAgentFromSession( state: ReturnType, serverId: string, agentId: string | undefined, ): Agent | null { if (!agentId) return null; const session = state.sessions[serverId]; return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null; } const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { serverId: null, id: null, status: null, cwd: null, lastError: null, archivedAt: null, requiresAttention: false, attentionReason: null, }; function selectChatAgentState( state: ReturnType, serverId: string, agentId: string | undefined, ): ChatAgentSelectedState { const agent = resolveChatAgentFromSession(state, serverId, agentId); if (!agent) return EMPTY_CHAT_AGENT_STATE; return { serverId: agent.serverId, id: agent.id, provider: agent.provider, status: agent.status, cwd: agent.cwd, workspaceId: agent.workspaceId, capabilities: agent.capabilities, currentModeId: agent.currentModeId, model: agent.model, thinkingOptionId: agent.thinkingOptionId, runtimeInfo: agent.runtimeInfo, features: agent.features, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, }; } function buildChatAgentFromState( state: ChatAgentStateShape, projectPlacement: Agent["projectPlacement"] | null, ): AgentScreenAgent | null { if (!state.serverId || !state.id || !state.status || !state.cwd) { return null; } return { serverId: state.serverId, id: state.id, provider: state.provider, status: state.status, cwd: state.cwd, workspaceId: state.workspaceId, capabilities: state.capabilities, currentModeId: state.currentModeId, model: state.model, thinkingOptionId: state.thinkingOptionId, runtimeInfo: state.runtimeInfo, features: state.features, lastError: state.lastError ?? null, projectPlacement, }; } function renderChatAgentNonReadyView(args: { viewState: AgentScreenViewState; effectiveAgent: AgentScreenAgent | null; t: TFunction; }): React.ReactElement | null { const { viewState, effectiveAgent, t } = args; if (viewState.tag === "not_found") { return ( {t("agentPanel.states.notFound")} ); } if (viewState.tag === "error") { return ( {t("agentPanel.states.failedToLoad")} {viewState.message} ); } if (viewState.tag === "boot" || !effectiveAgent) { return ( ); } return null; } function formatProviderLabel(provider: Agent["provider"]): string { if (!provider) { return "Agent"; } return provider .split(/[-_\s]+/) .filter((part) => part.length > 0) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { if (typeof title !== "string") { return null; } const normalized = title.trim(); if (!normalized) { return null; } if (normalized.toLowerCase() === "new agent") { return null; } return normalized; } function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { return !agent.archivedAt && Boolean(agent.projectPlacement); } type FetchAgentResult = Awaited>; function storeFetchedAgentDetail(input: { serverId: string; result: NonNullable; }): Agent { const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId); const hydrated: Agent = applyLegacyDaemonWorkspaceOwnership({ serverId: input.serverId, agent: { ...normalized, projectPlacement: input.result.project, }, }); const store = useSessionStore.getState(); if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { store.setAgents(input.serverId, (previous) => { const next = new Map(previous); next.set(hydrated.id, hydrated); return next; }); } else { store.setAgentDetails(input.serverId, (previous) => { const next = new Map(previous); next.set(hydrated.id, hydrated); return next; }); } store.setPendingPermissions(input.serverId, (previous) => { const next = new Map(previous); for (const [key, pending] of next.entries()) { if (pending.agentId === hydrated.id) { next.delete(key); } } for (const request of hydrated.pendingPermissions) { const key = derivePendingPermissionKey(hydrated.id, request); next.set(key, { key, agentId: hydrated.id, request }); } return next; }); return hydrated; } function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { const descriptorState = useSessionStore( useShallow((state) => { const session = state.sessions[context.serverId]; const agent = session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; return { provider: agent?.provider ?? "codex", title: agent?.title ?? null, status: agent?.status ?? null, pendingPermissionCount: agent?.pendingPermissions.length ?? 0, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, }; }), ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); const icon = getProviderIcon(provider); return { label: label ?? "", subtitle: `${formatProviderLabel(provider)} agent`, tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon, statusBucket: descriptorState.status ? deriveSidebarStateBucket({ status: descriptorState.isTurnActive ? "running" : descriptorState.status, pendingPermissionCount: descriptorState.pendingPermissionCount, requiresAttention: descriptorState.requiresAttention, attentionReason: descriptorState.attentionReason, }) : null, }; } function AgentPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( ); } function DraftPanel() { const { serverId, workspaceId, tabId, target, openFileInWorkspace, openImportSheet, retargetCurrentTab, } = usePaneContext(); const { isInteractive } = usePaneFocus(); invariant(target.kind === "draft", "DraftPanel requires draft target"); const handleCreated = useCallback( (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); useSessionStore.getState().setAgents(serverId, (prev) => { const next = new Map(prev); next.set(agentSnapshot.id, agent); return next; }); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], ); return ( ); } export function AgentConversationPanel() { const { target } = usePaneContext(); if (target.kind === "draft") { return ; } if (target.kind === "agent") { return ; } invariant(false, "AgentConversationPanel requires an agent or draft target"); } export const agentPanelRegistration: PanelRegistration<"agent"> = { kind: "agent", component: AgentConversationPanel, useDescriptor: useAgentPanelDescriptor, }; export function useDraftPanelDescriptor( target: { kind: "draft"; draftId: string }, context: { serverId: string }, ) { const createDescriptorState = useCreateFlowStore( useShallow((state) => { const pending = state.pendingByDraftId[target.draftId]; if (pending?.serverId !== context.serverId || pending.lifecycle !== "active") { return { isCreating: false, pendingPrompt: null, }; } return { isCreating: true, pendingPrompt: pending.text, }; }), ); return buildDraftPanelDescriptor({ ...createDescriptorState, icon: SquarePen, }); } const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_MESSAGE_SUBMISSIONS = [] as const; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; type RouteBottomAnchorRequest = ReturnType; function findActiveCreateHandoff(input: { pendingByDraftId: ReturnType["pendingByDraftId"]; serverId: string; agentId?: string; }): boolean { if (!input.agentId) { return false; } return Object.values(input.pendingByDraftId).some( (pending) => pending.lifecycle === "sent" && pending.serverId === input.serverId && pending.agentId === input.agentId, ); } function toErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } function isNotFoundErrorMessage(message: string): boolean { return /agent not found|not found/i.test(message); } type AgentLookupState = | { tag: "idle" } | { tag: "loading" } | { tag: "not_found"; message: string } | { tag: "error"; message: string }; function AgentPanelContent({ serverId, workspaceId, agentId, isPaneFocused, onOpenWorkspaceFile, }: { serverId: string; workspaceId: string; agentId: string; isPaneFocused: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); const resolvedAgentId = agentId.trim() || undefined; const resolvedServerId = serverId.trim() || undefined; const daemons = useHosts(); const runtimeServerId = resolvedServerId ?? ""; const runtimeClient = useHostRuntimeClient(runtimeServerId); const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); const runtimeLastError = useHostRuntimeLastError(runtimeServerId); const hasCachedAgent = useSessionStore((state) => { if (!resolvedServerId || !resolvedAgentId) return false; const session = state.sessions[resolvedServerId]; return Boolean( session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId), ); }); const connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId ? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null) : null; const serverLabel = daemon?.label ?? connectionServerId ?? t("agentPanel.unavailable.selectedHost"); const isUnknownDaemon = Boolean(connectionServerId && !daemon); const connectionStatus: HostRuntimeConnectionStatus = isUnknownDaemon && runtimeConnectionStatus === "connecting" ? "offline" : runtimeConnectionStatus; const lastConnectionError = runtimeLastError; if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) { return ( ); } return ( ); } function AgentPanelBody({ serverId, workspaceId, agentId, isPaneFocused, client, isConnected, connectionStatus, onOpenWorkspaceFile, }: { serverId: string; workspaceId: string; agentId?: string; isPaneFocused: boolean; client: ReturnType; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( useSessionStore, (state) => { if (!agentId) { return null; } const session = state.sessions[serverId]; return ( session?.agents?.get(agentId)?.projectPlacement ?? session?.agentDetails?.get(agentId)?.projectPlacement ?? null ); }, (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const agentState = useSessionStore( useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; setLookupState({ tag: "idle" }); }, [agentId, serverId]); useEffect(() => { if (!agentId) { return; } if (agentState.id) { if (workspaceKey) { resolvePendingAgent(workspaceKey, agentId); } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } return; } if (!client || !isConnected || !hasSession) { return; } if (lookupState.tag === "loading" || lookupState.tag === "not_found") { return; } setLookupState({ tag: "loading" }); const attemptToken = ++lookupAttemptTokenRef.current; client .fetchAgent({ agentId }) .then((result) => { if (attemptToken !== lookupAttemptTokenRef.current) { return; } if (!result) { if (workspaceKey) { resolvePendingAgent(workspaceKey, agentId); } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, }); return; } storeFetchedAgentDetail({ serverId, result }); if (workspaceKey) { resolvePendingAgent(workspaceKey, agentId); } setLookupState({ tag: "idle" }); return; }) .catch((error) => { if (attemptToken !== lookupAttemptTokenRef.current) { return; } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { if (workspaceKey) { resolvePendingAgent(workspaceKey, agentId); } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); }, [ agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, resolvePendingAgent, serverId, workspaceKey, ]); if (lookupState.tag === "not_found") { return ( {t("agentPanel.states.notFound")} ); } if (lookupState.tag === "error") { return ( {t("agentPanel.states.failedToLoad")} {lookupState.message} ); } const agent: AgentScreenAgent | null = agentState.serverId && agentState.id && agentState.status && agentState.cwd ? { serverId: agentState.serverId, id: agentState.id, provider: agentState.provider, status: agentState.status, cwd: agentState.cwd, workspaceId: agentState.workspaceId, capabilities: agentState.capabilities, currentModeId: agentState.currentModeId, model: agentState.model, thinkingOptionId: agentState.thinkingOptionId, runtimeInfo: agentState.runtimeInfo, features: agentState.features, lastError: agentState.lastError ?? null, projectPlacement, } : null; if (!agent) { return ( ); } return ( ); } function ChatAgentContent({ serverId, agentId, isPaneFocused, client, isConnected, connectionStatus, onOpenWorkspaceFile, }: { serverId: string; agentId?: string; isPaneFocused: boolean; client: ReturnType; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); const isPaneVisible = useRetainedPanelActive(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); const clearOnAgentBlurRef = useRef<() => void>(() => {}); const wasPaneFocusedRef = useRef(isPaneFocused); const reconnectToastPresentedRef = useRef(false); const initAttemptTokenRef = useRef(0); const routeBottomAnchorRequestRef = useRef<{ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); const agentState = useSessionStore( useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); const projectPlacement = useStoreWithEqualityFn( useSessionStore, (state) => { if (!agentId) { return null; } const session = state.sessions[serverId]; return ( session?.agents?.get(agentId)?.projectPlacement ?? session?.agentDetails?.get(agentId)?.projectPlacement ?? null ); }, (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const isInitializingFromMap = useSessionStore((state) => agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, ); const historySyncGeneration = useSessionStore( (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, ); const replicaTimelineStatus = useSessionStore((state) => agentId ? selectAgentTimelineState(state.sessions[serverId], agentId).status : ("cold" as const), ); const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced"; const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); const viewedTimelineSync = useSessionStore( (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, ); const subscribeToVisibilityCatchUp = useCallback( (listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}), [viewedTimelineSync], ); const readTimelineStatus = useCallback( () => !agentId || !viewedTimelineSync ? ("ready" as const) : viewedTimelineSync.getAgentTimelineStatus(agentId), [agentId, viewedTimelineSync], ); const timelineStatus = useSyncExternalStore( subscribeToVisibilityCatchUp, readTimelineStatus, readTimelineStatus, ); const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready"; const hasActiveCreateHandoff = useCreateFlowStore((state) => findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), ); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const { ensureAgentIsInitialized } = useAgentInitialization({ serverId, client: hasSession ? client : null, }); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted"; const attentionController = useAgentAttentionClear({ agentId, client, isConnected, requiresAttention: agentState.requiresAttention, attentionReason: agentState.attentionReason, isScreenFocused: isPaneFocused, }); useEffect(() => { clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", }); const shouldPresentReconnectToast = isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle"; useEffect(() => { if (connectionStatus === "online" || connectionStatus === "idle") { reconnectToastStateByServerId.delete(serverId); } if (!shouldPresentReconnectToast) { if (reconnectToastPresentedRef.current) { reconnectToastPresentedRef.current = false; dismissToast(); } return; } const startedAt = getHostRuntimeConnectionStatusSince(serverId) ?? Date.now(); const previousReconnectToastState = reconnectToastStateByServerId.get(serverId); const reconnectToastState = reconcileReconnectToastState( previousReconnectToastState, startedAt, ); if (reconnectToastState !== previousReconnectToastState) { reconnectToastStateByServerId.set(serverId, reconnectToastState); } if (reconnectToastState.presented) { if (!reconnectToastPresentedRef.current) { reconnectToastPresentedRef.current = true; toastApi.show(t("agentPanel.states.reconnecting"), { durationMs: null, icon: ( ), testID: "agent-reconnecting-toast", }); } return; } const delayMs = Math.max(0, startedAt + RECONNECT_TOAST_DELAY_MS - Date.now()); const timer = setTimeout(() => { if (reconnectToastStateByServerId.get(serverId) !== reconnectToastState) { return; } reconnectToastState.presented = true; reconnectToastPresentedRef.current = true; toastApi.show(t("agentPanel.states.reconnecting"), { durationMs: null, icon: ( ), testID: "agent-reconnecting-toast", }); }, delayMs); return () => clearTimeout(timer); }, [connectionStatus, dismissToast, serverId, shouldPresentReconnectToast, toastApi, t]); const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { if (wasPaneFocusedRef.current && !isPaneFocused) { clearOnAgentBlurRef.current(); } wasPaneFocusedRef.current = isPaneFocused; }, [isPaneFocused]); useEffect(() => { return () => { if (wasPaneFocusedRef.current) { clearOnAgentBlurRef.current(); } }; }, []); const isInitializing = agentId ? isInitializingFromMap : false; const isHistorySyncing = useMemo(() => { if (!agentId || !isInitializing) { return false; } const initKey = getInitKey(serverId, agentId); return Boolean(getInitDeferred(initKey)); }, [agentId, isInitializing, serverId]); const needsAuthoritativeSync = useMemo(() => { if (!agentId) { return false; } return agentHistorySyncGeneration < historySyncGeneration; }, [agentHistorySyncGeneration, agentId, historySyncGeneration]); const agent = useMemo( () => buildChatAgentFromState(agentState, projectPlacement), [agentState, projectPlacement], ); const continuity = useMemo(() => { if (!hasActiveCreateHandoff || !agentId) { return { kind: "none" }; } return { kind: "optimistic-create", agent: { serverId, id: agentId, status: "running", cwd: agent?.cwd ?? ".", projectPlacement: agent?.projectPlacement ?? null, }, }; }, [agent, agentId, hasActiveCreateHandoff, serverId]); const viewState = useAgentScreenStateMachine({ routeKey: `${serverId}:${agentId ?? ""}`, input: { agent: agent ?? null, isArchived: agentState.archivedAt !== null, missingAgentState, isConnected, isArchivingCurrentAgent, isHistorySyncing, needsAuthoritativeSync, visibilityCatchUpStatus, continuity, hasHydratedHistoryBefore, }, }); const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; const routeEntryKey = agentId ? `${serverId}:${agentId}` : null; routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ cachedIntent: routeBottomAnchorRequestRef.current, routeKey: routeEntryKey, hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, }); const routeBottomAnchorRequest = useMemo( () => deriveRouteBottomAnchorRequest({ intent: routeBottomAnchorRequestRef.current, effectiveAgentId: effectiveAgent?.id ?? null, }), [effectiveAgent?.id], ); const handleComposerHeightChange = useCallback( (_height: number) => { if (!agentId) { return; } streamViewRef.current?.prepareForViewportChange(); }, [agentId], ); const handleMessageSent = useCallback(() => { if (!agentId) { return; } streamViewRef.current?.scrollToBottom("message-sent"); }, [agentId]); useEffect(() => { initAttemptTokenRef.current += 1; setMissingAgentState({ kind: "idle" }); }, [agentId, serverId]); useEffect(() => { if (!agentId) { return; } if (agentState.archivedAt) { return; } if (agentState.id && hasAppliedAuthoritativeHistory) { if ( missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found" || missingAgentState.kind === "error" ) { setMissingAgentState(reconcileMissingAgentStateWithPresentAgent); } return; } if (!client || !isPaneVisible || !isConnected || !hasSession) { return; } if ( missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found" || missingAgentState.kind === "error" ) { return; } setMissingAgentState({ kind: "resolving" }); const attemptToken = ++initAttemptTokenRef.current; ensureAgentIsInitialized(agentId) .then(async () => { if (attemptToken !== initAttemptTokenRef.current) { return; } const currentSession = useSessionStore.getState().sessions[serverId]; const currentAgent = currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); if (!currentAgent) { const result = await client.fetchAgent({ agentId }); if (attemptToken !== initAttemptTokenRef.current) { return; } if (!result) { setMissingAgentState({ kind: "not_found", message: `Agent not found: ${agentId}`, }); return; } storeFetchedAgentDetail({ serverId, result }); } if (attemptToken !== initAttemptTokenRef.current) { return; } setMissingAgentState({ kind: "idle" }); return; }) .catch((error) => { if (attemptToken !== initAttemptTokenRef.current) { return; } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { setMissingAgentState({ kind: "not_found", message }); return; } setMissingAgentState({ kind: "error", message }); }); }, [ agentState.id, agentState.archivedAt, hasAppliedAuthoritativeHistory, agentId, client, ensureAgentIsInitialized, hasSession, isConnected, isPaneVisible, missingAgentState.kind, serverId, ]); const animatedContentStyle = useMemo( () => [animatedStaticStyles.content, animatedKeyboardStyle], [animatedKeyboardStyle], ); const nonReadyView = renderChatAgentNonReadyView({ viewState, effectiveAgent, t, }); if (nonReadyView) return nonReadyView; invariant(agentId, "agent id is defined when agent content is ready"); invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); const agentCwd = agentState.cwd; invariant(agentCwd, "agent cwd is defined when agent content is ready"); const showHistorySyncOverlay = viewState.tag === "ready" && viewState.sync.status === "catching_up" && viewState.sync.ui === "overlay"; const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error"; return ( ); } const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId, agentId, isPaneFocused, isArchivingCurrentAgent, agentState, effectiveAgent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, toastApi, toast, dismiss, streamViewRef, animatedContentStyle, handleComposerHeightChange, handleMessageSent, showHistorySyncOverlay, showHistorySyncError, cwd, onAttentionInputFocus, onAttentionPromptSend, onOpenWorkspaceFile, }: { serverId: string; agentId: string; isPaneFocused: boolean; isArchivingCurrentAgent: boolean; agentState: ChatAgentSelectedState; effectiveAgent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; toastApi: ToastApi; toast: ToastState | null; dismiss: () => void; streamViewRef: React.RefObject; animatedContentStyle: object[]; handleComposerHeightChange: (height: number) => void; handleMessageSent: () => void; showHistorySyncOverlay: boolean; showHistorySyncError: boolean; cwd: string; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); const rawAgentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, agentId, }), }); // Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out // when only toast state changes (which does not affect any draft field). const { text, setText, attachments, setAttachments, clear, isHydrated, attachmentFocusRequestId, composerState, } = rawAgentInputDraft; const agentInputDraft = useMemo( (): AgentInputDraft => ({ text, setText, attachments, setAttachments, clear, isHydrated, attachmentFocusRequestId, composerState, }), [ text, setText, attachments, setAttachments, clear, isHydrated, attachmentFocusRequestId, composerState, ], ); const streamSection = ( ); const composerSection = ( ); const streamContent = ( {streamSection} ); const contentContainer = {streamContent}; return ( {contentContainer} {showHistorySyncError ? ( ) : null} {composerSection} {showHistorySyncOverlay ? ( ) : null} {isArchivingCurrentAgent ? ( {t("agentPanel.states.archivingTitle")} {t("agentPanel.states.archivingSubtitle")} ) : null} ); }); const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, agentId, agent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, toast, onOpenWorkspaceFile, }: { streamViewRef: React.RefObject; serverId: string; agentId?: string; agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); const pendingMessageSubmissions = useSessionStore( useShallow((state) => agentId ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) : EMPTY_MESSAGE_SUBMISSIONS, ), ); const turnPresentation = useSessionStore( useShallow((state) => agentId ? selectAgentTurnPresentation(state.sessions[serverId], agentId) : { isActive: false, isCancelling: false, startedAt: null, turnId: null }, ), ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingPermissionList = useStoreWithEqualityFn( useSessionStore, (state) => { if (!agentId) { return EMPTY_PENDING_PERMISSION_LIST; } const allPendingPermissions = state.sessions[serverId]?.pendingPermissions; if (!allPendingPermissions) { return EMPTY_PENDING_PERMISSION_LIST; } const filtered: PendingPermission[] = []; for (const permission of allPendingPermissions.values()) { if (permission.agentId === agentId) { filtered.push(permission); } } return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST; }, shallow, ); const pendingPermissions = useMemo(() => { if (pendingPermissionList.length === 0) { return EMPTY_PENDING_PERMISSIONS; } return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); }, [pendingPermissionList]); return ( ); }); const AgentComposerSection = memo(function AgentComposerSection({ agentId, serverId, isPaneFocused, isArchivingCurrentAgent, archivedAt, cwd, isSubmitLoading, agentInputDraft, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, onMessageSent, }: { agentId?: string; serverId: string; isPaneFocused: boolean; isArchivingCurrentAgent: boolean; archivedAt: Date | null; cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; onMessageSent: () => void; }) { if (!agentId) { return null; } if (archivedAt) { return ; } if (isArchivingCurrentAgent) { return null; } return ( ); }); function ActiveAgentComposer({ agentId, serverId, isPaneFocused, cwd, isSubmitLoading, agentInputDraft, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, onMessageSent, }: { agentId: string; serverId: string; isPaneFocused: boolean; cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; onMessageSent: () => void; }) { const insets = useSafeAreaInsets(); const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_FORM_FACTOR_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext; const { archiveAgent } = useArchiveAgent(); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId, }); const canDetachSubagents = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, ); const handleOpenSubagent = useCallback( (subagentId: string) => { navigateToAgent({ serverId, agentId: subagentId }); }, [serverId], ); const handleOpenProviderSubagent = useCallback( (parentAgentId: string, subagentId: string) => { openTab({ kind: "provider_subagent", parentAgentId, subagentId }); }, [openTab], ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ serverId, parentAgentId: agentId, }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, workspaceId, }); const attachmentScopeKeys = useMemo( () => [workspaceAttachmentScopeKey], [workspaceAttachmentScopeKey], ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleOpenWorkspaceAttachment = useCallback( (attachment: WorkspaceComposerAttachment) => { if (attachment.kind !== "review") { return; } const checkout = { serverId, cwd: attachment.attachment.cwd, isGit: true, }; openFileExplorerForCheckout({ checkout, isCompact: isCompactFormFactor, }); setExplorerTabForCheckout({ ...checkout, tab: "changes", }); }, [isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], ); const handleClientSlashCommand = useCallback( async (command: ClientSlashCommand) => { const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId); if (!agent) { throw new Error("Agent not found"); } const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); if (workspaceKey) { unpinWorkspaceAgent(workspaceKey, agentId); hideWorkspaceAgent(workspaceKey, agentId); } if (command.kind === "replace-agent-with-draft") { retargetCurrentTab({ kind: "draft", draftId: generateDraftId(), setup: buildDraftAgentSetup(agent), }); } else if (workspaceKey) { closeWorkspaceTab(workspaceKey, tabId); } await archiveAgent({ serverId, agentId }); }, [ agentId, archiveAgent, closeWorkspaceTab, hideWorkspaceAgent, retargetCurrentTab, serverId, tabId, unpinWorkspaceAgent, workspaceId, ], ); const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", }); const inputAreaStyle = useMemo( () => [ animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle, ], [insets.bottom, composerKeyboardStyle], ); return ( ); } function AgentSessionUnavailableState({ serverLabel, connectionStatus, lastError, isUnknownDaemon = false, t, }: { serverLabel: string; connectionStatus: HostRuntimeConnectionStatus; lastError: string | null; isUnknownDaemon?: boolean; t: TFunction; }) { if (isUnknownDaemon) { return ( {t("agentPanel.unavailable.unknownHost", { serverLabel })} {t("agentPanel.unavailable.addHost")} ); } const isConnecting = connectionStatus === "connecting"; const isPreparingSession = connectionStatus === "online"; return ( {isConnecting || isPreparingSession ? ( <> {isPreparingSession ? t("agentPanel.unavailable.preparingSession", { serverLabel }) : t("agentPanel.unavailable.connecting", { serverLabel })} {isPreparingSession ? t("agentPanel.unavailable.showSoon") : t("agentPanel.unavailable.showWhenOnline")} ) : ( <> {t("agentPanel.unavailable.reconnectingTo", { serverLabel })} {t("agentPanel.unavailable.showAgainWhenReachable")} {lastError ? {lastError} : null} )} ); } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const animatedStaticStyles = RNStyleSheet.create({ content: { flex: 1, }, inputAreaWrapper: { width: "100%", }, }); const styles = StyleSheet.create((theme) => ({ root: { flex: 1, backgroundColor: theme.colors.surface0, }, container: { flex: 1, backgroundColor: theme.colors.surface0, }, contentContainer: { flex: 1, overflow: "hidden", ...(isWeb ? { userSelect: "none" as const } : {}), }, historySyncOverlay: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundColor: theme.colors.surface0, alignItems: "center", justifyContent: "center", zIndex: 40, }, archivingOverlay: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundColor: "rgba(8, 10, 14, 0.86)", alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[8], gap: theme.spacing[3], zIndex: 50, }, archivingTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, textAlign: "center", }, archivingSubtitle: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, textAlign: "center", }, loadingText: { fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, reconnectingStatusDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.colors.palette.amber[500], }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[6], gap: theme.spacing[3], }, errorContainer: { flex: 1, alignItems: "center", justifyContent: "center", }, errorText: { fontSize: theme.fontSize.lg, color: theme.colors.foregroundMuted, textAlign: "center", }, statusText: { marginTop: theme.spacing[2], textAlign: "center", fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, offlineTitle: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, textAlign: "center", }, offlineDescription: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, textAlign: "center", }, offlineDetails: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, textAlign: "center", }, }));