diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index cddc2ae3c..519c61f29 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,12 +1,51 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFocusEffect, useIsFocused } from "@react-navigation/native"; +import { ActivityIndicator, Platform, Text, View } from "react-native"; +import ReanimatedAnimated from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Bot } from "lucide-react-native"; import invariant from "tiny-invariant"; -import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { FileDropZone } from "@/components/file-drop-zone"; +import type { ImageAttachment } from "@/components/message-input"; +import { ToastViewport, useToastHost } from "@/components/toast-host"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; +import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; +import { useAgentInitialization } from "@/hooks/use-agent-initialization"; +import { + useAgentScreenStateMachine, + type AgentScreenMissingState, +} from "@/hooks/use-agent-screen-state-machine"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + useHostRuntimeSession, + useHosts, + type HostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; +import { + getInitDeferred, + getInitKey, +} from "@/utils/agent-initialization"; +import { + derivePendingPermissionKey, + normalizeAgentSnapshot, +} from "@/utils/agent-snapshots"; +import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "@/screens/agent/agent-ready-screen-bottom-anchor"; function formatProviderLabel(provider: Agent["provider"]): string { if (provider === "claude") { @@ -66,11 +105,9 @@ function AgentPanel() { const { serverId, target, openFileInWorkspace } = usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - { openFileInWorkspace(filePath); }} @@ -83,3 +120,891 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = { component: AgentPanel, useDescriptor: useAgentPanelDescriptor, }; + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +function logWebStickyBottom( + _event: string, + _details: Record +): void {} + +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); +} + +function AgentPanelContent({ + serverId, + agentId, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId: string; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const resolvedAgentId = agentId.trim() || undefined; + const resolvedServerId = serverId.trim() || undefined; + const daemons = useHosts(); + const runtimeServerId = resolvedServerId ?? ""; + const { + snapshot: runtimeSnapshot, + client: runtimeClient, + isConnected: runtimeIsConnected, + } = useHostRuntimeSession(runtimeServerId); + + const connectionServerId = resolvedServerId ?? null; + const daemon = connectionServerId + ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null + : null; + const serverLabel = + daemon?.label ?? connectionServerId ?? "Selected host"; + const isUnknownDaemon = Boolean(connectionServerId && !daemon); + const connectionStatus: HostRuntimeConnectionStatus = + runtimeSnapshot?.connectionStatus ?? + (isUnknownDaemon ? "offline" : "connecting"); + const lastConnectionError = runtimeSnapshot?.lastError ?? null; + + if (!resolvedServerId || !runtimeClient) { + return ( + + ); + } + + return ( + + ); +} + +function AgentPanelBody({ + serverId, + agentId, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + client: NonNullable["client"]>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const { theme } = useUnistyles(); + const panelToast = useToastHost(); + const { isArchivingAgent } = useArchiveAgent(); + const streamViewRef = useRef(null); + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const isScreenFocused = useIsFocused(); + const reconnectToastArmedRef = useRef(false); + const initAttemptTokenRef = useRef(0); + const routeBottomAnchorRequestRef = useRef<{ + routeKey: string; + reason: "initial-entry" | "resume"; + } | null>(null); + + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback( + (addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, + [] + ); + + const agent = useSessionStore((state) => + agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined + ); + const streamItemsRaw = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentStreamTail?.get(agentId) + : undefined + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); + const markPendingCreateLifecycle = useCreateFlowStore( + (state) => state.markLifecycle + ); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + const isInitializingFromMap = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false + : false + ); + const historySyncGeneration = useSessionStore( + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 + ); + const hasAppliedAuthoritativeHistory = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === + true + : false + ); + const agentHistorySyncGeneration = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1 + : -1 + ); + const allPendingPermissions = useSessionStore( + (state) => state.sessions[serverId]?.pendingPermissions + ); + const setAgents = useSessionStore((state) => state.setAgents); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setPendingPermissions = useSessionStore( + (state) => state.setPendingPermissions + ); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const { ensureAgentIsInitialized } = useAgentInitialization({ + serverId, + client: hasSession ? client : null, + }); + const [missingAgentState, setMissingAgentState] = useState({ + kind: "idle", + }); + + const pendingCreate = useMemo(() => { + if (!agentId) { + return null; + } + const values = Object.values(pendingByDraftId); + for (const entry of values) { + if ( + entry.lifecycle === "active" && + entry.serverId === serverId && + entry.agentId === agentId + ) { + return entry; + } + } + return null; + }, [agentId, pendingByDraftId, serverId]); + const isPendingCreateForPanel = Boolean(pendingCreate); + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; + + const pendingPermissions = useMemo(() => { + if (!allPendingPermissions || !agentId) { + return new Map(); + } + const filtered = new Map(); + for (const [key, permission] of allPendingPermissions) { + if (permission.agentId === agentId) { + filtered.set(key, permission); + } + } + return filtered; + }, [agentId, allPendingPermissions]); + + const setFocusedAgentId = useCallback( + (focusedAgentId: string | null) => { + useSessionStore.getState().setFocusedAgentId(serverId, focusedAgentId); + }, + [serverId] + ); + + const attentionController = useAgentAttentionClear({ + agentId, + client, + isConnected, + requiresAttention: agent?.requiresAttention, + attentionReason: agent?.attentionReason, + isScreenFocused, + }); + + const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const handleHistorySyncFailure = useCallback( + ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { + if (agentId) { + console.warn("[AgentPanel] history sync failed", { + origin, + agentId, + error, + }); + } + const message = toErrorMessage(error); + setMissingAgentState((previous) => { + if (previous.kind === "error" && previous.message === message) { + return previous; + } + return { kind: "error", message }; + }); + }, + [agentId] + ); + + const ensureInitializedWithSyncErrorHandling = useCallback( + (origin: "focus" | "entry") => { + if (!agentId) { + return; + } + ensureAgentIsInitialized(agentId).catch((error) => { + handleHistorySyncFailure({ origin, error }); + }); + }, + [agentId, ensureAgentIsInitialized, handleHistorySyncFailure] + ); + + useEffect(() => { + if (connectionStatus === "online") { + reconnectToastArmedRef.current = false; + return; + } + if (connectionStatus === "idle") { + return; + } + if (!reconnectToastArmedRef.current) { + reconnectToastArmedRef.current = true; + panelToast.api.show("Reconnecting...", { + durationMs: 2200, + testID: "agent-reconnecting-toast", + }); + } + }, [connectionStatus, panelToast.api]); + + useFocusEffect( + useCallback(() => { + if (!agentId || !isConnected || !hasSession) { + return; + } + ensureInitializedWithSyncErrorHandling("focus"); + }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected]) + ); + + const isArchivingCurrentAgent = Boolean( + agentId && + isArchivingAgent({ serverId, agentId }) + ); + + useFocusEffect( + useCallback(() => { + if (!agentId) { + setFocusedAgentId(null); + return; + } + + setFocusedAgentId(agentId); + return () => { + attentionController.clearOnAgentBlur(); + setFocusedAgentId(null); + }; + }, [agentId, attentionController, setFocusedAgentId]) + ); + + const isInitializing = agentId ? isInitializingFromMap !== false : 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 optimisticStreamItems = useMemo(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.clientMessageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), + }, + ]; + }, [isPendingCreateForPanel, pendingCreate]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + const alreadyHasOptimistic = streamItems.some( + (item) => item.kind === "user_message" && item.id === optimistic.id + ); + return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, streamItems]); + + const shouldUseOptimisticStream = + isPendingCreateForPanel && optimisticStreamItems.length > 0; + const authoritativeStatus = agent?.status; + const isAuthoritativeBootstrapping = + authoritativeStatus === "initializing" || authoritativeStatus === "idle"; + const showPendingCreateSubmitLoading = + isPendingCreateForPanel && + (!authoritativeStatus || isAuthoritativeBootstrapping); + const canFinalizePendingCreate = + Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; + + const placeholderAgent: Agent | null = useMemo(() => { + if (!shouldUseOptimisticStream || !agentId) { + return null; + } + const now = new Date(); + return { + serverId, + id: agentId, + provider: "claude", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "claude", + sessionId: null, + model: null, + modeId: null, + }, + title: "Agent", + cwd: ".", + model: null, + labels: {}, + }; + }, [agentId, serverId, shouldUseOptimisticStream]); + + const viewState = useAgentScreenStateMachine({ + routeKey: `${serverId}:${agentId ?? ""}`, + input: { + agent: agent ?? null, + placeholderAgent, + missingAgentState, + isConnected, + isArchivingCurrentAgent, + isHistorySyncing, + needsAuthoritativeSync, + shouldUseOptimisticStream, + 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] + ); + + useEffect(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return; + } + const hasUserMessage = streamItems.some( + (item) => + item.kind === "user_message" && + item.id === pendingCreate.clientMessageId + ); + if (hasUserMessage && canFinalizePendingCreate) { + if ( + agentId && + pendingCreate.images && + pendingCreate.images.length > 0 + ) { + setAgentStreamTail(serverId, (previous) => { + const current = previous.get(agentId); + if (!current) { + return previous; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + clientMessageId: pendingCreate.clientMessageId, + images: pendingCreate.images, + }); + if (merged === current) { + return previous; + } + + const next = new Map(previous); + next.set(agentId, merged); + return next; + }); + } + markPendingCreateLifecycle({ + draftId: pendingCreate.draftId, + lifecycle: "sent", + }); + clearPendingCreate({ draftId: pendingCreate.draftId }); + } + }, [ + agentId, + canFinalizePendingCreate, + clearPendingCreate, + isPendingCreateForPanel, + markPendingCreateLifecycle, + pendingCreate, + serverId, + setAgentStreamTail, + streamItems, + ]); + + useEffect(() => { + if (!agentId) { + return; + } + if (!isConnected || !hasSession) { + return; + } + const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + if (!shouldSyncOnEntry) { + return; + } + + ensureInitializedWithSyncErrorHandling("entry"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + needsAuthoritativeSync, + ]); + + useEffect(() => { + initAttemptTokenRef.current += 1; + setMissingAgentState({ kind: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agent || shouldUseOptimisticStream) { + if (missingAgentState.kind !== "idle") { + setMissingAgentState({ kind: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + return; + } + + setMissingAgentState({ kind: "resolving" }); + const attemptToken = ++initAttemptTokenRef.current; + + ensureAgentIsInitialized(agentId) + .then(async () => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const currentAgent = useSessionStore + .getState() + .sessions[serverId] + ?.agents.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; + } + const normalized = normalizeAgentSnapshot(result.agent, serverId); + const hydrated = { + ...normalized, + projectPlacement: result.project, + }; + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + setPendingPermissions(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; + }); + } + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + setMissingAgentState({ kind: "idle" }); + }) + .catch((error) => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setMissingAgentState({ kind: "not_found", message }); + return; + } + setMissingAgentState({ kind: "error", message }); + }); + }, [ + agent, + agentId, + client, + ensureAgentIsInitialized, + hasSession, + isConnected, + missingAgentState.kind, + serverId, + setAgents, + setPendingPermissions, + shouldUseOptimisticStream, + ]); + + const isHistoryRefreshCatchingUp = + viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "toast"; + const shouldEmitSyncErrorToast = + viewState.tag === "ready" && + viewState.sync.status === "sync_error" && + viewState.sync.shouldEmitSyncErrorToast; + + useDelayedHistoryRefreshToast({ + isCatchingUp: isHistoryRefreshCatchingUp, + indicatorColor: theme.colors.primary, + showToast: panelToast.api.show, + }); + + useEffect(() => { + if (!shouldEmitSyncErrorToast) { + return; + } + panelToast.api.error("Failed to refresh agent. Retrying in background."); + }, [panelToast.api, shouldEmitSyncErrorToast]); + + if (viewState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + + if (viewState.tag === "error") { + return ( + + + Failed to load agent + {viewState.message} + + + ); + } + + if (viewState.tag === "boot" || !effectiveAgent) { + return ( + + + + + + ); + } + + return ( + + + + + + + + + + {agentId && !isArchivingCurrentAgent ? ( + { + logWebStickyBottom("screen_composer_height_change", { + agentId, + height, + }); + streamViewRef.current?.prepareForViewportChange(); + }} + onMessageSent={() => { + logWebStickyBottom("screen_message_sent_scroll_to_bottom", { + agentId, + }); + streamViewRef.current?.scrollToBottom("message-sent"); + }} + /> + ) : null} + + {viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "overlay" ? ( + + + + ) : null} + + + + + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + + Please wait while we archive this agent. + + + ) : null} + + ); +} + +function AgentSessionUnavailableState({ + serverLabel, + connectionStatus, + lastError, + isUnknownDaemon = false, +}: { + serverLabel: string; + connectionStatus: HostRuntimeConnectionStatus; + lastError: string | null; + isUnknownDaemon?: boolean; +}) { + if (isUnknownDaemon) { + return ( + + + + Cannot open this agent because {serverLabel} is not configured on + this device. + + + Add the host in Settings or open an agent on a configured server to + continue. + + + + ); + } + + const isConnecting = connectionStatus === "connecting"; + const isPreparingSession = connectionStatus === "online"; + + return ( + + + {isConnecting || isPreparingSession ? ( + <> + + + {isPreparingSession + ? `Preparing ${serverLabel} session...` + : `Connecting to ${serverLabel}...`} + + + {isPreparingSession + ? "We will show this agent in a moment." + : "We will show this agent once the host is online."} + + + ) : ( + <> + + Reconnecting to {serverLabel}... + + + We will show this agent again as soon as the host is reachable. + + {lastError ? ( + {lastError} + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + content: { + flex: 1, + }, + 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, + }, + 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", + }, +})); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx deleted file mode 100644 index 324dd6c72..000000000 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ /dev/null @@ -1,1160 +0,0 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { - View, - Text, - ActivityIndicator, - Platform, - BackHandler, -} from "react-native"; -import { useFocusEffect, useIsFocused } from "@react-navigation/native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useQueryClient } from "@tanstack/react-query"; -import ReanimatedAnimated from "react-native-reanimated"; -import { GestureDetector } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { ToastViewport, useToastHost } from "@/components/toast-host"; -import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/components/message-input"; -import { - ExplorerSidebarAnimationProvider, -} from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { - useHostRuntimeSession, - type HostRuntimeConnectionStatus, -} from "@/runtime/host-runtime"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { Agent } from "@/contexts/session-context"; -import type { StreamItem } from "@/types/stream"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, - useCheckoutStatusQuery, -} from "@/hooks/use-checkout-status-query"; -import { useAgentInitialization } from "@/hooks/use-agent-initialization"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { - useAgentScreenStateMachine, - type AgentScreenMissingState, -} from "@/hooks/use-agent-screen-state-machine"; -import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; -import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; -import { - derivePendingPermissionKey, - normalizeAgentSnapshot, -} from "@/utils/agent-snapshots"; -import { mergePendingCreateImages } from "@/utils/pending-create-images"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; -import type { DaemonClient } from "@server/client/daemon-client"; -import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; -import type { ExplorerCheckoutContext } from "@/stores/panel-store"; -import { - deriveRouteBottomAnchorIntent, - deriveRouteBottomAnchorRequest, -} from "./agent-ready-screen-bottom-anchor"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; -function logWebStickyBottom( - _event: string, - _details: Record -): void { - // Intentionally disabled: this path is too noisy during voice debugging. -} - -export function AgentReadyScreen({ - serverId, - agentId, - showExplorerSidebar = true, - wrapWithExplorerSidebarProvider = true, - onOpenWorkspaceFile, -}: { - serverId: string; - agentId: string; - showExplorerSidebar?: boolean; - wrapWithExplorerSidebarProvider?: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}) { - const resolvedAgentId = agentId?.trim() || undefined; - const resolvedServerId = serverId?.trim() || undefined; - const daemons = useHosts(); - const runtimeServerId = resolvedServerId ?? ""; - const { - snapshot: runtimeSnapshot, - client: runtimeClient, - isConnected: runtimeIsConnected, - } = useHostRuntimeSession(runtimeServerId); - - const connectionServerId = resolvedServerId ?? null; - const daemon = connectionServerId - ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null - : null; - const serverLabel = - daemon?.label ?? connectionServerId ?? "Selected host"; - const isUnknownDaemon = Boolean(connectionServerId && !daemon); - const connectionStatus: HostRuntimeConnectionStatus = - runtimeSnapshot?.connectionStatus ?? - (isUnknownDaemon ? "offline" : "connecting"); - const lastConnectionError = runtimeSnapshot?.lastError ?? null; - const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); - - if (!resolvedServerId || !runtimeClient) { - return ( - - ); - } - - const content = ( - - ); - - if (!wrapWithExplorerSidebarProvider) { - return content; - } - - return {content}; -} - -type AgentScreenContentProps = { - serverId: string; - agentId?: string; - client: DaemonClient; - isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; - showExplorerSidebar: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}; - -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); -} - -function AgentScreenContent({ - serverId, - agentId, - client, - isConnected, - connectionStatus, - showExplorerSidebar, - onOpenWorkspaceFile, -}: AgentScreenContentProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const queryClient = useQueryClient(); - const panelToast = useToastHost(); - const resolvedAgentId = agentId; - const { isArchivingAgent } = useArchiveAgent(); - - const streamViewRef = useRef(null); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); - - const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); - - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const closeToAgent = usePanelStore((state) => state.closeToAgent); - const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); - const activateExplorerTabForCheckout = usePanelStore( - (state) => state.activateExplorerTabForCheckout - ); - - // Derive isExplorerOpen from the unified panel state - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - // Select only the specific agent - const agent = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agents?.get(resolvedAgentId) - : undefined - ); - // Checkout status for header subtitle + git fallback when cached project placement is absent - const checkoutStatusQuery = useCheckoutStatusQuery({ - serverId, - cwd: agent?.cwd ?? "", - }); - const checkout = checkoutStatusQuery.status; - const resolveCachedCheckoutIsGit = useCallback( - (params: { - cwd?: string | null; - projectPlacementIsGit?: boolean; - checkoutStatusIsGit?: boolean; - }): boolean | null => { - if (typeof params.projectPlacementIsGit === "boolean") { - return params.projectPlacementIsGit; - } - - const cwd = params.cwd?.trim(); - if (!cwd) { - return null; - } - const cachedCheckout = queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ); - if (typeof cachedCheckout?.isGit === "boolean") { - return cachedCheckout.isGit; - } - if (typeof params.checkoutStatusIsGit === "boolean") { - return params.checkoutStatusIsGit; - } - return null; - }, - [queryClient, serverId] - ); - const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => { - if (!resolvedAgentId) { - return null; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents?.get(resolvedAgentId); - const cwd = currentAgent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); - const openExplorerForActiveCheckout = useCallback(() => { - const checkoutContext = resolveCurrentExplorerCheckout(); - if (checkoutContext) { - activateExplorerTabForCheckout(checkoutContext); - } - openFileExplorer(); - }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); - // Swipe-left gesture to open explorer sidebar on mobile - const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === "agent", - onOpen: openExplorerForActiveCheckout, - }); - - // Handle hardware back button - close explorer sidebar first, then navigate back - useEffect(() => { - if (Platform.OS === "web") return; - - const handler = BackHandler.addEventListener("hardwareBackPress", () => { - if (isExplorerOpen) { - closeToAgent(); - return true; // Prevent default back navigation - } - return false; // Let default back navigation happen - }); - - return () => handler.remove(); - }, [isExplorerOpen, closeToAgent]); - - const activeExplorerCheckout = useMemo(() => { - const cwd = agent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [ - agent?.cwd, - agent?.projectPlacement?.checkout?.isGit, - resolveCachedCheckoutIsGit, - resolvedAgentId, - checkout?.isGit, - serverId, - ]); - - useEffect(() => { - setActiveExplorerCheckout(activeExplorerCheckout); - }, [activeExplorerCheckout, setActiveExplorerCheckout]); - - useEffect(() => { - if (!activeExplorerCheckout) { - return; - } - activateExplorerTabForCheckout(activeExplorerCheckout); - }, [activateExplorerTabForCheckout, activeExplorerCheckout]); - - useEffect(() => { - return () => { - setActiveExplorerCheckout(null); - }; - }, [setActiveExplorerCheckout]); - - // Select only the specific stream tail - use stable empty array to avoid infinite loop - const streamItemsRaw = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) - : undefined - ); - const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - - const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const markPendingCreateLifecycle = useCreateFlowStore( - (state) => state.markLifecycle - ); - const clearPendingCreate = useCreateFlowStore((state) => state.clear); - const pendingCreate = useMemo(() => { - if (!resolvedAgentId) { - return null; - } - const values = Object.values(pendingByDraftId); - for (const entry of values) { - if ( - entry.lifecycle === "active" && - entry.serverId === serverId && - entry.agentId === resolvedAgentId - ) { - return entry; - } - } - return null; - }, [pendingByDraftId, resolvedAgentId, serverId]); - const isPendingCreateForRoute = Boolean(pendingCreate); - - // Select only the specific initializing state - const isInitializingFromMap = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false - : false - ); - const historySyncGeneration = useSessionStore( - (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 - ); - const hasAppliedAuthoritativeHistory = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get( - resolvedAgentId - ) === true - : false - ); - const agentHistorySyncGeneration = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 - : -1 - ); - const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; - - // Select raw pending permissions - filter with useMemo to avoid new Map on every render - const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions - ); - const setAgents = useSessionStore((state) => state.setAgents); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setPendingPermissions = useSessionStore( - (state) => state.setPendingPermissions - ); - const pendingPermissions = useMemo(() => { - if (!allPendingPermissions || !resolvedAgentId) return new Map(); - const filtered = new Map(); - for (const [key, perm] of allPendingPermissions) { - if (perm.agentId === resolvedAgentId) { - filtered.set(key, perm); - } - } - return filtered; - }, [allPendingPermissions, resolvedAgentId]); - - const hasSession = useSessionStore( - (state) => Boolean(state.sessions[serverId]) - ); - const isScreenFocused = useIsFocused(); - const { ensureAgentIsInitialized } = useAgentInitialization({ - serverId, - client: hasSession ? client : null, - }); - const [missingAgentState, setMissingAgentState] = useState({ - kind: "idle", - }); - const reconnectToastArmedRef = useRef(false); - const initAttemptTokenRef = useRef(0); - const routeBottomAnchorRequestRef = useRef<{ - routeKey: string; - reason: "initial-entry" | "resume"; - } | null>(null); - const setFocusedAgentId = useCallback( - (agentId: string | null) => { - useSessionStore.getState().setFocusedAgentId(serverId, agentId); - }, - [serverId] - ); - - const attentionController = useAgentAttentionClear({ - agentId: resolvedAgentId, - client, - isConnected, - requiresAttention: agent?.requiresAttention, - attentionReason: agent?.attentionReason, - isScreenFocused, - }); - - const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (resolvedAgentId) { - console.warn("[AgentScreen] history sync failed", { - origin, - agentId: resolvedAgentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((prev) => { - if (prev.kind === "error" && prev.message === message) { - return prev; - } - return { kind: "error", message }; - }); - }, - [resolvedAgentId] - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!resolvedAgentId) { - return; - } - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - handleHistorySyncFailure({ origin, error }); - }); - }, - [ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId] - ); - - useEffect(() => { - if (connectionStatus === "online") { - reconnectToastArmedRef.current = false; - return; - } - if (connectionStatus === "idle") { - return; - } - if (!reconnectToastArmedRef.current) { - reconnectToastArmedRef.current = true; - panelToast.api.show("Reconnecting...", { - durationMs: 2200, - testID: "agent-reconnecting-toast", - }); - } - }, [connectionStatus, panelToast.api]); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [ - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - resolvedAgentId, - ]) - ); - - const isGitCheckout = activeExplorerCheckout?.isGit ?? false; - const isArchivingCurrentAgent = Boolean( - resolvedAgentId && - isArchivingAgent({ serverId, agentId: resolvedAgentId }) - ); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId) { - setFocusedAgentId(null); - return; - } - - setFocusedAgentId(resolvedAgentId); - return () => { - attentionController.clearOnAgentBlur(); - setFocusedAgentId(null); - }; - }, [attentionController, resolvedAgentId, setFocusedAgentId]) - ); - - const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; - const isHistorySyncing = useMemo(() => { - if (!resolvedAgentId || !isInitializing) { - return false; - } - const initKey = getInitKey(serverId, resolvedAgentId); - return Boolean(getInitDeferred(initKey)); - }, [resolvedAgentId, isInitializing, serverId]); - const needsAuthoritativeSync = useMemo(() => { - if (!resolvedAgentId) { - return false; - } - return agentHistorySyncGeneration < historySyncGeneration; - }, [agentHistorySyncGeneration, historySyncGeneration, resolvedAgentId]); - - const optimisticStreamItems = useMemo(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return EMPTY_STREAM_ITEMS; - } - return [ - { - kind: "user_message", - id: pendingCreate.clientMessageId, - text: pendingCreate.text, - timestamp: new Date(pendingCreate.timestamp), - ...(pendingCreate.images && pendingCreate.images.length > 0 - ? { images: pendingCreate.images } - : {}), - }, - ]; - }, [isPendingCreateForRoute, pendingCreate]); - - const mergedStreamItems = useMemo(() => { - if (optimisticStreamItems.length === 0) { - return streamItems; - } - const optimistic = optimisticStreamItems[0]; - if (!optimistic) { - return streamItems; - } - const alreadyHasOptimistic = streamItems.some( - (item) => item.kind === "user_message" && item.id === optimistic.id - ); - return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; - }, [optimisticStreamItems, streamItems]); - - const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; - const authoritativeStatus = agent?.status; - const isAuthoritativeBootstrapping = - authoritativeStatus === "initializing" || authoritativeStatus === "idle"; - const showPendingCreateSubmitLoading = - isPendingCreateForRoute && - (!authoritativeStatus || isAuthoritativeBootstrapping); - const canFinalizePendingCreate = - Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; - - const placeholderAgent: Agent | null = useMemo(() => { - if (!shouldUseOptimisticStream || !resolvedAgentId) { - return null; - } - const now = new Date(); - return { - serverId, - id: resolvedAgentId, - provider: "claude", - status: "running", - createdAt: now, - updatedAt: now, - lastUserMessageAt: now, - lastActivityAt: now, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: "claude", - sessionId: null, - model: null, - modeId: null, - }, - title: "Agent", - cwd: ".", - model: null, - labels: {}, - }; - }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); - - const viewState = useAgentScreenStateMachine({ - routeKey: `${serverId}:${resolvedAgentId ?? ""}`, - input: { - agent: agent ?? null, - placeholderAgent, - missingAgentState, - isConnected, - isArchivingCurrentAgent, - isHistorySyncing, - needsAuthoritativeSync, - shouldUseOptimisticStream, - hasHydratedHistoryBefore, - }, - }); - - const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; - const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null; - routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ - cachedIntent: routeBottomAnchorRequestRef.current, - routeKey: routeEntryKey, - hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, - }); - const routeBottomAnchorRequest = useMemo( - () => - deriveRouteBottomAnchorRequest({ - intent: routeBottomAnchorRequestRef.current, - effectiveAgentId: effectiveAgent?.id ?? null, - }), - [effectiveAgent?.id] - ); - useEffect(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return; - } - const hasUserMessage = streamItems.some( - (item) => - item.kind === "user_message" && - item.id === pendingCreate.clientMessageId - ); - if (hasUserMessage && canFinalizePendingCreate) { - if ( - resolvedAgentId && - pendingCreate.images && - pendingCreate.images.length > 0 - ) { - setAgentStreamTail(serverId, (prev) => { - const current = prev.get(resolvedAgentId); - if (!current) { - return prev; - } - - const merged = mergePendingCreateImages({ - streamItems: current, - clientMessageId: pendingCreate.clientMessageId, - images: pendingCreate.images, - }); - if (merged === current) { - return prev; - } - - const next = new Map(prev); - next.set(resolvedAgentId, merged); - return next; - }); - } - markPendingCreateLifecycle({ draftId: pendingCreate.draftId, lifecycle: "sent" }); - clearPendingCreate({ draftId: pendingCreate.draftId }); - } - }, [ - canFinalizePendingCreate, - clearPendingCreate, - isPendingCreateForRoute, - markPendingCreateLifecycle, - pendingCreate, - resolvedAgentId, - serverId, - setAgentStreamTail, - streamItems, - ]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - - if (!isConnected || !hasSession) { - return; - } - // On native clients, daemon stream forwarding is focused-agent only, so switching - // agents can leave timeline gaps unless we explicitly pull timeline catch-up. - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - resolvedAgentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - - useEffect(() => { - // Clear stale resolution state when route target changes. - initAttemptTokenRef.current += 1; - setMissingAgentState({ kind: "idle" }); - }, [serverId, resolvedAgentId]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - if (agent || shouldUseOptimisticStream) { - if (missingAgentState.kind !== "idle") { - setMissingAgentState({ kind: "idle" }); - } - return; - } - if (!isConnected || !hasSession) { - return; - } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { - return; - } - - setMissingAgentState({ kind: "resolving" }); - const attemptToken = ++initAttemptTokenRef.current; - - ensureAgentIsInitialized(resolvedAgentId) - .then(async () => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents.get(resolvedAgentId); - if (!currentAgent && client) { - const result = await client.fetchAgent(resolvedAgentId); - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - if (!result) { - setMissingAgentState({ - kind: "not_found", - message: `Agent not found: ${resolvedAgentId}`, - }); - return; - } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - 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; - }); - } - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - setMissingAgentState({ kind: "idle" }); - }) - .catch((error) => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const message = toErrorMessage(error); - if (isNotFoundErrorMessage(message)) { - setMissingAgentState({ kind: "not_found", message }); - return; - } - setMissingAgentState({ kind: "error", message }); - }); - }, [ - agent, - client, - ensureAgentIsInitialized, - hasSession, - isConnected, - missingAgentState.kind, - resolvedAgentId, - serverId, - setAgents, - setPendingPermissions, - shouldUseOptimisticStream, - ]); - - useEffect(() => { - if (Platform.OS !== "web") { - return; - } - const title = agent?.title || "Agent"; - document.title = title; - }, [agent?.title]); - - const isHistoryRefreshCatchingUp = - viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "toast"; - const shouldEmitSyncErrorToast = - viewState.tag === "ready" && - viewState.sync.status === "sync_error" && - viewState.sync.shouldEmitSyncErrorToast; - - useDelayedHistoryRefreshToast({ - isCatchingUp: isHistoryRefreshCatchingUp, - indicatorColor: theme.colors.primary, - showToast: panelToast.api.show, - }); - - useEffect(() => { - if (!shouldEmitSyncErrorToast) { - return; - } - panelToast.api.error("Failed to refresh agent. Retrying in background."); - }, [panelToast.api, shouldEmitSyncErrorToast]); - - if (viewState.tag === "not_found") { - return ( - - - Agent not found - - - ); - } - - if (viewState.tag === "error") { - return ( - - - Failed to load agent - {viewState.message} - - - ); - } - - if (viewState.tag === "boot" || !effectiveAgent) { - return ( - - - - - - ); - } - - const mainContent = ( - - - - {/* Content Area with Keyboard Animation */} - - - - - - - {/* Agent Input Area */} - {resolvedAgentId && !isArchivingCurrentAgent && ( - { - logWebStickyBottom("screen_composer_height_change", { - agentId: resolvedAgentId, - height, - }); - streamViewRef.current?.prepareForViewportChange(); - }} - onMessageSent={() => { - logWebStickyBottom("screen_message_sent_scroll_to_bottom", { - agentId: resolvedAgentId, - }); - streamViewRef.current?.scrollToBottom("message-sent"); - }} - /> - )} - - {viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "overlay" ? ( - - - - ) : null} - - - - - - - {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} - {showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? ( - - ) : null} - - {isArchivingCurrentAgent ? ( - - - Archiving agent... - - Please wait while we archive this agent. - - - ) : null} - - ); - - return ( - <> - {isMobile ? ( - - {mainContent} - - ) : ( - mainContent - )} - - {/* Mobile Explorer Sidebar Overlay */} - {showExplorerSidebar && isMobile && resolvedAgentId ? ( - - ) : null} - - ); -} - -function AgentSessionUnavailableState({ - serverLabel, - connectionStatus, - lastError, - isUnknownDaemon = false, -}: { - serverLabel: string; - connectionStatus: HostRuntimeConnectionStatus; - lastError: string | null; - isUnknownDaemon?: boolean; -}) { - if (isUnknownDaemon) { - return ( - - - - Cannot open this agent because {serverLabel} is not configured on - this device. - - - Add the host in Settings or open an agent on a configured server to - continue. - - - - ); - } - - const isConnecting = connectionStatus === "connecting"; - const isPreparingSession = connectionStatus === "online"; - - return ( - - - {isConnecting || isPreparingSession ? ( - <> - - - {isPreparingSession - ? `Preparing ${serverLabel} session...` - : `Connecting to ${serverLabel}...`} - - - {isPreparingSession - ? "We will show this agent in a moment." - : "We will show this agent once the host is online."} - - - ) : ( - <> - - Reconnecting to {serverLabel}... - - - We will show this agent again as soon as the host is reachable. - - {lastError ? ( - {lastError} - ) : null} - - )} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - outerContainer: { - flex: 1, - flexDirection: "row", - backgroundColor: theme.colors.surface0, - }, - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, - content: { - flex: 1, - }, - 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, - }, - 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, - }, - errorDetails: { - marginTop: theme.spacing[1], - textAlign: "center", - fontSize: theme.fontSize.xs, - 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", - }, -})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e9fefa0b2..d8f714daf 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -279,6 +279,25 @@ function ResolvedMobileActiveTabTrigger({ ); } +function WorkspaceDocumentTitleEffect({ + label, + titleState, +}: { + label: string; + titleState: "ready" | "loading"; +}) { + useEffect(() => { + if (Platform.OS !== "web" || typeof document === "undefined") { + return; + } + const resolvedLabel = label.trim(); + document.title = + titleState === "loading" ? "Loading..." : resolvedLabel || "Workspace"; + }, [label, titleState]); + + return null; +} + function MobileWorkspaceTabOption({ tab, tabIndex, @@ -1804,6 +1823,12 @@ function WorkspaceScreenContent({ }); const activeTabDescriptor = activeTab?.descriptor ?? null; + useEffect(() => { + if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { + return; + } + document.title = "Workspace"; + }, [activeTabDescriptor]); const buildPaneContentModel = useCallback( (input: { tab: WorkspaceTabDescriptor; @@ -1890,6 +1915,20 @@ function WorkspaceScreenContent({ return ( + {Platform.OS === "web" && activeTabDescriptor ? ( + + {(presentation) => ( + + )} + + ) : null}