Files
paseo/packages/app/src/screens/agent/agent-ready-screen.tsx
T

1182 lines
36 KiB
TypeScript

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 { 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 { useDaemonRegistry } from "@/contexts/daemon-registry-context";
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 {
buildAgentNavigationKey,
endNavigationTiming,
} from "@/utils/navigation-timing";
import { startPerfMonitor } from "@/utils/perf-monitor";
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 { useToast } from "@/contexts/toast-context";
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[] = [];
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[AgentExplorer] ${event}`, details);
}
export function AgentReadyScreen({
serverId,
agentId,
showExplorerSidebar = true,
wrapWithExplorerSidebarProvider = true,
}: {
serverId: string;
agentId: string;
showExplorerSidebar?: boolean;
wrapWithExplorerSidebarProvider?: boolean;
}) {
const resolvedAgentId = agentId?.trim() || undefined;
const resolvedServerId = serverId?.trim() || undefined;
const { daemons } = useDaemonRegistry();
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);
const focusServerId = resolvedServerId;
const navigationStatus = isRuntimeSessionAvailable
? "ready"
: "session_unavailable";
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !focusServerId) {
return;
}
const navigationKey = buildAgentNavigationKey(
focusServerId,
resolvedAgentId
);
endNavigationTiming(navigationKey, {
screen: "agent",
status: navigationStatus,
});
}, [focusServerId, navigationStatus, resolvedAgentId])
);
if (!resolvedServerId || !runtimeClient) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
connectionStatus={connectionStatus}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
const content = (
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
showExplorerSidebar={showExplorerSidebar}
/>
);
if (!wrapWithExplorerSidebarProvider) {
return content;
}
return <ExplorerSidebarAnimationProvider>{content}</ExplorerSidebarAnimationProvider>;
}
type AgentScreenContentProps = {
serverId: string;
agentId?: string;
client: DaemonClient;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
showExplorerSidebar: boolean;
};
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,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const toast = useToast();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(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<CheckoutStatusPayload>(
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();
logAgentExplorer("openExplorerForActiveCheckout", {
hasCheckoutContext: Boolean(checkoutContext),
checkoutContext,
});
if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext);
}
openFileExplorer();
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
const stop = startPerfMonitor(scope);
return stop;
}, [serverId, agentId]);
// 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<ExplorerCheckoutContext | null>(() => {
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<AgentScreenMissingState>({
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;
toast.show("Reconnecting...", {
durationMs: 2200,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, toast]);
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<StreamItem[]>(() => {
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<StreamItem[]>(() => {
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,
});
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [shouldEmitSyncErrorToast, toast]);
if (viewState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
if (viewState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{viewState.message}</Text>
</View>
</View>
);
}
if (viewState.tag === "boot" || !effectiveAgent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
}
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
/>
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{resolvedAgentId && !isArchivingCurrentAgent && (
<AgentInputArea
agentId={resolvedAgentId}
serverId={serverId}
autoFocus
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={() =>
streamViewRef.current?.prepareForViewportChange()
}
onMessageSent={() =>
streamViewRef.current?.scrollToBottom("message-sent")
}
/>
)}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? (
<ExplorerSidebar
serverId={serverId}
workspaceId={effectiveAgent.cwd}
workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
) : null}
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>
Please wait while we archive this agent.
</Text>
</View>
) : null}
</View>
);
return (
<>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
{mainContent}
</GestureDetector>
) : (
mainContent
)}
{/* Mobile Explorer Sidebar Overlay */}
{showExplorerSidebar && isMobile && resolvedAgentId ? (
<ExplorerSidebar
serverId={serverId}
workspaceId={effectiveAgent.cwd}
workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
) : null}
</>
);
}
function AgentSessionUnavailableState({
serverLabel,
connectionStatus,
lastError,
isUnknownDaemon = false,
}: {
serverLabel: string;
connectionStatus: HostRuntimeConnectionStatus;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on
this device.
</Text>
<Text style={styles.statusText}>
Add the host in Settings or open an agent on a configured server to
continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
const isPreparingSession = connectionStatus === "online";
return (
<View style={styles.container}>
<View style={styles.centerState}>
{isConnecting || isPreparingSession ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
{isPreparingSession
? `Preparing ${serverLabel} session...`
: `Connecting to ${serverLabel}...`}
</Text>
<Text style={styles.statusText}>
{isPreparingSession
? "We will show this agent in a moment."
: "We will show this agent once the host is online."}
</Text>
</>
) : (
<>
<Text style={styles.offlineTitle}>
Reconnecting to {serverLabel}...
</Text>
<Text style={styles.offlineDescription}>
We will show this agent again as soon as the host is reachable.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
) : null}
</>
)}
</View>
</View>
);
}
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",
},
}));