diff --git a/codex-patch.txt b/codex-patch.txt deleted file mode 100644 index 9eb7b90ed..000000000 --- a/codex-patch.txt +++ /dev/null @@ -1 +0,0 @@ -patch diff --git a/package-lock.json b/package-lock.json index af9d61f3e..8b94b2efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "paseo", "version": "1.0.0", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "packages/server", @@ -6938,6 +6939,32 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.90.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", + "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", + "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -21010,6 +21037,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@tanstack/react-query": "^5.90.11", "buffer": "^6.0.3", "expo": "^54.0.18", "expo-audio": "~1.0.13", diff --git a/packages/app/package.json b/packages/app/package.json index 32195cf35..1ede8830d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,6 +20,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@tanstack/react-query": "^5.90.11", "buffer": "^6.0.3", "expo": "^54.0.18", "expo-audio": "~1.0.13", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 061f7ab49..fa730ff53 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -5,13 +5,37 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { SessionProvider } from "@/contexts/session-context"; import { RealtimeProvider } from "@/contexts/realtime-context"; -import { useSettings } from "@/hooks/use-settings"; -import { View, ActivityIndicator } from "react-native"; +import { useAppSettings } from "@/hooks/use-settings"; +import { View, ActivityIndicator, Text } from "react-native"; import { GlobalFooter } from "@/components/global-footer"; import { useUnistyles } from "react-native-unistyles"; import { FooterControlsProvider } from "@/contexts/footer-controls-context"; +import { DaemonRegistryProvider } from "@/contexts/daemon-registry-context"; +import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; -function AppContainer({ children }: { children: React.ReactNode }) { +function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return {children}; +} + +function AppContainer({ children }: { children: ReactNode }) { const { theme } = useUnistyles(); return ( @@ -21,61 +45,107 @@ function AppContainer({ children }: { children: React.ReactNode }) { ); } -function ProvidersWrapper({ children }: { children: React.ReactNode }) { - const { settings, isLoading } = useSettings(); +function ProvidersWrapper({ children }: { children: ReactNode }) { + const { settings, isLoading: settingsLoading } = useAppSettings(); + const { activeDaemon, isLoading: connectionsLoading } = useDaemonConnections(); - if (isLoading) { - return ( - - - - ); + if (settingsLoading || connectionsLoading) { + return ; + } + + if (!activeDaemon) { + return ; } return ( - + {children} ); } +function LoadingView() { + return ( + + + + ); +} + +function MissingDaemonView() { + return ( + + + + No daemon configured. Open Settings to add a server URL. + + + ); +} + export default function RootLayout() { return ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index 175bd0e5e..8dfe779b3 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -1,560 +1,148 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { - View, - Text, - ActivityIndicator, - Pressable, - Modal, - useWindowDimensions, - LayoutChangeEvent, -} from "react-native"; +import { useCallback, useEffect, useMemo } from "react"; +import type { ReactNode } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; -import { AgentStreamView } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { CreateAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal"; -import { useSession } from "@/contexts/session-context"; +import { useSessionDirectory } from "@/hooks/use-session-directory"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { Agent } from "@/contexts/session-context"; -import { useFooterControls } from "@/contexts/footer-controls-context"; -import { generateMessageId } from "@/types/stream"; -const DROPDOWN_WIDTH = 220; +type AgentMatch = { + serverId: string; + serverLabel: string; + agent: Agent; +}; -type BranchStatus = "idle" | "loading" | "ready" | "error"; - -function extractAgentModel(agent?: Agent | null): string | null { - if (!agent) { - return null; - } - - const directModel = typeof agent.model === "string" ? agent.model.trim() : ""; - if (directModel.length > 0) { - return directModel; - } - - const metadata = agent.persistence?.metadata; - if (!metadata || typeof metadata !== "object") { - return null; - } - - const persistedModel = (metadata as Record).model; - if (typeof persistedModel === "string" && persistedModel.trim().length > 0) { - return persistedModel.trim(); - } - - const extra = (metadata as Record).extra; - if (!extra || typeof extra !== "object") { - return null; - } - - const getModelFrom = (source: unknown) => { - if (!source || typeof source !== "object") { - return null; - } - const candidate = (source as Record).model; - return typeof candidate === "string" && candidate.trim().length > 0 - ? candidate.trim() - : null; - }; - - return ( - getModelFrom((extra as Record).codex) ?? - getModelFrom((extra as Record).claude) - ); -} - -export default function AgentScreen() { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const router = useRouter(); +export default function LegacyAgentRedirectScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const { - agents, - agentStreamState, - initializingAgents, - pendingPermissions, - respondToPermission, - initializeAgent, - refreshAgent, - setFocusedAgentId, - ws, - } = useSession(); - const { registerFooterControls, unregisterFooterControls } = useFooterControls(); - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - const [menuVisible, setMenuVisible] = useState(false); - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); - const [menuContentHeight, setMenuContentHeight] = useState(0); - const menuButtonRef = useRef(null); - const [branchStatus, setBranchStatus] = useState("idle"); - const [branchLabel, setBranchLabel] = useState(null); - const [branchError, setBranchError] = useState(null); - const [showCreateAgentModal, setShowCreateAgentModal] = useState(false); - const [createAgentInitialValues, setCreateAgentInitialValues] = - useState(); - const repoInfoRequestIdRef = useRef(null); - const hasPendingRepoRequest = repoInfoRequestIdRef.current !== null; - const branchDisplayValue = - branchStatus === "error" - ? branchError ?? "Unavailable" - : branchLabel ?? "Unknown"; - const shouldListenForBranchInfo = menuVisible || hasPendingRepoRequest; + const router = useRouter(); + const { theme } = useUnistyles(); + const { connectionStates } = useDaemonConnections(); + const sessionDirectory = useSessionDirectory(); + const agentId = typeof id === "string" ? id.trim() : undefined; - // Keyboard animation - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const bottomInset = useSharedValue(insets.bottom); - - useEffect(() => { - bottomInset.value = insets.bottom; - }, [insets.bottom, bottomInset]); - - const animatedKeyboardStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - bottomInset.value); - return { - transform: [{ translateY: -shift }], - }; - }); - - const agent = id ? agents.get(id) : undefined; - const streamItems = id ? agentStreamState.get(id) || [] : []; - const agentPermissions = new Map( - Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id) - ); - const agentModel = extractAgentModel(agent); - const modelDisplayValue = agentModel ?? "Unknown"; - - const resetBranchState = useCallback(() => { - repoInfoRequestIdRef.current = null; - setBranchStatus("idle"); - setBranchLabel(null); - setBranchError(null); - }, []); - - const sendGitRepoInfoRequest = useCallback( - (cwd: string) => { - if (!cwd) { - resetBranchState(); - return; - } - - const requestId = generateMessageId(); - repoInfoRequestIdRef.current = requestId; - setBranchStatus("loading"); - setBranchLabel(null); - setBranchError(null); - - ws.send({ - type: "session", - message: { - type: "git_repo_info_request", - cwd, - requestId, - }, - }); - }, - [resetBranchState, ws] - ); - - useEffect(() => { - if (!agent?.cwd) { - resetBranchState(); - return; + const matches = useMemo(() => { + if (!agentId) { + return []; } - sendGitRepoInfoRequest(agent.cwd); - }, [agent?.cwd, resetBranchState, sendGitRepoInfoRequest]); - - useEffect(() => { - if (!shouldListenForBranchInfo) { - return; - } - const unsubscribe = ws.on("git_repo_info_response", (message) => { - if (message.type !== "git_repo_info_response") { + const results: AgentMatch[] = []; + sessionDirectory.forEach((session, serverId) => { + if (!session) { return; } - - if ( - repoInfoRequestIdRef.current && - message.payload.requestId && - message.payload.requestId !== repoInfoRequestIdRef.current - ) { + const agent = session.agents.get(agentId); + if (!agent) { return; } - - if (agent?.cwd && message.payload.cwd && message.payload.cwd !== agent.cwd) { - return; - } - - repoInfoRequestIdRef.current = null; - - if (message.payload.error) { - setBranchStatus("error"); - setBranchError(message.payload.error); - setBranchLabel(null); - return; - } - - setBranchStatus("ready"); - setBranchError(null); - setBranchLabel(message.payload.currentBranch ?? null); + const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; + results.push({ serverId, serverLabel, agent }); }); - return () => { - unsubscribe(); - }; - }, [agent?.cwd, shouldListenForBranchInfo, ws]); + return results; + }, [agentId, sessionDirectory, connectionStates]); + + const hasSessions = sessionDirectory.size > 0; + const isRedirecting = Boolean(agentId && matches.length === 1); useEffect(() => { - if (!id) { - setFocusedAgentId(null); + if (!isRedirecting) { return; } - - setFocusedAgentId(id); - return () => { - setFocusedAgentId(null); - }; - }, [id, setFocusedAgentId]); - - const hasStreamState = id ? agentStreamState.has(id) : false; - const initializationState = id ? initializingAgents.get(id) : undefined; - const isInitializing = id - ? initializationState !== undefined - ? initializationState - : !hasStreamState - : false; - - useEffect(() => { - if (!id) { - return; - } - - if (initializationState !== undefined) { - return; - } - - if (hasStreamState) { - return; - } - - initializeAgent({ agentId: id }); - }, [id, initializeAgent, initializationState, hasStreamState]); - - const agentControls = useMemo(() => { - if (!id) return null; - return ; - }, [id]); - - useEffect(() => { - if (!agentControls || !agent || isInitializing) { - unregisterFooterControls(); - return; - } - - registerFooterControls(agentControls); - - return () => { - unregisterFooterControls(); - }; - }, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]); - - const recalculateMenuPosition = useCallback( - (onMeasured?: () => void) => { - requestAnimationFrame(() => { - const anchor = menuButtonRef.current; - - if (!anchor) { - if (onMeasured) { - onMeasured(); - } - return; - } - - anchor.measureInWindow((x, y, width, height) => { - const verticalOffset = 8; - const horizontalMargin = 16; - const desiredLeft = x + width - DROPDOWN_WIDTH; - const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin; - const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft); - - // Position menu below button - add insets.top to account for status bar - const buttonBottom = y + height + insets.top; - const top = buttonBottom + verticalOffset; - - // If menu would go off screen, clamp to visible area - const bottomEdge = top + menuContentHeight; - const maxBottom = windowHeight - horizontalMargin; - const clampedTop = bottomEdge > maxBottom - ? Math.max(verticalOffset, maxBottom - menuContentHeight) - : top; - - console.log('[Menu] Button position:', { x, y, width, height, insetsTop: insets.top }); - console.log('[Menu] Calculated position:', { buttonBottom, top, clampedTop, left: clampedLeft }); - - setMenuPosition({ - top: clampedTop, - left: clampedLeft, - }); - - if (onMeasured) { - onMeasured(); - } - }); - }); - }, - [menuContentHeight, windowHeight, windowWidth] - ); - - const handleOpenMenu = useCallback(() => { - if (agent?.cwd) { - sendGitRepoInfoRequest(agent.cwd); - } - - recalculateMenuPosition(() => { - setMenuVisible(true); + const match = matches[0]; + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { + serverId: match.serverId, + agentId: match.agent.id, + }, }); - }, [agent?.cwd, recalculateMenuPosition, sendGitRepoInfoRequest]); + }, [isRedirecting, matches, router]); - const handleCloseMenu = useCallback(() => { - setMenuVisible(false); - setMenuContentHeight(0); - }, []); - - useEffect(() => { - if (!menuVisible) { - return; - } - - recalculateMenuPosition(); - }, [menuVisible, recalculateMenuPosition]); - - const handleMenuLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - setMenuContentHeight((current) => (current === height ? current : height)); - }, []); - - const handleBackToHome = useCallback(() => { + const handleGoHome = useCallback(() => { router.replace("/"); }, [router]); - const handleViewChanges = useCallback(() => { - handleCloseMenu(); - if (id) { - router.push(`/git-diff?agentId=${id}`); - } - }, [id, router, handleCloseMenu]); - - const handleBrowseFiles = useCallback(() => { - handleCloseMenu(); - if (id) { - router.push(`/file-explorer?agentId=${id}`); - } - }, [handleCloseMenu, id, router]); - - const handleRefreshAgent = useCallback(() => { - if (!id) { - return; - } - handleCloseMenu(); - refreshAgent({ agentId: id }); - }, [handleCloseMenu, id, refreshAgent]); - - const handleCreateNewAgent = useCallback(() => { - if (!agent) { - return; - } - handleCloseMenu(); - setCreateAgentInitialValues({ - workingDir: agent.cwd, - provider: agent.provider, - modeId: agent.currentModeId, - model: agentModel ?? undefined, - }); - setShowCreateAgentModal(true); - }, [agent, agentModel, handleCloseMenu]); - - const handleCloseCreateAgentModal = useCallback(() => { - setShowCreateAgentModal(false); - }, []); - - const createAgentModal = ( - + const handleSelectMatch = useCallback( + (match: AgentMatch) => { + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { + serverId: match.serverId, + agentId: match.agent.id, + }, + }); + }, + [router] ); + let body: ReactNode = null; - if (!agent) { - return ( - <> - - - - Agent not found - + if (!agentId) { + body = ( + + Missing agent + + This link is missing an agent id. Go back to the Agents screen to pick one. + + + Go Home + + + ); + } else if (!hasSessions || isRedirecting) { + body = ( + + + + {isRedirecting ? "Opening your agent..." : "Looking for your agent..."} + + + ); + } else if (matches.length === 0) { + body = ( + + Agent not found + + We couldn't find "{agentId}" on any connected daemon. Make sure the daemon is online + or reopen the agent from the Home screen after it reconnects. + + + Go Home + + + ); + } else if (matches.length > 1) { + body = ( + + Pick a daemon + + Multiple daemons have an agent with this id. Choose the one you intended to open. + + + {matches.map((match) => ( + handleSelectMatch(match)} + > + {match.serverLabel} + + {match.agent.cwd} + + + ))} - {createAgentModal} - + ); } return ( - <> - - {/* Header */} - - - - - - } - /> - - {/* Content Area with Keyboard Animation */} - - - {isInitializing ? ( - - - Loading agent... - - ) : ( - - respondToPermission(agentId, requestId, response) - } - /> - )} - - - - {/* Dropdown Menu */} - - - - - - - Directory - - {agent.cwd} - - - - - Model - - {modelDisplayValue} - - - - - Branch - - {branchStatus === "loading" ? ( - <> - - Fetching… - - ) : ( - - {branchDisplayValue} - - )} - - - - - - - - - View Changes - - - - Browse Files - - - - New Agent - - - - - {isInitializing ? "Refreshing..." : "Refresh"} - - {isInitializing && ( - - )} - - - - - - {createAgentModal} - + + + {body} + ); } @@ -563,103 +151,59 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, content: { flex: 1, + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[8], }, - loadingContainer: { + centerState: { flex: 1, - alignItems: "center", justifyContent: "center", - gap: 16, - }, - loadingText: { - fontSize: theme.fontSize.base, - color: theme.colors.mutedForeground, - }, - errorContainer: { - flex: 1, alignItems: "center", - justifyContent: "center", + gap: theme.spacing[4], }, - errorText: { - fontSize: theme.fontSize.lg, - color: theme.colors.mutedForeground, - }, - menuButton: { - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - menuOverlay: { - flex: 1, - }, - menuBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - dropdownMenu: { - backgroundColor: theme.colors.popover, - borderRadius: theme.borderRadius.lg, - padding: theme.spacing[2], - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, - menuMetaContainer: { - gap: theme.spacing[2], - marginBottom: theme.spacing[2], - }, - menuMetaRow: { - gap: theme.spacing[1], - }, - menuMetaLabel: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - letterSpacing: 0.5, - textTransform: "uppercase", - }, - menuMetaValue: { - fontSize: theme.fontSize.sm, + title: { + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, + textAlign: "center", }, - menuMetaValueRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - menuMetaPendingText: { + subtitle: { fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, + textAlign: "center", }, - menuMetaValueError: { - color: theme.colors.destructive, - }, - menuDivider: { - height: StyleSheet.hairlineWidth, - backgroundColor: theme.colors.border, - marginVertical: theme.spacing[2], - }, - menuItem: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], + primaryButton: { + marginTop: theme.spacing[2], + paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.primary, }, - menuItemDisabled: { - opacity: 0.6, + primaryButtonText: { + color: theme.colors.primaryForeground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, }, - menuItemSpinner: { - marginLeft: "auto", + matchList: { + width: "100%", + gap: theme.spacing[3], }, - menuItemText: { + matchButton: { + width: "100%", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.muted, + }, + matchLabel: { fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, - fontWeight: theme.fontWeight.normal, + }, + matchMeta: { + marginTop: theme.spacing[1], + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, }, })); diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx new file mode 100644 index 000000000..4a2edd422 --- /dev/null +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -0,0 +1,769 @@ +import { useEffect, useMemo, useRef, useCallback, useState } from "react"; +import { + View, + Text, + ActivityIndicator, + Pressable, + Modal, + useWindowDimensions, + LayoutChangeEvent, +} from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle, Download } from "lucide-react-native"; +import { BackHeader } from "@/components/headers/back-header"; +import { AgentStreamView } from "@/components/agent-stream-view"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal"; +import type { Agent, SessionContextValue } from "@/contexts/session-context"; +import { useFooterControls } from "@/contexts/footer-controls-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session"; +import { useDaemonRequest } from "@/hooks/use-daemon-request"; +import type { SessionOutboundMessage } from "@server/server/messages"; + +const DROPDOWN_WIDTH = 220; + +type GitRepoInfoResponseMessage = Extract< + SessionOutboundMessage, + { type: "git_repo_info_response" } +>; + +type BranchStatus = "idle" | "loading" | "ready" | "error"; + +function extractAgentModel(agent?: Agent | null): string | null { + if (!agent) { + return null; + } + + const directModel = typeof agent.model === "string" ? agent.model.trim() : ""; + if (directModel.length > 0) { + return directModel; + } + + const metadata = agent.persistence?.metadata; + if (!metadata || typeof metadata !== "object") { + return null; + } + + const persistedModel = (metadata as Record).model; + if (typeof persistedModel === "string" && persistedModel.trim().length > 0) { + return persistedModel.trim(); + } + + const extra = (metadata as Record).extra; + if (!extra || typeof extra !== "object") { + return null; + } + + const getModelFrom = (source: unknown) => { + if (!source || typeof source !== "object") { + return null; + } + const candidate = (source as Record).model; + return typeof candidate === "string" && candidate.trim().length > 0 + ? candidate.trim() + : null; + }; + + return ( + getModelFrom((extra as Record).codex) ?? + getModelFrom((extra as Record).claude) + ); +} + +export default function AgentScreen() { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>(); + const resolvedAgentId = typeof agentId === "string" ? agentId : undefined; + const resolvedServerId = typeof serverId === "string" ? serverId : undefined; + const handleBackToHome = useCallback(() => { + router.replace("/"); + }, [router]); + const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections(); + let session: SessionContextValue | null = null; + + useEffect(() => { + if (!resolvedServerId) { + return; + } + + if (activeDaemonId === resolvedServerId) { + return; + } + + setActiveDaemonId(resolvedServerId); + }, [resolvedServerId, activeDaemonId, setActiveDaemonId]); + + try { + session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true }); + } catch (error) { + if (error instanceof DaemonSessionUnavailableError) { + session = null; + } else { + throw error; + } + } + + const connectionServerId = resolvedServerId ?? null; + const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; + const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon"; + const connectionStatus = connection?.status ?? "idle"; + const connectionStatusLabel = formatConnectionStatus(connectionStatus); + const lastConnectionError = connection?.lastError ?? null; + + if (!session) { + return ( + + ); + } + + const { + agents, + agentStreamState, + initializingAgents, + pendingPermissions, + initializeAgent, + refreshAgent, + setFocusedAgentId, + ws, + } = session; + const routeServerId = resolvedServerId ?? session.serverId; + const { registerFooterControls, unregisterFooterControls } = useFooterControls(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const [menuVisible, setMenuVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [menuContentHeight, setMenuContentHeight] = useState(0); + const menuButtonRef = useRef(null); + const [showCreateAgentModal, setShowCreateAgentModal] = useState(false); + const [showImportAgentModal, setShowImportAgentModal] = useState(false); + const [createAgentInitialValues, setCreateAgentInitialValues] = + useState(); + + // Keyboard animation + const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); + const bottomInset = useSharedValue(insets.bottom); + + useEffect(() => { + bottomInset.value = insets.bottom; + }, [insets.bottom, bottomInset]); + + const animatedKeyboardStyle = useAnimatedStyle(() => { + "worklet"; + const absoluteHeight = Math.abs(keyboardHeight.value); + const shift = Math.max(0, absoluteHeight - bottomInset.value); + return { + transform: [{ translateY: -shift }], + }; + }); + + const agent = resolvedAgentId ? agents.get(resolvedAgentId) : undefined; + const streamItems = resolvedAgentId ? agentStreamState.get(resolvedAgentId) || [] : []; + const agentPermissions = new Map( + Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === resolvedAgentId) + ); + const agentModel = extractAgentModel(agent); + const modelDisplayValue = agentModel ?? "Unknown"; + + const gitRepoInfoRequest = useDaemonRequest< + { cwd: string }, + { cwd: string; currentBranch: string | null }, + GitRepoInfoResponseMessage + >({ + ws, + responseType: "git_repo_info_response", + buildRequest: ({ params, requestId }) => ({ + type: "session", + message: { + type: "git_repo_info_request", + cwd: params?.cwd ?? ".", + requestId, + }, + }), + getRequestKey: (params) => params?.cwd ?? "default", + selectData: (message) => ({ + cwd: message.payload.cwd, + currentBranch: message.payload.currentBranch ?? null, + }), + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + keepPreviousData: false, + }); + const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } = gitRepoInfoRequest; + const branchStatus: BranchStatus = !agent?.cwd + ? "idle" + : gitRepoInfoRequest.status === "loading" + ? "loading" + : gitRepoInfoRequest.status === "error" + ? "error" + : gitRepoInfoRequest.status === "success" + ? "ready" + : "idle"; + const branchLabel = gitRepoInfoRequest.data?.currentBranch ?? null; + const branchError = gitRepoInfoRequest.error?.message ?? null; + const branchDisplayValue = + branchStatus === "error" + ? branchError ?? "Unavailable" + : branchLabel ?? "Unknown"; + + useEffect(() => { + if (!agent?.cwd) { + resetGitRepoInfo(); + return; + } + fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {}); + }, [agent?.cwd, fetchGitRepoInfo, resetGitRepoInfo]); + + useEffect(() => { + if (!resolvedAgentId) { + setFocusedAgentId(null); + return; + } + + setFocusedAgentId(resolvedAgentId); + return () => { + setFocusedAgentId(null); + }; + }, [resolvedAgentId, setFocusedAgentId]); + + const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false; + const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined; + const isInitializing = resolvedAgentId + ? initializationState !== undefined + ? initializationState + : !hasStreamState + : false; + + useEffect(() => { + if (!resolvedAgentId) { + return; + } + + if (initializationState !== undefined) { + return; + } + + if (hasStreamState) { + return; + } + + initializeAgent({ agentId: resolvedAgentId }); + }, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]); + + const agentControls = useMemo(() => { + if (!resolvedAgentId) return null; + return ; + }, [resolvedAgentId, routeServerId]); + + useEffect(() => { + if (!agentControls || !agent || isInitializing) { + unregisterFooterControls(); + return; + } + + registerFooterControls(agentControls); + + return () => { + unregisterFooterControls(); + }; + }, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]); + + const recalculateMenuPosition = useCallback( + (onMeasured?: () => void) => { + requestAnimationFrame(() => { + const anchor = menuButtonRef.current; + + if (!anchor) { + if (onMeasured) { + onMeasured(); + } + return; + } + + anchor.measureInWindow((x, y, width, height) => { + const verticalOffset = 8; + const horizontalMargin = 16; + const desiredLeft = x + width - DROPDOWN_WIDTH; + const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin; + const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft); + + // Position menu below button using the raw coordinates from measureInWindow + const buttonBottom = y + height; + const top = buttonBottom + verticalOffset; + + // If menu would go off screen, clamp to visible area + const bottomEdge = top + menuContentHeight; + const maxBottom = windowHeight - horizontalMargin; + const clampedTop = bottomEdge > maxBottom + ? Math.max(verticalOffset, maxBottom - menuContentHeight) + : top; + + setMenuPosition({ + top: clampedTop, + left: clampedLeft, + }); + + if (onMeasured) { + onMeasured(); + } + }); + }); + }, + [menuContentHeight, windowHeight, windowWidth] + ); + + const handleOpenMenu = useCallback(() => { + if (agent?.cwd) { + fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {}); + } + + recalculateMenuPosition(() => { + setMenuVisible(true); + }); + }, [agent?.cwd, fetchGitRepoInfo, recalculateMenuPosition]); + + const handleCloseMenu = useCallback(() => { + setMenuVisible(false); + setMenuContentHeight(0); + }, []); + + useEffect(() => { + if (!menuVisible) { + return; + } + + recalculateMenuPosition(); + }, [menuVisible, recalculateMenuPosition]); + + const handleMenuLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setMenuContentHeight((current) => (current === height ? current : height)); + }, []); + + const handleViewChanges = useCallback(() => { + handleCloseMenu(); + if (resolvedAgentId) { + router.push({ + pathname: "/git-diff", + params: { + agentId: resolvedAgentId, + serverId: routeServerId, + }, + }); + } + }, [resolvedAgentId, routeServerId, router, handleCloseMenu]); + + const handleBrowseFiles = useCallback(() => { + handleCloseMenu(); + if (resolvedAgentId) { + router.push({ + pathname: "/file-explorer", + params: { + agentId: resolvedAgentId, + serverId: routeServerId, + }, + }); + } + }, [handleCloseMenu, resolvedAgentId, routeServerId, router]); + + const handleRefreshAgent = useCallback(() => { + if (!resolvedAgentId) { + return; + } + handleCloseMenu(); + refreshAgent({ agentId: resolvedAgentId }); + }, [handleCloseMenu, resolvedAgentId, refreshAgent]); + + const handleCreateNewAgent = useCallback(() => { + if (!agent) { + return; + } + handleCloseMenu(); + setCreateAgentInitialValues({ + workingDir: agent.cwd, + provider: agent.provider, + modeId: agent.currentModeId, + model: agentModel ?? undefined, + }); + setShowCreateAgentModal(true); + }, [agent, agentModel, handleCloseMenu]); + + const handleCloseCreateAgentModal = useCallback(() => { + setShowCreateAgentModal(false); + }, []); + + const handleImportAgent = useCallback(() => { + handleCloseMenu(); + setShowImportAgentModal(true); + }, [handleCloseMenu]); + + const handleCloseImportAgentModal = useCallback(() => { + setShowImportAgentModal(false); + }, []); + + const createAgentModal = ( + + ); + + const importAgentModal = ( + + ); + + if (!agent) { + return ( + <> + + + + Agent not found + + + {createAgentModal} + {importAgentModal} + + ); + } + + return ( + <> + + {/* Header */} + + + + + + } + /> + + {/* Content Area with Keyboard Animation */} + + + {isInitializing ? ( + + + Loading agent... + + ) : ( + + )} + + + + {/* Dropdown Menu */} + + + + + + + Directory + + {agent.cwd} + + + + + Model + + {modelDisplayValue} + + + + + Branch + + {branchStatus === "loading" ? ( + <> + + Fetching… + + ) : ( + + {branchDisplayValue} + + )} + + + + + + + + + View Changes + + + + Browse Files + + + + Import Agent + + + + New Agent + + + + + {isInitializing ? "Refreshing..." : "Refresh"} + + {isInitializing && ( + + )} + + + + + + {createAgentModal} + {importAgentModal} + + ); +} + +function AgentSessionUnavailableState({ + onBack, + serverLabel, + connectionStatus, + connectionStatusLabel, + lastError, +}: { + onBack: () => void; + serverLabel: string; + connectionStatus: ConnectionStatus; + connectionStatusLabel: string; + lastError: string | null; +}) { + const isConnecting = connectionStatus === "connecting"; + + return ( + + + + {isConnecting ? ( + <> + + Connecting to {serverLabel}... + We'll show this agent once the daemon is online. + + ) : ( + <> + + Can't open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}. + + + Connect this daemon or switch to another one to continue. + + {lastError ? {lastError} : null} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + content: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + loadingText: { + fontSize: theme.fontSize.base, + color: theme.colors.mutedForeground, + }, + 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.mutedForeground, + textAlign: "center", + }, + statusText: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + errorDetails: { + marginTop: theme.spacing[1], + textAlign: "center", + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + }, + menuButton: { + padding: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + menuOverlay: { + flex: 1, + }, + menuBackdrop: { + ...StyleSheet.absoluteFillObject, + }, + dropdownMenu: { + backgroundColor: theme.colors.popover, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[2], + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + menuMetaContainer: { + gap: theme.spacing[2], + marginBottom: theme.spacing[2], + }, + menuMetaRow: { + gap: theme.spacing[1], + }, + menuMetaLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + letterSpacing: 0.5, + textTransform: "uppercase", + }, + menuMetaValue: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + menuMetaValueRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + menuMetaPendingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + menuMetaValueError: { + color: theme.colors.destructive, + }, + menuDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: theme.colors.border, + marginVertical: theme.spacing[2], + }, + menuItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + }, + menuItemDisabled: { + opacity: 0.6, + }, + menuItemSpinner: { + marginLeft: "auto", + }, + menuItemText: { + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index b1b533e9d..6ffb28f42 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -29,26 +29,104 @@ import { X, } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; -import { useSession, type ExplorerEntry } from "@/contexts/session-context"; +import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-context"; +import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session"; export default function FileExplorerScreen() { - const { theme } = useUnistyles(); const { agentId, path: pathParamRaw, file: fileParamRaw, + serverId, } = useLocalSearchParams<{ agentId: string; path?: string | string[]; file?: string | string[]; + serverId?: string; }>(); + const resolvedServerId = typeof serverId === "string" ? serverId : undefined; + const { connectionStates } = useDaemonConnections(); + + let session: SessionContextValue | null = null; + + try { + session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true }); + } catch (error) { + if (error instanceof DaemonSessionUnavailableError) { + session = null; + } else { + throw error; + } + } + + const connectionServerId = resolvedServerId ?? null; + const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; + const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected daemon"; + const connectionStatus = connection?.status ?? "idle"; + const connectionStatusLabel = formatConnectionStatus(connectionStatus); + const lastError = connection?.lastError ?? null; + + if (!session) { + return ( + + ); + } + + const routeServerId = resolvedServerId ?? session.serverId; + + return ( + + ); +} + +type FileExplorerContentProps = { + session: SessionContextValue; + agentId?: string; + pathParamRaw?: string | string[]; + fileParamRaw?: string | string[]; + routeServerId: string; +}; + +type FileExplorerSessionUnavailableProps = { + agentId?: string; + serverId?: string; + serverLabel: string; + connectionStatus: ConnectionStatus; + connectionStatusLabel: string; + lastError: string | null; +}; + +function FileExplorerContent({ + session, + agentId, + pathParamRaw, + fileParamRaw, + routeServerId, +}: FileExplorerContentProps) { + const { theme } = useUnistyles(); const { agents, fileExplorer, requestDirectoryListing, requestFilePreview, navigateExplorerBack, - } = useSession(); + } = session; const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [selectedEntryPath, setSelectedEntryPath] = useState(null); const pendingPathParamRef = useRef(null); @@ -258,12 +336,15 @@ export default function FileExplorerScreen() { const handleCloseExplorer = useCallback(() => { if (agentId) { - router.replace({ pathname: "/agent/[id]", params: { id: agentId } }); + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { serverId: routeServerId, agentId }, + }); return; } router.back(); - }, [agentId]); + }, [agentId, routeServerId]); const handleBackNavigation = useCallback(() => { if (!agentId) { @@ -576,6 +657,61 @@ export default function FileExplorerScreen() { ); } +function FileExplorerSessionUnavailable({ + agentId, + serverId, + serverLabel, + connectionStatus, + connectionStatusLabel, + lastError, +}: FileExplorerSessionUnavailableProps) { + const { theme } = useUnistyles(); + + const handleClose = useCallback(() => { + if (agentId && serverId) { + router.replace({ + pathname: "/agent/[serverId]/[agentId]", + params: { serverId, agentId }, + }); + return; + } + router.back(); + }, [agentId, serverId]); + + const isConnecting = connectionStatus === "connecting"; + + return ( + + + + + } + /> + + {isConnecting ? ( + <> + + Connecting to {serverLabel}... + We'll load files once this daemon is online. + + ) : ( + <> + + Can't open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}. + + Connect this daemon and try again. + {lastError ? {lastError} : null} + + )} + + + ); +} + function ViewToggle({ viewMode, onChange, @@ -828,6 +964,16 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, textAlign: "center", }, + statusText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + errorDetails: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + textAlign: "center", + }, emptyText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.base, diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx index b256f9e59..dbf30e853 100644 --- a/packages/app/src/app/git-diff.tsx +++ b/packages/app/src/app/git-diff.tsx @@ -3,7 +3,11 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native"; import { useLocalSearchParams } from "expo-router"; import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; -import { useSession } from "@/contexts/session-context"; +import type { SessionContextValue } from "@/contexts/session-context"; +import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session"; interface ParsedDiffFile { path: string; @@ -51,8 +55,55 @@ function parseDiff(diffText: string): ParsedDiffFile[] { } export default function GitDiffScreen() { - const { agentId } = useLocalSearchParams<{ agentId: string }>(); - const { agents, gitDiffs, requestGitDiff } = useSession(); + const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>(); + const resolvedServerId = typeof serverId === "string" ? serverId : undefined; + const { connectionStates } = useDaemonConnections(); + + let session: SessionContextValue | null = null; + + try { + session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true }); + } catch (error) { + if (error instanceof DaemonSessionUnavailableError) { + session = null; + } else { + throw error; + } + } + + const connectionServerId = resolvedServerId ?? null; + const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; + const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Active daemon"; + const connectionStatus = connection?.status ?? "idle"; + const connectionStatusLabel = formatConnectionStatus(connectionStatus); + const lastError = connection?.lastError ?? null; + + if (!session) { + return ( + + ); + } + + const routeServerId = resolvedServerId ?? session.serverId; + + return ; +} + +function GitDiffContent({ + session, + agentId, + routeServerId, +}: { + session: SessionContextValue; + agentId?: string; + routeServerId: string; +}) { + const { agents, gitDiffs, requestGitDiff } = session; const [isLoading, setIsLoading] = useState(true); const agent = agentId ? agents.get(agentId) : undefined; @@ -64,7 +115,6 @@ export default function GitDiffScreen() { return; } - // Always request fresh diff when screen mounts setIsLoading(true); requestGitDiff(agentId); @@ -85,6 +135,7 @@ export default function GitDiffScreen() { return ( + Server: {routeServerId} Agent not found @@ -99,6 +150,7 @@ export default function GitDiffScreen() { return ( + Server: {routeServerId} {isLoading ? ( @@ -156,11 +208,55 @@ export default function GitDiffScreen() { ); } +function SessionUnavailableState({ + serverLabel, + connectionStatus, + connectionStatusLabel, + lastError, +}: { + serverLabel: string; + connectionStatus: ConnectionStatus; + connectionStatusLabel: string; + lastError: string | null; +}) { + const isConnecting = connectionStatus === "connecting"; + + return ( + + + Server: {serverLabel} + + {isConnecting ? ( + <> + + Connecting to {serverLabel}... + We'll show changes once this session is online. + + ) : ( + <> + + Can't load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}. + + Connect this daemon or switch to another one to continue. + {lastError ? {lastError} : null} + + )} + + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.background, }, + metaText: { + paddingHorizontal: theme.spacing[6], + marginBottom: theme.spacing[2], + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, scrollView: { flex: 1, }, @@ -191,6 +287,18 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, textAlign: "center", }, + statusText: { + marginTop: theme.spacing[3], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + errorDetails: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + }, emptyContainer: { flex: 1, alignItems: "center", diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index de72edbc0..b766d4ab8 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,5 +1,5 @@ -import { View } from "react-native"; -import { useState, useCallback } from "react"; +import { View, Text } from "react-native"; +import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; @@ -9,15 +9,29 @@ import { EmptyState } from "@/components/empty-state"; import { AgentList } from "@/components/agent-list"; import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal"; import { useSession } from "@/contexts/session-context"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus, getConnectionStatusTone, type ConnectionStatusTone } from "@/utils/daemons"; +import { useLocalSearchParams } from "expo-router"; export default function HomeScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const { agents } = useSession(); + const { activeDaemonId, connectionStates } = useDaemonConnections(); + const aggregatedAgents = useAggregatedAgents(agents); const [showCreateModal, setShowCreateModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [createModalMounted, setCreateModalMounted] = useState(false); const [importModalMounted, setImportModalMounted] = useState(false); + const [pendingImportServerId, setPendingImportServerId] = useState(null); + const { modal, flow, action, serverId: serverIdParam } = useLocalSearchParams<{ + modal?: string; + flow?: string; + action?: string; + serverId?: string; + }>(); + const deepLinkHandledRef = useRef(null); // Keyboard animation const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -30,17 +44,38 @@ export default function HomeScreen() { }; }); - const hasAgents = agents.size > 0; + const aggregatedCount = aggregatedAgents.reduce((count, group) => count + group.agents.length, 0); + const hasAgents = aggregatedCount > 0; + const connectionIssues = useMemo(() => { + return Array.from(connectionStates.values()).filter( + (entry) => entry.status === "connecting" || entry.status === "offline" || entry.status === "error" + ); + }, [connectionStates]); + const statusColors: Record = { + success: theme.colors.palette.green[400], + warning: theme.colors.palette.amber[500], + error: theme.colors.destructive, + muted: theme.colors.mutedForeground, + }; const handleCreateAgent = useCallback(() => { setCreateModalMounted(true); setShowCreateModal(true); }, []); + const openImportModal = useCallback( + (serverIdOverride?: string | null) => { + const resolvedServerId = serverIdOverride ?? activeDaemonId ?? null; + setPendingImportServerId(resolvedServerId); + setImportModalMounted(true); + setShowImportModal(true); + }, + [activeDaemonId] + ); + const handleImportAgent = useCallback(() => { - setImportModalMounted(true); - setShowImportModal(true); - }, []); + openImportModal(); + }, [openImportModal]); const handleCloseCreateModal = useCallback(() => { setShowCreateModal(false); @@ -48,8 +83,40 @@ export default function HomeScreen() { const handleCloseImportModal = useCallback(() => { setShowImportModal(false); + setPendingImportServerId(null); }, []); + const wantsImportDeepLink = useMemo(() => { + const values = [modal, flow, action]; + return values.some( + (value) => typeof value === "string" && value.trim().toLowerCase() === "import" + ); + }, [action, flow, modal]); + const deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null; + const deepLinkKey = useMemo(() => { + if (!wantsImportDeepLink) { + return null; + } + return JSON.stringify({ + action: action ?? null, + flow: flow ?? null, + modal: modal ?? null, + serverId: deepLinkServerId, + }); + }, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]); + + useEffect(() => { + if (!wantsImportDeepLink || !deepLinkKey) { + deepLinkHandledRef.current = null; + return; + } + if (deepLinkHandledRef.current === deepLinkKey) { + return; + } + deepLinkHandledRef.current = deepLinkKey; + openImportModal(deepLinkServerId); + }, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]); + return ( {/* Header */} @@ -58,21 +125,56 @@ export default function HomeScreen() { onImportAgent={handleImportAgent} /> + {connectionIssues.length > 0 ? ( + + {connectionIssues.map((entry) => { + const tone = getConnectionStatusTone(entry.status); + const statusColor = statusColors[tone]; + return ( + + + + + {entry.daemon.label} · {formatConnectionStatus(entry.status)} + + + {entry.lastError ? ( + + {entry.lastError} + + ) : null} + + ); + })} + + ) : null} + {/* Content Area with Keyboard Animation */} {hasAgents ? ( - + ) : ( - + )} {/* Create Agent Modal */} {createModalMounted ? ( - + ) : null} {importModalMounted ? ( - + ) : null} ); @@ -83,6 +185,39 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, + connectionBanner: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[4], + gap: theme.spacing[2], + }, + connectionRow: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + gap: theme.spacing[1], + }, + connectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + connectionDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + }, + connectionLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + connectionError: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + }, content: { flex: 1, }, diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 8d096a943..d5e62069a 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import type { MutableRefObject } from "react"; import { View, Text, @@ -11,11 +12,14 @@ import { Platform, } from "react-native"; import { router } from "expo-router"; -import { StyleSheet } from "react-native-unistyles"; -import { useSettings } from "@/hooks/use-settings"; -import { useSession } from "@/contexts/session-context"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useAppSettings } from "@/hooks/use-settings"; +import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context"; +import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { theme as defaultTheme } from "@/styles/theme"; import { BackHeader } from "@/components/headers/back-header"; +import { useSessionForServer } from "@/hooks/use-session-directory"; const delay = (ms: number) => new Promise((resolve) => { @@ -67,6 +71,9 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], }, + inputDisabled: { + opacity: theme.opacity[50], + }, helperText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, @@ -115,11 +122,23 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], marginBottom: theme.spacing[3], }, + daemonCard: { + gap: theme.spacing[2], + }, + daemonCardActive: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.palette.blue[500], + }, settingRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", }, + daemonHeaderRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, settingContent: { flex: 1, }, @@ -132,6 +151,87 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, }, + connectionStatusBadge: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + connectionStatusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + }, + connectionStatusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + connectionErrorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], + }, + daemonActionsRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + daemonActionButton: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + daemonActionText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + daemonActionDestructive: { + borderColor: theme.colors.destructive, + }, + daemonActionDestructiveText: { + color: theme.colors.destructive, + }, + daemonFormActionsRow: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + marginTop: theme.spacing[3], + }, + daemonActionPrimary: { + backgroundColor: theme.colors.palette.blue[500], + borderColor: theme.colors.palette.blue[500], + }, + daemonActionPrimaryText: { + color: theme.colors.palette.white, + }, + daemonActionDisabled: { + opacity: theme.opacity[50], + }, + testResultSuccessText: { + color: theme.colors.palette.green[400], + fontSize: theme.fontSize.xs, + }, + testResultErrorText: { + color: theme.colors.palette.red[200], + fontSize: theme.fontSize.xs, + }, + testResultInfoText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + addButton: { + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + alignItems: "center", + }, + addButtonText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, themeCardDisabled: { backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, @@ -202,24 +302,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, - restartButton: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - marginTop: theme.spacing[3], - backgroundColor: theme.colors.destructive, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - }, - restartButtonDisabled: { - opacity: theme.opacity[50], - }, - restartButtonText: { - color: theme.colors.destructiveForeground, - textAlign: "center", - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - }, footer: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, @@ -238,11 +320,20 @@ const styles = StyleSheet.create((theme) => ({ }, })); -export default function SettingsScreen() { - const { settings, isLoading, updateSettings, resetSettings } = useSettings(); - const { restartServer, ws } = useSession(); +type DaemonTestState = { + status: "idle" | "testing" | "success" | "error"; + message?: string; +}; - const [serverUrl, setServerUrl] = useState(settings.serverUrl); +export default function SettingsScreen() { + const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); + const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon, setDefaultDaemon } = useDaemonRegistry(); + const { activeDaemon, activeDaemonId, setActiveDaemonId, connectionStates, updateConnectionStatus } = useDaemonConnections(); + const activeDaemonSession = useSessionForServer(activeDaemonId ?? null); + const activeDaemonConnection = activeDaemonId ? connectionStates.get(activeDaemonId) : null; + const activeDaemonStatusLabel = activeDaemonConnection ? formatConnectionStatus(activeDaemonConnection.status) : null; + + const [serverUrl, setServerUrl] = useState(activeDaemon?.wsUrl ?? ""); const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker); const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn); const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme); @@ -252,9 +343,24 @@ export default function SettingsScreen() { success: boolean; message: string; } | null>(null); - const [isRestarting, setIsRestarting] = useState(false); + const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false); + const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string; autoConnect: boolean }>( + { id: null, label: "", wsUrl: "", autoConnect: true } + ); + const [isSavingDaemon, setIsSavingDaemon] = useState(false); + const [daemonTestStates, setDaemonTestStates] = useState>(() => new Map()); + const isLoading = settingsLoading || daemonLoading; + const baselineServerUrl = activeDaemon?.wsUrl ?? ""; const isMountedRef = useRef(true); - const wsIsConnectedRef = useRef(ws.isConnected); + const isServerConfigLocked = Boolean(activeDaemon && !activeDaemonSession); + const serverDescriptionText = activeDaemon + ? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${ + isServerConfigLocked ? " - Session unavailable" : "" + }` + : "No active daemon selected."; + const serverHelperText = isServerConfigLocked + ? `Connect to ${activeDaemon?.label ?? "this daemon"} to edit its server URL.` + : "Must be a valid WebSocket URL (ws:// or wss://)"; useEffect(() => { return () => { @@ -262,10 +368,6 @@ export default function SettingsScreen() { }; }, []); - useEffect(() => { - wsIsConnectedRef.current = ws.isConnected; - }, [ws.isConnected]); - const waitForCondition = useCallback( async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { const deadline = Date.now() + timeoutMs; @@ -336,71 +438,147 @@ export default function SettingsScreen() { }); }, []); - const waitForServerRestart = useCallback(async () => { - const maxAttempts = 12; - const retryDelayMs = 2500; - const disconnectTimeoutMs = 7000; - const reconnectTimeoutMs = 10000; + const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => { + if (profile) { + setDaemonForm({ + id: profile.id, + label: profile.label, + wsUrl: profile.wsUrl, + autoConnect: profile.autoConnect, + }); + } else { + setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true }); + } + setIsDaemonFormVisible(true); + }, []); - if (wsIsConnectedRef.current) { - await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs); + const handleCloseDaemonForm = useCallback(() => { + setIsDaemonFormVisible(false); + setDaemonForm({ id: null, label: "", wsUrl: "", autoConnect: true }); + }, []); + + const handleSubmitDaemonForm = useCallback(async () => { + if (!daemonForm.label.trim()) { + Alert.alert("Label required", "Please enter a label for the daemon."); + return; + } + if (!validateServerUrl(daemonForm.wsUrl)) { + Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://"); + return; } - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await testServerConnection(settings.serverUrl); - const reconnected = await waitForCondition( - () => wsIsConnectedRef.current, - reconnectTimeoutMs - ); - - if (isMountedRef.current) { - setIsRestarting(false); - if (!reconnected) { - Alert.alert( - "Server reachable", - "The server came back online but the app has not reconnected yet." - ); - } - } - return; - } catch (error) { - console.warn( - `[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed`, - error - ); - if (attempt === maxAttempts) { - if (isMountedRef.current) { - setIsRestarting(false); - Alert.alert( - "Unable to reconnect", - "The server did not come back online. Please verify the daemon restarted." - ); - } - return; - } - await delay(retryDelayMs); + try { + setIsSavingDaemon(true); + const payload = { + label: daemonForm.label.trim(), + wsUrl: daemonForm.wsUrl.trim(), + autoConnect: daemonForm.autoConnect, + }; + if (daemonForm.id) { + await updateDaemon(daemonForm.id, payload); + setActiveDaemonId(daemonForm.id); + } else { + const created = await addDaemon(payload); + setActiveDaemonId(created.id); } + handleCloseDaemonForm(); + } catch (error) { + console.error("[Settings] Failed to save daemon", error); + Alert.alert("Error", "Unable to save daemon"); + } finally { + setIsSavingDaemon(false); } - }, [settings.serverUrl, testServerConnection, waitForCondition]); + }, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm, setActiveDaemonId]); + + const handleRemoveDaemon = useCallback( + (profile: DaemonProfile) => { + Alert.alert( + "Remove Daemon", + `Remove ${profile.label}?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: async () => { + try { + await removeDaemon(profile.id); + } catch (error) { + console.error("[Settings] Failed to remove daemon", error); + Alert.alert("Error", "Unable to remove daemon"); + } + }, + }, + ] + ); + }, + [removeDaemon] + ); + + const handleSetDefaultDaemon = useCallback( + (profile: DaemonProfile) => { + void setDefaultDaemon(profile.id); + }, + [setDefaultDaemon] + ); + + const handleSetActiveDaemon = useCallback( + (profile: DaemonProfile) => { + setActiveDaemonId(profile.id); + }, + [setActiveDaemonId] + ); + + const updateDaemonTestState = useCallback((daemonId: string, state: { status: "idle" | "testing" | "success" | "error"; message?: string }) => { + setDaemonTestStates((prev) => { + const next = new Map(prev); + next.set(daemonId, state); + return next; + }); + }, []); + + const handleTestDaemonConnection = useCallback( + async (profile: DaemonProfile) => { + const url = profile.wsUrl; + if (!validateServerUrl(url)) { + Alert.alert("Invalid URL", "Daemon URL must be ws:// or wss://"); + return; + } + updateDaemonTestState(profile.id, { status: "testing" }); + updateConnectionStatus(profile.id, "connecting"); + try { + await testServerConnection(url, 4000); + updateDaemonTestState(profile.id, { status: "success", message: "Reachable" }); + updateConnectionStatus(profile.id, "online", { lastOnlineAt: new Date().toISOString() }); + } catch (error) { + const message = error instanceof Error ? error.message : "Connection failed"; + updateDaemonTestState(profile.id, { status: "error", message }); + updateConnectionStatus(profile.id, "offline", { lastError: message }); + } + }, + [testServerConnection, updateConnectionStatus, updateDaemonTestState] + ); // Update local state when settings load useEffect(() => { - setServerUrl(settings.serverUrl); setUseSpeaker(settings.useSpeaker); setKeepScreenOn(settings.keepScreenOn); setTheme(settings.theme); }, [settings]); + useEffect(() => { + setServerUrl(activeDaemon?.wsUrl ?? ""); + }, [activeDaemon?.wsUrl]); + // Track changes useEffect(() => { const changed = - serverUrl !== settings.serverUrl || + serverUrl !== baselineServerUrl || useSpeaker !== settings.useSpeaker || keepScreenOn !== settings.keepScreenOn || theme !== settings.theme; setHasChanges(changed); - }, [serverUrl, useSpeaker, keepScreenOn, theme, settings]); + }, [serverUrl, baselineServerUrl, useSpeaker, keepScreenOn, theme, settings]); function validateServerUrl(url: string): boolean { try { @@ -411,6 +589,15 @@ export default function SettingsScreen() { } } + function deriveDaemonLabel(url: string): string { + try { + const parsed = new URL(url); + return parsed.hostname || "Daemon"; + } catch { + return "Daemon"; + } + } + async function handleSave() { // Validate server URL if (!validateServerUrl(serverUrl)) { @@ -423,13 +610,28 @@ export default function SettingsScreen() { } try { + const trimmedUrl = serverUrl.trim(); + await updateSettings({ - serverUrl, useSpeaker, keepScreenOn, theme, }); + if (activeDaemon) { + await updateDaemon(activeDaemon.id, { + wsUrl: trimmedUrl, + label: activeDaemon.label || deriveDaemonLabel(trimmedUrl), + }); + } else { + await addDaemon({ + label: deriveDaemonLabel(trimmedUrl), + wsUrl: trimmedUrl, + autoConnect: true, + isDefault: true, + }); + } + Alert.alert( "Settings Saved", "Your settings have been saved successfully.", @@ -478,58 +680,16 @@ export default function SettingsScreen() { const restartConfirmationMessage = "This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts."; - const beginServerRestart = useCallback(() => { - if (!wsIsConnectedRef.current) { - Alert.alert( - "Not Connected", - "Connect to the server before attempting a restart." - ); - return; - } - - setIsRestarting(true); - try { - restartServer("settings_screen_restart"); - } catch (error) { - setIsRestarting(false); - Alert.alert( - "Error", - "Failed to send restart request. Please ensure you are connected to the server." - ); - return; - } - - void waitForServerRestart(); - }, [restartServer, waitForServerRestart]); - - function handleRestartServer() { - if (Platform.OS === "web") { - const hasBrowserConfirm = - typeof globalThis !== "undefined" && - typeof (globalThis as any).confirm === "function"; - - const confirmed = hasBrowserConfirm - ? (globalThis as any).confirm(restartConfirmationMessage) - : true; - - if (confirmed) { - beginServerRestart(); - } - return; - } - - Alert.alert("Restart Server", restartConfirmationMessage, [ - { text: "Cancel", style: "cancel" }, - { - text: "Restart", - style: "destructive", - onPress: beginServerRestart, - }, - ]); - } - async function handleTestConnection() { + if (isServerConfigLocked) { + Alert.alert( + "Session unavailable", + `${activeDaemon?.label ?? "This daemon"} is not connected. Connect to it before testing the URL.` + ); + return; + } + if (!validateServerUrl(serverUrl)) { Alert.alert( "Invalid URL", @@ -579,10 +739,11 @@ export default function SettingsScreen() { {/* Server Configuration */} Server Configuration + {serverDescriptionText} WebSocket URL - Must be a valid WebSocket URL (ws:// or wss://) + {serverHelperText} {/* Test Connection Button */} @@ -604,7 +767,7 @@ export default function SettingsScreen() { disabled={isTesting || !validateServerUrl(serverUrl)} style={[ styles.testButton, - (isTesting || !validateServerUrl(serverUrl)) && + (isTesting || !validateServerUrl(serverUrl) || isServerConfigLocked) && styles.testButtonDisabled, ]} > @@ -640,6 +803,101 @@ export default function SettingsScreen() { )} + {/* Daemon Management */} + + Daemons + + {daemons.length === 0 ? ( + + No daemons configured. + + ) : ( + daemons.map((daemon) => { + const connection = connectionStates.get(daemon.id); + const connectionStatus = connection?.status ?? "idle"; + const lastConnectionError = connection?.lastError ?? null; + const testState = daemonTestStates.get(daemon.id); + return ( + + ); + }) + )} + + {isDaemonFormVisible ? ( + + {daemonForm.id ? "Edit Daemon" : "Add Daemon"} + Label + setDaemonForm((prev) => ({ ...prev, label: text }))} + placeholder="My Server" + placeholderTextColor={defaultTheme.colors.mutedForeground} + /> + + WebSocket URL + setDaemonForm((prev) => ({ ...prev, wsUrl: text }))} + placeholder="wss://example.com/ws" + placeholderTextColor={defaultTheme.colors.mutedForeground} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + + + Auto-connect + Connect automatically when Paseo launches. + + setDaemonForm((prev) => ({ ...prev, autoConnect: value }))} + trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }} + thumbColor={daemonForm.autoConnect ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]} + /> + + + + + Cancel + + + + {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save Daemon" : "Add Daemon"} + + + + + ) : ( + handleOpenDaemonForm()}> + Add Daemon + + )} + + {/* Audio Settings */} Audio @@ -728,26 +986,6 @@ export default function SettingsScreen() { Reset to Defaults - - - {isRestarting && ( - - )} - - {isRestarting ? "Restarting..." : "Restart Server"} - - {/* App Info */} @@ -760,3 +998,243 @@ export default function SettingsScreen() { ); } + +interface DaemonCardProps { + daemon: DaemonProfile; + isActive: boolean; + connectionStatus: ConnectionStatus; + lastError: string | null; + testState?: DaemonTestState; + onSetActive: (daemon: DaemonProfile) => void; + onSetDefault: (daemon: DaemonProfile) => void; + onTestConnection: (daemon: DaemonProfile) => void; + onEdit: (daemon: DaemonProfile) => void; + onRemove: (daemon: DaemonProfile) => void; + restartConfirmationMessage: string; + waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise; + testServerConnection: (url: string, timeoutMs?: number) => Promise; + isScreenMountedRef: MutableRefObject; +} + +function DaemonCard({ + daemon, + isActive, + connectionStatus, + lastError, + testState, + onSetActive, + onSetDefault, + onTestConnection, + onEdit, + onRemove, + restartConfirmationMessage, + waitForCondition, + testServerConnection, + isScreenMountedRef, +}: DaemonCardProps) { + const { theme } = useUnistyles(); + const statusLabel = formatConnectionStatus(connectionStatus); + const statusTone = getConnectionStatusTone(connectionStatus); + const statusColor = + statusTone === "success" + ? theme.colors.palette.green[400] + : statusTone === "warning" + ? theme.colors.palette.amber[500] + : statusTone === "error" + ? theme.colors.destructive + : theme.colors.mutedForeground; + const badgeText = isActive ? `Active · ${statusLabel}` : statusLabel; + const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; + const daemonSession = useSessionForServer(daemon.id); + const [isRestarting, setIsRestarting] = useState(false); + const wsIsConnectedRef = useRef(daemonSession?.ws.isConnected ?? false); + const isTesting = testState?.status === "testing"; + const sessionIsConnected = daemonSession?.ws.isConnected ?? false; + + useEffect(() => { + wsIsConnectedRef.current = sessionIsConnected; + }, [sessionIsConnected]); + + const waitForDaemonRestart = useCallback(async () => { + const maxAttempts = 12; + const retryDelayMs = 2500; + const disconnectTimeoutMs = 7000; + const reconnectTimeoutMs = 10000; + + if (wsIsConnectedRef.current) { + await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await testServerConnection(daemon.wsUrl); + const reconnected = await waitForCondition(() => wsIsConnectedRef.current, reconnectTimeoutMs); + + if (isScreenMountedRef.current) { + setIsRestarting(false); + if (!reconnected) { + Alert.alert( + "Server reachable", + `${daemon.label} came back online but Paseo has not reconnected yet.` + ); + } + } + return; + } catch (error) { + console.warn( + `[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed for ${daemon.label}`, + error + ); + if (attempt === maxAttempts) { + if (isScreenMountedRef.current) { + setIsRestarting(false); + Alert.alert( + "Unable to reconnect", + `${daemon.label} did not come back online. Please verify it restarted.` + ); + } + return; + } + await delay(retryDelayMs); + } + } + }, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]); + + const beginServerRestart = useCallback(() => { + if (!daemonSession) { + Alert.alert( + "Daemon unavailable", + `${daemon.label} is not connected. Select it to connect before restarting.` + ); + return; + } + + if (!wsIsConnectedRef.current) { + Alert.alert("Not Connected", "Connect to the server before attempting a restart."); + return; + } + + setIsRestarting(true); + try { + daemonSession.restartServer(`settings_daemon_restart_${daemon.id}`); + } catch (error) { + console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error); + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send restart request. Please ensure you are connected to the server." + ); + return; + } + + void waitForDaemonRestart(); + }, [daemon.id, daemon.label, daemonSession, waitForDaemonRestart]); + + const handleRestartPress = useCallback(() => { + if (!daemonSession) { + Alert.alert( + "Daemon unavailable", + `${daemon.label} is not connected. Select it to connect before restarting.` + ); + return; + } + + if (Platform.OS === "web") { + const hasBrowserConfirm = + typeof globalThis !== "undefined" && + typeof (globalThis as any).confirm === "function"; + + const confirmed = hasBrowserConfirm + ? (globalThis as any).confirm(`Restart ${daemon.label}? ${restartConfirmationMessage}`) + : true; + + if (confirmed) { + beginServerRestart(); + } + return; + } + + Alert.alert(`Restart ${daemon.label}`, restartConfirmationMessage, [ + { text: "Cancel", style: "cancel" }, + { + text: "Restart", + style: "destructive", + onPress: beginServerRestart, + }, + ]); + }, [beginServerRestart, daemon.label, daemonSession, restartConfirmationMessage]); + + return ( + + + {daemon.label} + + + {badgeText} + + + {daemon.wsUrl} + {connectionError ? {connectionError} : null} + {testState && testState.status !== "idle" ? ( + + {testState.message ?? (testState.status === "success" ? "Reachable" : "Testing...")} + + ) : null} + + {!isActive ? ( + onSetActive(daemon)}> + Set Active + + ) : null} + onSetDefault(daemon)}> + {daemon.isDefault ? "Default" : "Make Default"} + + onTestConnection(daemon)} + disabled={isTesting} + > + + {isTesting ? "Testing..." : "Test"} + + + + {isRestarting ? ( + + ) : ( + Restart + )} + + onEdit(daemon)}> + Edit + + onRemove(daemon)} + > + Remove + + + + ); +} diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx index 027355d3e..819c5ab8e 100644 --- a/packages/app/src/components/active-processes.tsx +++ b/packages/app/src/components/active-processes.tsx @@ -14,7 +14,7 @@ export interface ActiveProcessesProps { }>; viewMode: "orchestrator" | "agent"; activeAgentId: string | null; - onSelectAgent: (id: string) => void; + onSelectAgent: (serverId: string, id: string) => void; onSelectOrchestrator: () => void; } @@ -174,7 +174,7 @@ export function ActiveProcesses({ return ( onSelectAgent(agent.id)} + onPress={() => onSelectAgent(agent.serverId, agent.id)} style={({ pressed }) => [ styles.processItem, isActive ? styles.processItemActive : styles.processItemInactive, diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 94d17e81d..602542e7d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -20,7 +20,6 @@ import Animated, { FadeIn, FadeOut, } from "react-native-reanimated"; -import { useSession } from "@/contexts/session-context"; import { useRealtime } from "@/contexts/realtime-context"; import { useAudioRecorder } from "@/hooks/use-audio-recorder"; import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; @@ -31,6 +30,7 @@ import { RealtimeControls } from "./realtime-controls"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; +import { useDaemonSession } from "@/hooks/use-daemon-session"; type QueuedMessage = { id: string; @@ -40,6 +40,7 @@ type QueuedMessage = { interface AgentInputAreaProps { agentId: string; + serverId?: string; } const MIN_INPUT_HEIGHT = 50; @@ -68,7 +69,7 @@ type TextAreaHandle = { } & Record; }; -export function AgentInputArea({ agentId }: AgentInputAreaProps) { +export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const { theme } = useUnistyles(); const { ws, @@ -80,7 +81,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { saveDraftInput, queuedMessages: queuedMessagesByAgent, setQueuedMessages: setQueuedMessagesByAgent, - } = useSession(); + } = useDaemonSession(serverId); const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [userInput, setUserInput] = useState(""); @@ -137,7 +138,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { if (!userInput.trim() || !ws.isConnected) return; const message = userInput.trim(); - const imageUris = selectedImages.length > 0 ? selectedImages.map(img => img.uri) : undefined; + const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined; setUserInput(""); setSelectedImages([]); @@ -145,7 +146,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { setIsProcessing(true); try { - await sendAgentMessage(agentId, message, imageUris); + await sendAgentMessage(agentId, message, imageAttachments); } catch (error) { console.error("[AgentInput] Failed to send message:", error); } finally { @@ -710,7 +711,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { // Cancels current agent run before sending queued prompt handleCancelAgent(); - await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri)); + await sendAgentMessage(agentId, item.text, item.images); } const realtimeButton = ( @@ -846,7 +847,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { > - + {/* Right button group */} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 428330906..af254bb71 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,131 +1,143 @@ import { View, Text, Pressable, ScrollView, Modal } from "react-native"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { Agent } from "@/contexts/session-context"; -import { useSession } from "@/contexts/session-context"; import { formatTimeAgo } from "@/utils/time"; import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { type AggregatedAgentGroup } from "@/hooks/use-aggregated-agents"; +import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session"; interface AgentListProps { - agents: Map; + agentGroups: AggregatedAgentGroup[]; } -export function AgentList({ agents }: AgentListProps) { +export function AgentList({ agentGroups }: AgentListProps) { const { theme } = useUnistyles(); - const { deleteAgent } = useSession(); + const { setActiveDaemonId } = useDaemonConnections(); const [actionAgent, setActionAgent] = useState(null); + const [actionAgentServerId, setActionAgentServerId] = useState(null); + let actionSession = null; + let actionSessionUnavailable = false; + + try { + actionSession = useDaemonSession(actionAgentServerId ?? undefined, { suppressUnavailableAlert: true }); + } catch (error) { + if (error instanceof DaemonSessionUnavailableError) { + actionSessionUnavailable = true; + actionSession = null; + } else { + throw error; + } + } + + const deleteAgent = actionSession?.deleteAgent; const isActionSheetVisible = actionAgent !== null; - - // Sort agents by status so running agents stay at the top, then fall back to the - // persisted timestamp of the last user message, and finally their last activity - const agentArray = useMemo(() => { - return Array.from(agents.values()).sort((a, b) => { - const aIsRunning = a.status === "running"; - const bIsRunning = b.status === "running"; + const isActionDaemonUnavailable = Boolean(actionAgentServerId && actionSessionUnavailable); - if (aIsRunning && !bIsRunning) { - return -1; - } - if (!aIsRunning && bIsRunning) { - return 1; - } - - const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime(); - const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime(); - return sortB - sortA; - }); - }, [agents]); - - const handleAgentPress = useCallback((agentId: string) => { + const handleAgentPress = useCallback((serverId: string, agentId: string) => { if (isActionSheetVisible) { return; } - router.push(`/agent/${agentId}`); - }, [isActionSheetVisible]); + setActiveDaemonId(serverId); + router.push({ + pathname: "/agent/[serverId]/[agentId]", + params: { + serverId, + agentId, + }, + }); + }, [isActionSheetVisible, setActiveDaemonId]); - const handleAgentLongPress = useCallback((agent: Agent) => { + const handleAgentLongPress = useCallback((serverId: string, agent: Agent) => { + setActionAgentServerId(serverId); setActionAgent(agent); }, []); const handleCloseActionSheet = useCallback(() => { setActionAgent(null); + setActionAgentServerId(null); }, []); const handleDeleteAgent = useCallback(() => { - if (!actionAgent) { + if (!actionAgent || !deleteAgent) { return; } deleteAgent(actionAgent.id); setActionAgent(null); + setActionAgentServerId(null); }, [actionAgent, deleteAgent]); return ( <> - {agentArray.map((agent) => { - const statusColor = getAgentStatusColor(agent.status); - const statusLabel = getAgentStatusLabel(agent.status); - const timeAgo = formatTimeAgo(agent.lastActivityAt); - const providerLabel = getAgentProviderDefinition(agent.provider).label; + {agentGroups.map(({ serverId, serverLabel, agents }) => ( + + {serverLabel} + {agents.map((agent) => { + const statusColor = getAgentStatusColor(agent.status); + const statusLabel = getAgentStatusLabel(agent.status); + const timeAgo = formatTimeAgo(agent.lastActivityAt); + const providerLabel = getAgentProviderDefinition(agent.provider).label; + const rowServerId = agent.serverId ?? serverId; - return ( - [ - styles.agentItem, - pressed && styles.agentItemPressed, - ]} - onPress={() => handleAgentPress(agent.id)} - onLongPress={() => handleAgentLongPress(agent)} - > - - [ + styles.agentItem, + pressed && styles.agentItemPressed, + ]} + onPress={() => handleAgentPress(rowServerId, agent.id)} + onLongPress={() => handleAgentLongPress(rowServerId, agent)} > - {agent.title || "New Agent"} - - - - {agent.cwd} - - - - - + - - {providerLabel} - - + {agent.title || "New Agent"} + - - - - {statusLabel} + + {agent.cwd} + + + + + + + {providerLabel} + + + + + + + {statusLabel} + + + + + + {timeAgo} - - - {timeAgo} - - - - - ); - })} + + ); + })} + + ))} - Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project. + {isActionDaemonUnavailable + ? "Connect this daemon before managing its agents." + : "Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project."} - Delete agent + + {isActionDaemonUnavailable ? "Daemon offline" : "Delete agent"} + ({ paddingHorizontal: theme.spacing[4], paddingTop: theme.spacing[4], }, + section: { + marginBottom: theme.spacing[4], + }, + sectionLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.mutedForeground, + textTransform: "uppercase", + letterSpacing: 0.5, + marginBottom: theme.spacing[2], + }, agentItem: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], @@ -281,6 +314,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructiveForeground, fontWeight: theme.fontWeight.semibold, }, + sheetDeleteTextDisabled: { + opacity: 0.5, + }, sheetCancelButton: { backgroundColor: theme.colors.muted, }, diff --git a/packages/app/src/components/agent-sidebar.tsx b/packages/app/src/components/agent-sidebar.tsx index 210e55e04..ba24e3340 100644 --- a/packages/app/src/components/agent-sidebar.tsx +++ b/packages/app/src/components/agent-sidebar.tsx @@ -22,7 +22,7 @@ interface AgentSidebarProps { agents: Agent[]; activeAgentId: string | null; onClose: () => void; - onSelectAgent: (agentId: string) => void; + onSelectAgent: (serverId: string, agentId: string) => void; onNewAgent: () => void; edgeSwipeTranslateX?: SharedValue | null; } @@ -72,8 +72,8 @@ export function AgentSidebar({ } }, [isOpen]); - function handleAgentSelect(agentId: string) { - onSelectAgent(agentId); + function handleAgentSelect(agentId: string, serverId: string) { + onSelectAgent(serverId, agentId); onClose(); } @@ -189,7 +189,7 @@ export function AgentSidebar({ styles.agentItem, { backgroundColor: isActive ? theme.colors.secondary : "transparent" }, ]} - onPress={() => handleAgentSelect(agent.id)} + onPress={() => handleAgentSelect(agent.id, agent.serverId)} > ; export interface AgentStreamViewProps { agentId: string; + serverId?: string; agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; - onPermissionResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; } export function AgentStreamView({ agentId, + serverId, agent, streamItems, pendingPermissions, - onPermissionResponse, }: AgentStreamViewProps) { const flatListRef = useRef>(null); const insets = useSafeAreaInsets(); @@ -63,7 +71,9 @@ export function AgentStreamView({ const isNearBottomRef = useRef(true); const isUserScrollingRef = useRef(false); const router = useRouter(); - const { requestDirectoryListing, requestFilePreview } = useSession(); + const session = useDaemonSession(serverId); + const { requestDirectoryListing, requestFilePreview, ws } = session; + const resolvedServerId = serverId ?? session.serverId; // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -97,6 +107,7 @@ export function AgentStreamView({ params: { agentId, path: normalized.directory, + serverId: resolvedServerId, ...(normalized.file ? { file: normalized.file } : {}), ...(target.lineStart !== undefined ? { lineStart: String(target.lineStart) } @@ -107,7 +118,7 @@ export function AgentStreamView({ }, }); }, - [agentId, requestDirectoryListing, requestFilePreview, router] + [agentId, requestDirectoryListing, requestFilePreview, resolvedServerId, router] ); const handleScroll = useCallback( @@ -316,11 +327,7 @@ export function AgentStreamView({ {pendingPermissionItems.length > 0 ? ( {pendingPermissionItems.map((permission) => ( - + ))} ) : null} @@ -333,7 +340,7 @@ export function AgentStreamView({ ); - }, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]); + }, [pendingPermissionItems, showWorkingIndicator, ws]); const flatListData = useMemo(() => { return [...streamItems].reverse(); @@ -512,10 +519,10 @@ function WorkingIndicator() { // Permission Request Card Component function PermissionRequestCard({ permission, - onResponse, + ws, }: { permission: PendingPermission; - onResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; + ws: UseWebSocketReturn; }) { const { theme } = useUnistyles(); @@ -617,6 +624,50 @@ function PermissionRequestCard({ [theme] ); + const permissionResponse = useDaemonRequest< + { agentId: string; requestId: string; response: AgentPermissionResponse }, + { agentId: string; requestId: string }, + PermissionResolvedMessage + >({ + ws, + responseType: "agent_permission_resolved", + buildRequest: ({ params }) => ({ + type: "session", + message: { + type: "agent_permission_response", + agentId: params?.agentId ?? "", + requestId: params?.requestId ?? "", + response: params?.response ?? { behavior: "deny" }, + }, + }), + matchResponse: (message, context) => + message.payload.agentId === context.params?.agentId && + message.payload.requestId === context.params?.requestId, + getRequestKey: (params) => + params ? `${params.agentId}:${params.requestId}` : "default", + selectData: (message) => ({ + agentId: message.payload.agentId, + requestId: message.payload.requestId, + }), + timeoutMs: 15000, + keepPreviousData: false, + }); + const isResponding = permissionResponse.isLoading; + const handleResponse = useCallback( + (response: AgentPermissionResponse) => { + permissionResponse + .execute({ + agentId: permission.agentId, + requestId: permission.request.id, + response, + }) + .catch((error) => { + console.error("[PermissionRequestCard] Failed to respond to permission:", error); + }); + }, + [permission.agentId, permission.request.id, permissionResponse] + ); + return ( - onResponse(permission.agentId, request.id, { behavior: "allow" }) - } + onPress={() => handleResponse({ behavior: "allow" })} + disabled={isResponding} > - - Allow - + {isResponding ? ( + + ) : ( + + Allow + + )} - onResponse(permission.agentId, request.id, { + handleResponse({ behavior: "deny", message: "Denied by user", }) } + disabled={isResponding} > - - Deny - + {isResponding ? ( + + ) : ( + + Deny + + )} diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 5051ccd67..e0bc7da46 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -39,7 +39,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, Check, X, ChevronDown } from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import { useRecentPaths } from "@/hooks/use-recent-paths"; -import { useSession } from "@/contexts/session-context"; import { useRouter } from "expo-router"; import { generateMessageId } from "@/types/stream"; import { useAudioRecorder } from "@/hooks/use-audio-recorder"; @@ -50,6 +49,7 @@ import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, } from "@server/server/agent/provider-manifest"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { AgentProvider, AgentMode, @@ -58,7 +58,12 @@ import type { AgentTimelineItem, AgentModelDefinition, } from "@server/server/agent/agent-sdk-types"; -import type { WSInboundMessage } from "@server/server/messages"; +import { useDaemonRequest } from "@/hooks/use-daemon-request"; +import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useSession } from "@/contexts/session-context"; +import { useSessionForServer } from "@/hooks/use-session-directory"; +import type { UseWebSocketReturn } from "@/hooks/use-websocket"; export type CreateAgentInitialValues = { workingDir?: string; @@ -72,12 +77,14 @@ interface AgentFlowModalProps { onClose: () => void; flow: "create" | "import"; initialValues?: CreateAgentInitialValues; + serverId?: string | null; } interface ModalWrapperProps { isVisible: boolean; onClose: () => void; initialValues?: CreateAgentInitialValues; + serverId?: string | null; } const providerDefinitions = AGENT_PROVIDER_DEFINITIONS; @@ -150,6 +157,11 @@ type RepoInfoState = { isDirty: boolean; }; +type GitRepoInfoResponseMessage = Extract< + SessionOutboundMessage, + { type: "git_repo_info_response" } +>; + function formatRelativeTime(date: Date): string { const now = Date.now(); const diffMs = now - date.getTime(); @@ -188,6 +200,7 @@ function AgentFlowModal({ onClose, flow, initialValues, + serverId, }: AgentFlowModalProps) { const insets = useSafeAreaInsets(); const { height: screenHeight, width: screenWidth } = useWindowDimensions(); @@ -201,17 +214,121 @@ function AgentFlowModal({ const isCreateFlow = !isImportFlow; const { recentPaths, addRecentPath } = useRecentPaths(); + const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections(); + const daemonEntries = useMemo(() => Array.from(connectionStates.values()), [connectionStates]); + const initialServerId = useMemo(() => { + if (serverId) { + return serverId; + } + if (activeDaemonId) { + return activeDaemonId; + } + return daemonEntries[0]?.daemon.id ?? null; + }, [serverId, activeDaemonId, daemonEntries]); + const [selectedServerId, setSelectedServerId] = useState(initialServerId); + const activeSession = useSession(); + const selectedSession = useSessionForServer(selectedServerId); + const session = + selectedServerId === null ? activeSession : selectedSession ?? null; + + useEffect(() => { + if (!selectedServerId) { + setSelectedServerId(initialServerId); + return; + } + const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId); + if (!exists) { + setSelectedServerId(initialServerId); + } + }, [daemonEntries, selectedServerId, initialServerId]); + + useEffect(() => { + if (isVisible && initialServerId && selectedServerId !== initialServerId) { + setSelectedServerId(initialServerId); + } + }, [isVisible, initialServerId]); + + const ws = session?.ws ?? null; + const inertWebSocket = useMemo( + () => ({ + isConnected: false, + isConnecting: false, + conversationId: null, + lastError: null, + send: () => {}, + on: () => () => {}, + sendPing: () => {}, + sendUserMessage: () => {}, + }), + [] + ); + const effectiveWs = ws ?? inertWebSocket; + const createAgent = session?.createAgent; + const resumeAgent = session?.resumeAgent; + const sendAgentAudio = session?.sendAgentAudio; + const agents = session?.agents; + const providerModels = session?.providerModels; + const requestProviderModels = session?.requestProviderModels; + const gitRepoInfoRequest = useDaemonRequest< + { cwd: string }, + RepoInfoState, + GitRepoInfoResponseMessage + >({ + ws: effectiveWs, + responseType: "git_repo_info_response", + buildRequest: ({ params, requestId }) => ({ + type: "session", + message: { + type: "git_repo_info_request", + cwd: params?.cwd ?? ".", + requestId, + }, + }), + getRequestKey: (params) => params?.cwd ?? "default", + selectData: (message) => ({ + cwd: message.payload.cwd, + repoRoot: message.payload.repoRoot, + branches: message.payload.branches ?? [], + currentBranch: message.payload.currentBranch ?? null, + isDirty: Boolean(message.payload.isDirty), + }), + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + keepPreviousData: false, + }); const { - ws, - createAgent, - resumeAgent, - sendAgentAudio, - agents, - providerModels, - requestProviderModels, - } = useSession(); - const isWsConnected = ws.isConnected; + status: repoRequestStatus, + data: repoInfo, + error: repoRequestError, + execute: inspectRepoInfo, + reset: resetRepoInfo, + cancel: cancelRepoInfo, + } = gitRepoInfoRequest; + const isWsConnected = ws?.isConnected ?? false; const router = useRouter(); + const activeServerId = session?.serverId ?? null; + const selectedDaemonConnection = selectedServerId + ? connectionStates.get(selectedServerId) + : null; + const selectedDaemonLabel = + selectedDaemonConnection?.daemon.label ?? + selectedDaemonConnection?.daemon.wsUrl ?? + selectedServerId ?? + "Selected daemon"; + const selectedDaemonStatusLabel = selectedDaemonConnection + ? formatConnectionStatus(selectedDaemonConnection.status) + : "offline"; + const selectedDaemonIsUnavailable = Boolean( + selectedServerId && + (!session || selectedDaemonConnection?.status !== "online") + ); + const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim(); + const daemonAvailabilityError = selectedDaemonIsUnavailable + ? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. Connect to it before creating or importing agents.${ + selectedDaemonLastError ? ` ${selectedDaemonLastError}` : "" + }` + : null; + const isTargetDaemonReady = Boolean(session && !selectedDaemonIsUnavailable); const [isMounted, setIsMounted] = useState(isVisible); const [workingDir, setWorkingDir] = useState(""); @@ -245,13 +362,7 @@ function AgentFlowModal({ const [dictationVolume, setDictationVolume] = useState(0); const [dictationDebugInfo, setDictationDebugInfo] = useState(null); const [openDropdown, setOpenDropdown] = useState(null); - const [repoInfo, setRepoInfo] = useState(null); - const [repoInfoStatus, setRepoInfoStatus] = useState< - "idle" | "loading" | "ready" | "error" - >("idle"); - const [repoInfoError, setRepoInfoError] = useState(null); const pendingRequestIdRef = useRef(null); - const repoInfoRequestIdRef = useRef(null); const shouldSyncBaseBranchRef = useRef(true); const promptInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null @@ -353,6 +464,9 @@ function AgentFlowModal({ }, [applyInitialValues, isVisible]); const refreshProviderModels = useCallback(() => { + if (!requestProviderModels) { + return; + } const trimmed = workingDir.trim(); requestProviderModels(selectedProvider, { cwd: trimmed.length > 0 ? trimmed : undefined, @@ -466,13 +580,13 @@ function AgentFlowModal({ ); const agentDefinition = providerDefinitionMap.get(selectedProvider); const modeOptions = agentDefinition?.modes ?? []; - const modelState = providerModels.get(selectedProvider); + const modelState = providerModels?.get(selectedProvider); const availableModels = modelState?.models ?? []; const isModelLoading = modelState?.isLoading ?? false; const modelError = modelState?.error ?? null; useEffect(() => { - if (!isVisible) { + if (!isVisible || !providerModels || !requestProviderModels || !isTargetDaemonReady) { return; } const trimmed = workingDir.trim(); @@ -483,7 +597,7 @@ function AgentFlowModal({ requestProviderModels(selectedProvider, { cwd: trimmed.length > 0 ? trimmed : undefined, }); - }, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir]); + }, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir, isTargetDaemonReady]); const setPromptHeight = useCallback((nextHeight: number) => { const bounded = Math.min( PROMPT_MAX_HEIGHT, @@ -599,6 +713,9 @@ function AgentFlowModal({ }, [shouldAutoFocusPrompt]); const activeSessionIds = useMemo(() => { const ids = new Set(); + if (!agents) { + return ids; + } agents.forEach((agent) => { if (agent.sessionId) { ids.add(agent.sessionId); @@ -687,12 +804,9 @@ function AgentFlowModal({ setWorktreeSlugEdited(false); setErrorMessage(""); setIsLoading(false); - setRepoInfo(null); - setRepoInfoStatus("idle"); - setRepoInfoError(null); + resetRepoInfo(); setOpenDropdown(null); pendingRequestIdRef.current = null; - repoInfoRequestIdRef.current = null; shouldSyncBaseBranchRef.current = true; dictationRequestIdRef.current = null; setIsDictating(false); @@ -702,7 +816,8 @@ function AgentFlowModal({ if (dictationRecorder.isRecording?.()) { void dictationRecorder.stop().catch(() => {}); } - }, [setPromptHeight]); + cancelRepoInfo(); + }, [cancelRepoInfo, resetRepoInfo, setPromptHeight]); const handleDictationStart = useCallback(async () => { if (!isCreateFlow || isLoading || !isWsConnected || isDictating || isDictationProcessing) { @@ -739,6 +854,13 @@ function AgentFlowModal({ if (!isDictating || isDictationProcessing) { return; } + if (!sendAgentAudio) { + setErrorMessage( + daemonAvailabilityError ?? + "Connect to the selected daemon before using dictation." + ); + return; + } try { const audioData = await dictationRecorder.stop(); setIsDictating(false); @@ -760,19 +882,38 @@ function AgentFlowModal({ dictationRequestIdRef.current = null; setIsDictationProcessing(false); } - }, [dictationRecorder, isDictating, isDictationProcessing, sendAgentAudio]); + }, [ + daemonAvailabilityError, + dictationRecorder, + isDictating, + isDictationProcessing, + sendAgentAudio, + ]); const navigateToAgentIfNeeded = useCallback(() => { const agentId = pendingNavigationAgentIdRef.current; - if (!agentId) { + if (!agentId || !activeServerId) { return; } pendingNavigationAgentIdRef.current = null; + if (activeDaemonId !== activeServerId) { + setActiveDaemonId(activeServerId); + } InteractionManager.runAfterInteractions(() => { - router.push(`/agent/${agentId}`); + router.push({ + pathname: "/agent/[serverId]/[agentId]", + params: { + serverId: activeServerId, + agentId, + }, + }); }); - }, [router]); + }, [activeDaemonId, activeServerId, router, setActiveDaemonId]); + + const handleSelectServer = useCallback((serverId: string) => { + setSelectedServerId(serverId); + }, []); const handleCloseAnimationComplete = useCallback(() => { console.log("[CreateAgentModal] close animation complete – resetting form"); @@ -783,6 +924,14 @@ function AgentFlowModal({ const requestImportCandidates = useCallback( (provider?: AgentProvider) => { + if (!isTargetDaemonReady || !ws || !ws.isConnected) { + setIsImportLoading(false); + setImportError( + daemonAvailabilityError ?? + "Connect to the selected daemon to load import candidates." + ); + return; + } setIsImportLoading(true); setImportError(null); const msg: WSInboundMessage = { @@ -804,7 +953,7 @@ function AgentFlowModal({ setImportError("Unable to load agents to import. Please try again."); } }, - [ws] + [daemonAvailabilityError, isTargetDaemonReady, ws] ); useEffect(() => { @@ -973,34 +1122,6 @@ function AgentFlowModal({ slugifyWorktreeName, ]); - const requestRepoInfo = useCallback( - (path: string) => { - const trimmed = path.trim(); - if (!trimmed) { - setRepoInfo(null); - setRepoInfoStatus("idle"); - setRepoInfoError(null); - repoInfoRequestIdRef.current = null; - return; - } - - const requestId = generateMessageId(); - repoInfoRequestIdRef.current = requestId; - setRepoInfoStatus("loading"); - setRepoInfoError(null); - - ws.send({ - type: "session", - message: { - type: "git_repo_info_request", - cwd: trimmed, - requestId, - }, - }); - }, - [ws] - ); - useEffect(() => { if (!isCreateFlow || !isVisible) { return; @@ -1008,62 +1129,61 @@ function AgentFlowModal({ shouldSyncBaseBranchRef.current = true; }, [isCreateFlow, isVisible, workingDir]); - useEffect(() => { - if (!isCreateFlow || !isVisible) { - return; - } - requestRepoInfo(workingDir); - }, [isCreateFlow, isVisible, requestRepoInfo, workingDir]); + const trimmedWorkingDir = workingDir.trim(); + const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0; + const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected) + ? daemonAvailabilityError ?? "Connect to the selected daemon to inspect the repository." + : null; + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo + ? "idle" + : repoAvailabilityError + ? "error" + : repoRequestStatus === "loading" + ? "loading" + : repoRequestStatus === "error" + ? "error" + : repoRequestStatus === "success" + ? "ready" + : "idle"; + const repoInfoError = repoAvailabilityError ?? repoRequestError?.message ?? null; useEffect(() => { - if (!isCreateFlow || !isVisible) { + if (!shouldInspectRepo) { + cancelRepoInfo(); + resetRepoInfo(); return; } - const unsubscribe = ws.on("git_repo_info_response", (message) => { - if (message.type !== "git_repo_info_response") { - return; - } - if ( - repoInfoRequestIdRef.current && - message.payload.requestId && - message.payload.requestId !== repoInfoRequestIdRef.current - ) { - return; - } - - if (message.payload.error) { - setRepoInfo(null); - setRepoInfoStatus("error"); - setRepoInfoError(message.payload.error); - return; - } - - setRepoInfo({ - cwd: message.payload.cwd, - repoRoot: message.payload.repoRoot, - branches: message.payload.branches ?? [], - currentBranch: message.payload.currentBranch ?? null, - isDirty: Boolean(message.payload.isDirty), - }); - setRepoInfoStatus("ready"); - setRepoInfoError(null); - setBaseBranch((prev) => { - if ( - shouldSyncBaseBranchRef.current || - prev.trim().length === 0 - ) { - shouldSyncBaseBranchRef.current = false; - return message.payload.currentBranch ?? ""; - } - return prev; - }); - }); + if (repoAvailabilityError) { + cancelRepoInfo(); + return; + } + inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {}); return () => { - unsubscribe(); + cancelRepoInfo(); }; - }, [isCreateFlow, isVisible, ws]); + }, [ + cancelRepoInfo, + inspectRepoInfo, + repoAvailabilityError, + resetRepoInfo, + shouldInspectRepo, + trimmedWorkingDir, + ]); + + useEffect(() => { + if (!repoInfo) { + return; + } + setBaseBranch((prev) => { + if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) { + shouldSyncBaseBranchRef.current = false; + return repoInfo.currentBranch ?? ""; + } + return prev; + }); + }, [repoInfo]); const handleCreate = useCallback(async () => { const trimmedPath = workingDir.trim(); @@ -1087,6 +1207,14 @@ function AgentFlowModal({ return; } + if (!createAgent || !isTargetDaemonReady) { + setErrorMessage( + daemonAvailabilityError ?? + "Connect to the selected daemon before creating an agent." + ); + return; + } + const trimmedBaseBranch = baseBranch.trim(); try { @@ -1162,6 +1290,8 @@ function AgentFlowModal({ validateWorktreeName, addRecentPath, createAgent, + daemonAvailabilityError, + isTargetDaemonReady, ]); const handleImportCandidatePress = useCallback( @@ -1169,6 +1299,13 @@ function AgentFlowModal({ if (isLoading) { return; } + if (!resumeAgent || !isTargetDaemonReady) { + setImportError( + daemonAvailabilityError ?? + "Connect to the selected daemon before importing an agent." + ); + return; + } setErrorMessage(""); const requestId = generateMessageId(); pendingRequestIdRef.current = requestId; @@ -1178,14 +1315,20 @@ function AgentFlowModal({ requestId, }); }, - [isLoading, resumeAgent] + [ + daemonAvailabilityError, + isLoading, + isTargetDaemonReady, + resumeAgent, + setImportError, + ] ); const renderImportItem = useCallback>( ({ item }) => ( handleImportCandidatePress(item)} - disabled={isLoading} + disabled={isLoading || !isTargetDaemonReady} style={styles.resumeItem} > @@ -1209,11 +1352,11 @@ function AgentFlowModal({ ), - [getProviderLabel, handleImportCandidatePress, isLoading] + [getProviderLabel, handleImportCandidatePress, isLoading, isTargetDaemonReady] ); useEffect(() => { - if (!isImportFlow || !isVisible) { + if (!isImportFlow || !isVisible || !ws) { return; } const unsubscribe = ws.on("list_persisted_agents_response", (message) => { @@ -1240,7 +1383,7 @@ function AgentFlowModal({ }, [isImportFlow, isVisible, ws]); useEffect(() => { - if (!shouldListenForStatus) { + if (!shouldListenForStatus || !ws) { return; } const unsubscribe = ws.on("status", (message) => { @@ -1295,7 +1438,7 @@ function AgentFlowModal({ }, [handleClose, shouldListenForStatus, ws]); useEffect(() => { - if (!shouldListenForDictation) { + if (!shouldListenForDictation || !ws) { return; } const unsubscribe = ws.on("transcription_result", (message) => { @@ -1360,7 +1503,7 @@ function AgentFlowModal({ { if (!shouldHandlePromptDesktopSubmit) { @@ -1517,6 +1661,54 @@ function AgentFlowModal({ keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > + + Target Daemon + {daemonEntries.length === 0 ? ( + No daemons available + ) : ( + + {daemonEntries.map(({ daemon, status }) => { + const isSelected = daemon.id === selectedServerId; + const label = daemon.label || daemon.wsUrl; + return ( + handleSelectServer(daemon.id)} + style={[styles.daemonChip, isSelected && styles.daemonChipSelected]} + > + + {label} + + + {formatConnectionStatus(status)} + + + ); + })} + + )} + {daemonAvailabilityError ? ( + + {daemonAvailabilityError} + + ) : null} + + + {daemonAvailabilityError ? ( + + {daemonAvailabilityError} + + ) : null} {providerFilterOptions.map((option) => { @@ -1736,7 +1933,7 @@ function AgentFlowModal({ Refresh @@ -1764,6 +1961,7 @@ function AgentFlowModal({ Try Again @@ -2956,6 +3154,50 @@ const styles = StyleSheet.create(((theme: any) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, + daemonSelectorSection: { + marginBottom: theme.spacing[6], + }, + daemonSelectorLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + textTransform: "uppercase", + letterSpacing: 0.5, + marginBottom: theme.spacing[2], + }, + daemonSelectorChips: { + flexDirection: "row", + gap: theme.spacing[2], + }, + daemonChip: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + marginRight: theme.spacing[2], + }, + daemonChipSelected: { + backgroundColor: theme.colors.palette.blue[900], + borderColor: theme.colors.palette.blue[500], + }, + daemonChipText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + daemonChipTextSelected: { + color: theme.colors.palette.white, + }, + daemonChipStatus: { + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + }, + daemonAvailabilityText: { + marginTop: theme.spacing[2], + marginBottom: theme.spacing[2], + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + }, footer: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, diff --git a/packages/app/src/components/empty-state.tsx b/packages/app/src/components/empty-state.tsx index f1a135f33..6ec39e6b1 100644 --- a/packages/app/src/components/empty-state.tsx +++ b/packages/app/src/components/empty-state.tsx @@ -1,22 +1,35 @@ import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Plus } from "lucide-react-native"; +import { Plus, Download } from "lucide-react-native"; interface EmptyStateProps { onCreateAgent: () => void; + onImportAgent?: () => void; } -export function EmptyState({ onCreateAgent }: EmptyStateProps) { +export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) { const { theme } = useUnistyles(); + const hasImportCta = typeof onImportAgent === "function"; return ( Hammock What would you like to work on? - - - New agent - + + + + New agent + + {hasImportCta ? ( + + + Import agent + + ) : null} + ); } @@ -40,18 +53,35 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", marginBottom: theme.spacing[8], }, + buttonGroup: { + width: "100%", + gap: theme.spacing[3], + }, button: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[6], - backgroundColor: theme.colors.primary, borderRadius: theme.borderRadius.lg, + justifyContent: "center", }, - buttonText: { + primaryButton: { + backgroundColor: theme.colors.primary, + }, + primaryButtonText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, color: theme.colors.primaryForeground, }, + secondaryButton: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.muted, + }, + secondaryButtonText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + }, })); diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/global-footer.tsx index fb89c4d90..e1a60484c 100644 --- a/packages/app/src/components/global-footer.tsx +++ b/packages/app/src/components/global-footer.tsx @@ -3,12 +3,13 @@ import { View, Pressable, Text, Platform } from "react-native"; import { usePathname, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AudioLines, Users, Plus } from "lucide-react-native"; +import { AudioLines, Users, Plus, Download } from "lucide-react-native"; import { useRealtime } from "@/contexts/realtime-context"; import { useSession } from "@/contexts/session-context"; import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { RealtimeControls } from "./realtime-controls"; -import { CreateAgentModal } from "./create-agent-modal"; +import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal"; import Animated, { FadeIn, FadeOut, @@ -23,8 +24,10 @@ export function GlobalFooter() { const router = useRouter(); const { isRealtimeMode, startRealtime } = useRealtime(); const { ws } = useSession(); + const { activeDaemonId } = useDaemonConnections(); const { controls } = useFooterControls(); const [showCreateModal, setShowCreateModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); // Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -72,7 +75,7 @@ export function GlobalFooter() { return null; } - // For home and orchestrator screens, show three buttons with realtime stacked on top + // For home and orchestrator screens, show action buttons with realtime stacked on top const nonAgentFooterHeight = isRealtimeMode ? FOOTER_HEIGHT * 2 + insets.bottom : FOOTER_HEIGHT + insets.bottom; @@ -104,8 +107,8 @@ export function GlobalFooter() { )} - {/* Three button menu - always visible */} - + {/* Action menu */} + router.push("/")} style={({ pressed }) => [ @@ -123,6 +126,23 @@ export function GlobalFooter() { Agents + setShowImportModal(true)} + style={({ pressed }) => [ + styles.footerButton, + pressed && styles.buttonPressed, + ]} + > + + + + Import + + { console.log("[GlobalFooter] New Agent button pressed"); @@ -168,6 +188,12 @@ export function GlobalFooter() { setShowCreateModal(false)} + serverId={activeDaemonId} + /> + setShowImportModal(false)} + serverId={activeDaemonId} /> ); @@ -187,7 +213,7 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, - threeButtonContainer: { + actionButtonContainer: { flexDirection: "row", padding: theme.spacing[4], gap: theme.spacing[3], diff --git a/packages/app/src/components/headers/home-header.tsx b/packages/app/src/components/headers/home-header.tsx index ecc9ca15f..069fbfdde 100644 --- a/packages/app/src/components/headers/home-header.tsx +++ b/packages/app/src/components/headers/home-header.tsx @@ -1,9 +1,8 @@ -import { useCallback, useState } from "react"; -import { Modal, Pressable, Text, View } from "react-native"; +import { useCallback } from "react"; +import { Pressable } from "react-native"; import { router } from "expo-router"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Settings, MessageSquare, MoreVertical, Plus } from "lucide-react-native"; +import { Settings, MessageSquare, Download, Plus } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; interface HomeHeaderProps { @@ -13,69 +12,40 @@ interface HomeHeaderProps { export function HomeHeader({ onCreateAgent, onImportAgent }: HomeHeaderProps) { const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const openMenu = useCallback(() => setIsMenuVisible(true), []); - const closeMenu = useCallback(() => setIsMenuVisible(false), []); + const handleCreatePress = useCallback(() => { + onCreateAgent(); + }, [onCreateAgent]); const handleImportPress = useCallback(() => { - closeMenu(); onImportAgent(); - }, [closeMenu, onImportAgent]); + }, [onImportAgent]); return ( - <> - router.push("/settings")} + style={styles.iconButton} + > + + + } + right={ + <> router.push("/settings")} + onPress={() => router.push("/orchestrator")} style={styles.iconButton} > - + - } - right={ - <> - router.push("/orchestrator")} - style={styles.iconButton} - > - - - - - - - - - - } - /> - - - - - - - Import Agent - - - - - + + + + + + + + } + /> ); } @@ -84,32 +54,4 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], borderRadius: theme.borderRadius.lg, }, - menuOverlay: { - flex: 1, - }, - menuBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - menuContainer: { - position: "absolute", - minWidth: 180, - borderRadius: theme.borderRadius.xl, - backgroundColor: theme.colors.background, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - shadowColor: "#000", - shadowOpacity: 0.15, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 4, - }, - menuItem: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - }, - menuItemText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, })); diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx new file mode 100644 index 000000000..cddba384c --- /dev/null +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import { SessionProvider } from "@/contexts/session-context"; +import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; + +export function MultiDaemonSessionHost() { + const { daemons } = useDaemonRegistry(); + const { activeDaemonId } = useDaemonConnections(); + + const backgroundDaemons = useMemo(() => { + const shouldConnect = new Map(); + + for (const daemon of daemons) { + if (daemon.autoConnect) { + shouldConnect.set(daemon.id, daemon); + } + } + + if (activeDaemonId) { + const activeDaemon = daemons.find((daemon) => daemon.id === activeDaemonId); + if (activeDaemon) { + shouldConnect.set(activeDaemon.id, activeDaemon); + } + // The active daemon already wraps the UI tree via ProvidersWrapper, + // so we skip rendering it here to avoid duplicate websocket connections. + shouldConnect.delete(activeDaemonId); + } + + return Array.from(shouldConnect.values()); + }, [daemons, activeDaemonId]); + + if (backgroundDaemons.length === 0) { + return null; + } + + return ( + <> + {backgroundDaemons.map((daemon) => ( + + {null} + + ))} + + ); +} diff --git a/packages/app/src/contexts/daemon-connections-context.tsx b/packages/app/src/contexts/daemon-connections-context.tsx new file mode 100644 index 000000000..aa1599aac --- /dev/null +++ b/packages/app/src/contexts/daemon-connections-context.tsx @@ -0,0 +1,239 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context"; +import type { SessionContextValue } from "./session-context"; + +export interface SessionDirectoryEntry { + getSnapshot: () => SessionContextValue | null; + subscribe: (listener: () => void) => () => void; +} + +export type ConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; + +export interface DaemonConnectionRecord { + daemon: DaemonProfile; + status: ConnectionStatus; + lastOnlineAt?: string; + lastError?: string | null; +} + +interface DaemonConnectionsContextValue { + activeDaemonId: string | null; + activeDaemon: DaemonProfile | null; + connectionStates: Map; + isLoading: boolean; + setActiveDaemonId: (daemonId: string) => void; + updateConnectionStatus: ( + daemonId: string, + status: ConnectionStatus, + extras?: { lastError?: string | null; lastOnlineAt?: string } + ) => void; + sessionAccessors: Map; + registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void; + unregisterSessionAccessor: (daemonId: string) => void; + subscribeToSessionDirectory: (listener: () => void) => () => void; + notifySessionDirectoryChange: () => void; +} + +const DaemonConnectionsContext = createContext(null); + +const ACTIVE_DAEMON_STORAGE_KEY = "@paseo:active-daemon-id"; +const ACTIVE_DAEMON_QUERY_KEY = ["active-daemon-id"]; + +export function useDaemonConnections(): DaemonConnectionsContextValue { + const ctx = useContext(DaemonConnectionsContext); + if (!ctx) { + throw new Error("useDaemonConnections must be used within DaemonConnectionsProvider"); + } + return ctx; +} + +export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { + const { daemons, isLoading: registryLoading } = useDaemonRegistry(); + const [activeDaemonId, setActiveDaemonIdState] = useState(null); + const [connectionStates, setConnectionStates] = useState>(new Map()); + const [sessionAccessors, setSessionAccessors] = useState>(new Map()); + const queryClient = useQueryClient(); + const activeDaemonPreference = useQuery({ + queryKey: ACTIVE_DAEMON_QUERY_KEY, + queryFn: loadActiveDaemonPreference, + staleTime: Infinity, + gcTime: Infinity, + }); + const sessionDirectoryListenersRef = useRef void>>(new Set()); + + useEffect(() => { + if (activeDaemonPreference.isPending) { + return; + } + setActiveDaemonIdState(activeDaemonPreference.data ?? null); + }, [activeDaemonPreference.data, activeDaemonPreference.isPending]); + + // Ensure connection states stay in sync with registry entries + useEffect(() => { + setConnectionStates((prev) => { + const next = new Map(); + for (const daemon of daemons) { + const existing = prev.get(daemon.id); + next.set(daemon.id, { + daemon, + status: existing?.status ?? "idle", + lastOnlineAt: existing?.lastOnlineAt, + lastError: existing?.lastError, + }); + } + return next; + }); + }, [daemons]); + + useEffect(() => { + setSessionAccessors((prev) => { + const next = new Map(prev); + for (const key of Array.from(next.keys())) { + if (!daemons.some((daemon) => daemon.id === key)) { + next.delete(key); + } + } + return next; + }); + }, [daemons]); + + // Keep active daemon aligned with registry default + const persistActiveDaemonId = useCallback(async (daemonId: string | null) => { + queryClient.setQueryData(ACTIVE_DAEMON_QUERY_KEY, daemonId); + try { + if (daemonId) { + await AsyncStorage.setItem(ACTIVE_DAEMON_STORAGE_KEY, daemonId); + } else { + await AsyncStorage.removeItem(ACTIVE_DAEMON_STORAGE_KEY); + } + } catch (error) { + console.error("[DaemonConnections] Failed to persist active daemon", error); + } + }, [queryClient]); + + const setActiveDaemonId = useCallback( + (daemonId: string) => { + setActiveDaemonIdState(daemonId); + void persistActiveDaemonId(daemonId); + }, + [persistActiveDaemonId] + ); + + useEffect(() => { + if (activeDaemonPreference.isPending) { + return; + } + + if (daemons.length === 0) { + setActiveDaemonIdState(null); + void persistActiveDaemonId(null); + return; + } + + if (activeDaemonId && daemons.some((daemon) => daemon.id === activeDaemonId)) { + return; + } + + const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0]; + setActiveDaemonId(fallback.id); + }, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]); + + const activeDaemon = useMemo(() => { + if (!activeDaemonId) { + return null; + } + return daemons.find((daemon) => daemon.id === activeDaemonId) ?? null; + }, [activeDaemonId, daemons]); + + const updateConnectionStatus = useCallback( + ( + daemonId: string, + status: ConnectionStatus, + extras?: { lastError?: string | null; lastOnlineAt?: string } + ) => { + setConnectionStates((prev) => { + const next = new Map(prev); + const existing = next.get(daemonId); + if (!existing) { + return prev; + } + const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError")); + next.set(daemonId, { + ...existing, + status, + lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null, + lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt, + }); + return next; + }); + }, + [] + ); + + const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => { + setSessionAccessors((prev) => { + const next = new Map(prev); + next.set(daemonId, entry); + return next; + }); + }, []); + + const unregisterSessionAccessor = useCallback((daemonId: string) => { + setSessionAccessors((prev) => { + const next = new Map(prev); + next.delete(daemonId); + return next; + }); + }, []); + + const subscribeToSessionDirectory = useCallback((listener: () => void) => { + sessionDirectoryListenersRef.current.add(listener); + return () => { + sessionDirectoryListenersRef.current.delete(listener); + }; + }, []); + + const notifySessionDirectoryChange = useCallback(() => { + const listeners = Array.from(sessionDirectoryListenersRef.current); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error("[DaemonConnections] Session directory listener failed", error); + } + } + }, []); + + const value: DaemonConnectionsContextValue = { + activeDaemonId, + activeDaemon, + connectionStates, + isLoading: registryLoading || activeDaemonPreference.isPending, + setActiveDaemonId, + updateConnectionStatus, + sessionAccessors, + registerSessionAccessor, + unregisterSessionAccessor, + subscribeToSessionDirectory, + notifySessionDirectoryChange, + }; + + return ( + + {children} + + ); +} + +async function loadActiveDaemonPreference(): Promise { + try { + const stored = await AsyncStorage.getItem(ACTIVE_DAEMON_STORAGE_KEY); + return stored ?? null; + } catch (error) { + console.error("[DaemonConnections] Failed to read active daemon preference", error); + throw error; + } +} diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx new file mode 100644 index 000000000..62613d911 --- /dev/null +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -0,0 +1,218 @@ +import { createContext, useCallback, useContext, useMemo } from "react"; +import type { ReactNode } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; +const LEGACY_SETTINGS_KEY = "@paseo:settings"; +const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws"; +const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"]; + +export type DaemonProfile = { + id: string; + label: string; + wsUrl: string; + restUrl?: string | null; + autoConnect: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; + metadata?: Record | null; +}; + +type CreateDaemonInput = { + label: string; + wsUrl: string; + restUrl?: string | null; + autoConnect?: boolean; + isDefault?: boolean; +}; + +type UpdateDaemonInput = Partial>; + +interface DaemonRegistryContextValue { + daemons: DaemonProfile[]; + isLoading: boolean; + error: unknown | null; + defaultDaemon: DaemonProfile | null; + addDaemon: (input: CreateDaemonInput) => Promise; + updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise; + removeDaemon: (id: string) => Promise; + setDefaultDaemon: (id: string) => Promise; +} + +const DaemonRegistryContext = createContext(null); + +export function useDaemonRegistry(): DaemonRegistryContextValue { + const ctx = useContext(DaemonRegistryContext); + if (!ctx) { + throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider"); + } + return ctx; +} + +export function DaemonRegistryProvider({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + const { data: daemons = [], isPending, error } = useQuery({ + queryKey: DAEMON_REGISTRY_QUERY_KEY, + queryFn: loadDaemonRegistryFromStorage, + staleTime: Infinity, + gcTime: Infinity, + }); + + const persist = useCallback( + async (profiles: DaemonProfile[]) => { + queryClient.setQueryData(DAEMON_REGISTRY_QUERY_KEY, profiles); + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles)); + }, + [queryClient] + ); + + const readDaemons = useCallback(() => { + return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons; + }, [queryClient, daemons]); + + const addDaemon = useCallback(async (input: CreateDaemonInput) => { + const existing = readDaemons(); + const timestamp = new Date().toISOString(); + const profile: DaemonProfile = { + id: generateDaemonId(), + label: input.label.trim() || deriveLabelFromUrl(input.wsUrl), + wsUrl: input.wsUrl, + restUrl: input.restUrl ?? null, + autoConnect: input.autoConnect ?? true, + isDefault: input.isDefault ?? existing.length === 0, + createdAt: timestamp, + updatedAt: timestamp, + metadata: null, + }; + + const next = normalizeDefaults([...existing, profile]); + await persist(next); + return profile; + }, [persist, readDaemons]); + + const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => { + const next = readDaemons().map((daemon) => + daemon.id === id + ? { + ...daemon, + ...updates, + updatedAt: new Date().toISOString(), + } + : daemon + ); + await persist(next); + }, [persist, readDaemons]); + + const removeDaemon = useCallback(async (id: string) => { + const remaining = readDaemons().filter((daemon) => daemon.id !== id); + const normalized = normalizeDefaults(remaining); + await persist(normalized.length > 0 ? normalized : [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)]); + }, [persist, readDaemons]); + + const setDefaultDaemon = useCallback(async (id: string) => { + const next = readDaemons().map((daemon) => ({ + ...daemon, + isDefault: daemon.id === id, + updatedAt: daemon.id === id ? new Date().toISOString() : daemon.updatedAt, + })); + await persist(next); + }, [persist, readDaemons]); + + const defaultDaemon = useMemo(() => { + if (daemons.length === 0) { + return null; + } + const explicit = daemons.find((daemon) => daemon.isDefault); + return explicit ?? daemons[0]; + }, [daemons]); + + const value: DaemonRegistryContextValue = { + daemons, + isLoading: isPending, + error: error ?? null, + defaultDaemon, + addDaemon, + updateDaemon, + removeDaemon, + setDefaultDaemon, + }; + + return ( + + {children} + + ); +} + +function generateDaemonId(): string { + const random = Math.random().toString(36).slice(2, 8); + return `daemon_${Date.now().toString(36)}_${random}`; +} + +function deriveLabelFromUrl(url: string): string { + try { + const parsed = new URL(url); + return parsed.hostname || "Unnamed Daemon"; + } catch { + return "Unnamed Daemon"; + } +} + +function createProfile(label: string, wsUrl: string, isDefault: boolean): DaemonProfile { + const timestamp = new Date().toISOString(); + return { + id: generateDaemonId(), + label, + wsUrl, + restUrl: null, + autoConnect: true, + isDefault, + createdAt: timestamp, + updatedAt: timestamp, + metadata: null, + }; +} + +function normalizeDefaults(profiles: DaemonProfile[]): DaemonProfile[] { + if (profiles.length === 0) { + return profiles; + } + const hasDefault = profiles.some((daemon) => daemon.isDefault); + if (hasDefault) { + return profiles; + } + const [first, ...rest] = profiles; + return [ + { ...first, isDefault: true }, + ...rest, + ]; +} + +async function loadDaemonRegistryFromStorage(): Promise { + try { + const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); + if (stored) { + return JSON.parse(stored) as DaemonProfile[]; + } + + const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); + if (legacy) { + const legacyParsed = JSON.parse(legacy) as Record; + const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null; + if (legacyUrl) { + const migrated = [createProfile("Primary Daemon", legacyUrl, true)]; + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } + } + + const fallback = [createProfile("Local Daemon", FALLBACK_DAEMON_URL, true)]; + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback)); + return fallback; + } catch (error) { + console.error("[DaemonRegistry] Failed to load daemon registry", error); + throw error; + } +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8687b7b43..71aa666f5 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,5 +1,7 @@ -import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect } from "react"; +import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket"; +import { useDaemonRequest } from "@/hooks/use-daemon-request"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { reduceStreamUpdate, generateMessageId, hydrateStreamState, type StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; @@ -9,6 +11,7 @@ import type { AgentStreamEventPayload, WSInboundMessage, GitSetupOptions, + SessionOutboundMessage, } from "@server/server/messages"; import type { AgentLifecycleStatus, @@ -26,6 +29,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { ScrollView } from "react-native"; import * as FileSystem from 'expo-file-system'; +import { useDaemonConnections } from "./daemon-connections-context"; const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => { const fallbackId = @@ -43,6 +47,16 @@ type DraftInput = { images: Array<{ uri: string; mimeType: string }>; }; +type GitDiffResponseMessage = Extract< + SessionOutboundMessage, + { type: "git_diff_response" } +>; + +type FileExplorerResponseMessage = Extract< + SessionOutboundMessage, + { type: "file_explorer_response" } +>; + export type MessageEntry = | { type: "user"; @@ -91,6 +105,7 @@ type ProviderModelState = { }; export interface Agent { + serverId: string; id: string; provider: AgentProvider; status: AgentLifecycleStatus; @@ -183,6 +198,48 @@ const pushHistory = (history: string[], path: string): string[] => { return [...normalizedHistory, path]; }; +const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:"; + +type PersistedSessionSnapshot = { + agents: AgentSnapshotPayload[]; + commands: Command[]; + savedAt: string; +}; + +const getSessionSnapshotStorageKey = (serverId: string): string => { + return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`; +}; + +async function loadPersistedSessionSnapshot(serverId: string): Promise { + try { + const raw = await AsyncStorage.getItem(getSessionSnapshotStorageKey(serverId)); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as PersistedSessionSnapshot; + if (!Array.isArray(parsed?.agents) || !Array.isArray(parsed?.commands)) { + return null; + } + return parsed; + } catch (error) { + console.error(`[Session] Failed to load persisted snapshot for ${serverId}`, error); + return null; + } +} + +async function persistSessionSnapshot(serverId: string, snapshot: { agents: AgentSnapshotPayload[]; commands: Command[] }) { + try { + const payload: PersistedSessionSnapshot = { + agents: snapshot.agents, + commands: snapshot.commands, + savedAt: new Date().toISOString(), + }; + await AsyncStorage.setItem(getSessionSnapshotStorageKey(serverId), JSON.stringify(payload)); + } catch (error) { + console.error(`[Session] Failed to persist snapshot for ${serverId}`, error); + } +} + type PendingAgentLifecycleRequest = { kind: "initialize" | "refresh"; params: { @@ -191,11 +248,12 @@ type PendingAgentLifecycleRequest = { }; }; -function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { +function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string): Agent { const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null; return { + serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status as AgentLifecycleStatus, @@ -217,8 +275,25 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { }; } +function buildSessionStateFromSnapshots(serverId: string, snapshots: AgentSnapshotPayload[]) { + const agents = new Map(); + const pendingPermissions = new Map(); -interface SessionContextValue { + for (const snapshot of snapshots) { + const agent = normalizeAgentSnapshot(snapshot, serverId); + agents.set(agent.id, agent); + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + } + + return { agents, pendingPermissions }; +} + + +export interface SessionContextValue { + serverId: string; // WebSocket ws: UseWebSocketReturn; @@ -275,7 +350,11 @@ interface SessionContextValue { initializeAgent: (params: { agentId: string; requestId?: string }) => void; refreshAgent: (params: { agentId: string; requestId?: string }) => void; cancelAgentRun: (agentId: string) => void; - sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; + sendAgentMessage: ( + agentId: string, + message: string, + images?: Array<{ uri: string; mimeType?: string }> + ) => Promise; sendAgentAudio: ( agentId: string, audioBlob: Blob, @@ -308,11 +387,46 @@ export function useSession() { interface SessionProviderProps { children: ReactNode; serverUrl: string; + serverId: string; } -export function SessionProvider({ children, serverUrl }: SessionProviderProps) { +export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) { const ws = useWebSocket(serverUrl); const wsIsConnected = ws.isConnected; + const { + updateConnectionStatus, + registerSessionAccessor, + unregisterSessionAccessor, + notifySessionDirectoryChange, + } = useDaemonConnections(); + + useEffect(() => { + if (ws.isConnected) { + updateConnectionStatus(serverId, "online", { + lastOnlineAt: new Date().toISOString(), + lastError: null, + }); + return; + } + + if (ws.isConnecting) { + updateConnectionStatus(serverId, "connecting"); + return; + } + + if (ws.lastError) { + updateConnectionStatus(serverId, "error", { lastError: ws.lastError }); + return; + } + + updateConnectionStatus(serverId, "offline"); + }, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]); + + useEffect(() => { + return () => { + updateConnectionStatus(serverId, "offline", { lastError: null }); + }; + }, [serverId, updateConnectionStatus]); // State for voice detection flags (will be set by RealtimeContext) const isDetectingRef = useRef(false); @@ -353,6 +467,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const previousAgentStatusRef = useRef>(new Map()); const providerModelRequestIdsRef = useRef>(new Map()); const pendingAgentLifecycleRequestsRef = useRef([]); + const hasHydratedSnapshotRef = useRef(false); // Buffer for streaming audio chunks interface AudioChunk { @@ -368,6 +483,54 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setFocusedAgentOverride(agentId); }, []); + useEffect(() => { + let isMounted = true; + + const hydrateFromSnapshot = async () => { + if (hasHydratedSnapshotRef.current) { + return; + } + hasHydratedSnapshotRef.current = true; + const snapshot = await loadPersistedSessionSnapshot(serverId); + if (!snapshot || !isMounted) { + return; + } + + const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots( + serverId, + snapshot.agents + ); + + let applied = false; + setAgents((prev) => { + if (prev.size > 0) { + return prev; + } + applied = true; + return hydratedAgents; + }); + + if (!applied) { + return; + } + + setPendingPermissions(hydratedPermissions); + const commandEntries = snapshot.commands ?? []; + setCommands((prev) => { + if (prev.size > 0) { + return prev; + } + return new Map(commandEntries.map((command) => [command.id, command])); + }); + }; + + void hydrateFromSnapshot(); + + return () => { + isMounted = false; + }; + }, [serverId]); + useEffect(() => { if (focusedAgentOverride) { if (orchestratorFocusedAgentId !== null) { @@ -408,6 +571,91 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { [] ); + const gitDiffRequest = useDaemonRequest< + { agentId: string }, + { agentId: string; diff: string }, + GitDiffResponseMessage + >({ + ws, + responseType: "git_diff_response", + buildRequest: ({ params }) => ({ + type: "session", + message: { + type: "git_diff_request", + agentId: params?.agentId ?? "", + }, + }), + matchResponse: (message, context) => + message.payload.agentId === context.params?.agentId, + getRequestKey: (params) => params?.agentId ?? "default", + selectData: (message) => ({ + agentId: message.payload.agentId, + diff: message.payload.diff ?? "", + }), + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + timeoutMs: 10000, + keepPreviousData: false, + }); + + const directoryListingRequest = useDaemonRequest< + { agentId: string; path: string }, + FileExplorerResponseMessage["payload"], + FileExplorerResponseMessage + >({ + ws, + responseType: "file_explorer_response", + buildRequest: ({ params }) => ({ + type: "session", + message: { + type: "file_explorer_request", + agentId: params?.agentId ?? "", + path: params?.path, + mode: "list", + }, + }), + matchResponse: (message, context) => + message.payload.mode === "list" && + message.payload.agentId === context.params?.agentId && + message.payload.path === context.params?.path, + getRequestKey: (params) => + params ? `${params.agentId}:list:${params.path}` : "default", + selectData: (message) => message.payload, + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + timeoutMs: 10000, + keepPreviousData: false, + }); + + const filePreviewRequest = useDaemonRequest< + { agentId: string; path: string }, + FileExplorerResponseMessage["payload"], + FileExplorerResponseMessage + >({ + ws, + responseType: "file_explorer_response", + buildRequest: ({ params }) => ({ + type: "session", + message: { + type: "file_explorer_request", + agentId: params?.agentId ?? "", + path: params?.path, + mode: "file", + }, + }), + matchResponse: (message, context) => + message.payload.mode === "file" && + message.payload.agentId === context.params?.agentId && + message.payload.path === context.params?.path, + getRequestKey: (params) => + params ? `${params.agentId}:file:${params.path}` : "default", + selectData: (message) => message.payload, + extractError: (message) => + message.payload.error ? new Error(message.payload.error) : null, + timeoutMs: 10000, + keepPreviousData: false, + }); + const sendAgentLifecycleRequest = useCallback( (request: PendingAgentLifecycleRequest) => { const { kind, params } = request; @@ -452,21 +700,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { console.log("[Session] Session state:", agentsList.length, "agents,", commandsList.length, "commands"); - const nextAgents = new Map(); - const nextPermissions = new Map(); + const { agents: hydratedAgents, pendingPermissions: hydratedPermissions } = buildSessionStateFromSnapshots( + serverId, + agentsList + ); + const normalizedCommands = commandsList.map((command) => command as Command); - for (const snapshot of agentsList) { - const agent = normalizeAgentSnapshot(snapshot); - nextAgents.set(agent.id, agent); - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - nextPermissions.set(key, { key, agentId: agent.id, request }); - } - } - - setAgents(nextAgents); - setPendingPermissions(nextPermissions); - setCommands(new Map(commandsList.map((c) => [c.id, c as Command]))); + setAgents(hydratedAgents); + setPendingPermissions(hydratedPermissions); + setCommands(new Map(normalizedCommands.map((command) => [command.id, command]))); setAgentStreamState((prev) => { if (prev.size === 0) { return prev; @@ -503,12 +745,13 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return changed ? next : prev; }); + void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands }); }); const unsubAgentState = ws.on("agent_state", (message) => { if (message.type !== "agent_state") return; const snapshot = message.payload; - const agent = normalizeAgentSnapshot(snapshot); + const agent = normalizeAgentSnapshot(snapshot, serverId); console.log("[Session] Agent state update:", agent.id, agent.status); @@ -912,62 +1155,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { } }); - // Git diff response handler - const unsubGitDiff = ws.on("git_diff_response", (message) => { - if (message.type !== "git_diff_response") return; - const { agentId, diff, error } = message.payload; - - console.log("[Session] Git diff response for agent:", agentId, error ? `(error: ${error})` : ""); - - if (error) { - console.error("[Session] Git diff error:", error); - setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error}`)); - } else { - setGitDiffs((prev) => new Map(prev).set(agentId, diff || "")); - } - }); - - const unsubFileExplorer = ws.on("file_explorer_response", (message) => { - if (message.type !== "file_explorer_response") { - return; - } - const { agentId, directory, file, mode, error } = message.payload; - - console.log( - "[Session] File explorer response for agent:", - agentId, - mode, - error ? `(error: ${error})` : "" - ); - - updateExplorerState(agentId, (state) => { - const nextState: AgentFileExplorerState = { - ...state, - isLoading: false, - lastError: error ?? null, - pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!error) { - if (mode === "list" && directory) { - const directories = new Map(state.directories); - directories.set(directory.path, directory); - nextState.directories = directories; - } - - if (mode === "file" && file) { - const files = new Map(state.files); - files.set(file.path, file); - nextState.files = files; - } - } - - return nextState; - }); - }); - const unsubProviderModels = ws.on( "list_provider_models_response", (message) => { @@ -1074,8 +1261,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { unsubActivity(); unsubChunk(); unsubTranscription(); - unsubGitDiff(); - unsubFileExplorer(); unsubProviderModels(); unsubAgentDeleted(); }; @@ -1166,7 +1351,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); - const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => { + const sendAgentMessage = useCallback(async ( + agentId: string, + message: string, + images?: Array<{ uri: string; mimeType?: string }> + ) => { // Generate unique message ID for deduplication const messageId = generateMessageId(); @@ -1186,24 +1375,28 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { // Convert images to base64 if provided let imagesData: Array<{ data: string; mimeType: string }> | undefined; - if (imageUris && imageUris.length > 0) { - imagesData = []; - for (const imageUri of imageUris) { - try { - // Use FileSystem.File to read the image as base64 - const file = new FileSystem.File(imageUri); - const base64 = file.base64Sync(); - - // Get MIME type from the file - const mimeType = file.type || 'image/jpeg'; - - imagesData.push({ - data: base64, - mimeType, - }); - } catch (error) { - console.error('[Session] Failed to convert image:', error); - } + if (images && images.length > 0) { + const encodedImages = await Promise.all( + images.map(async ({ uri, mimeType }) => { + try { + const data = await FileSystem.readAsStringAsync(uri, { + encoding: "base64", + }); + return { + data, + mimeType: mimeType ?? "image/jpeg", + }; + } catch (error) { + console.error("[Session] Failed to convert image:", error); + return null; + } + }) + ); + const validImages = encodedImages.filter( + (entry): entry is { data: string; mimeType: string } => entry !== null + ); + if (validImages.length > 0) { + imagesData = validImages; } } @@ -1229,7 +1422,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const queue = queuedMessages.get(agentId); if (queue && queue.length > 0) { const [next, ...rest] = queue; - void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri)); + void sendAgentMessage(agentId, next.text, next.images); setQueuedMessages((prev) => { const updated = new Map(prev); updated.set(agentId, rest); @@ -1389,15 +1582,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { }, []); const requestGitDiff = useCallback((agentId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "git_diff_request", - agentId, - }, - }; - ws.send(msg); - }, [ws]); + gitDiffRequest + .execute({ agentId }) + .then((result) => { + setGitDiffs((prev) => new Map(prev).set(result.agentId, result.diff)); + }) + .catch((error) => { + setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error.message}`)); + }); + }, [gitDiffRequest, setGitDiffs]); const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => { const normalizedPath = path && path.length > 0 ? path : "."; @@ -1413,17 +1606,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { lastVisitedPath: normalizedPath, })); - const msg: WSInboundMessage = { - type: "session", - message: { - type: "file_explorer_request", - agentId, - path: normalizedPath, - mode: "list", - }, - }; - ws.send(msg); - }, [updateExplorerState, ws]); + directoryListingRequest + .execute({ agentId, path: normalizedPath }) + .then((payload) => { + updateExplorerState(agentId, (state) => { + const nextState: AgentFileExplorerState = { + ...state, + isLoading: false, + lastError: payload.error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.directory) { + const directories = new Map(state.directories); + directories.set(payload.directory.path, payload.directory); + nextState.directories = directories; + } + + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + lastError: error.message, + pendingRequest: null, + })); + }); + }, [directoryListingRequest, updateExplorerState]); const requestFilePreview = useCallback((agentId: string, path: string) => { const normalizedPath = path && path.length > 0 ? path : "."; @@ -1434,17 +1647,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { pendingRequest: { path: normalizedPath, mode: "file" }, })); - const msg: WSInboundMessage = { - type: "session", - message: { - type: "file_explorer_request", - agentId, - path: normalizedPath, - mode: "file", - }, - }; - ws.send(msg); - }, [updateExplorerState, ws]); + filePreviewRequest + .execute({ agentId, path: normalizedPath }) + .then((payload) => { + updateExplorerState(agentId, (state) => { + const nextState: AgentFileExplorerState = { + ...state, + isLoading: false, + lastError: payload.error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.file) { + const files = new Map(state.files); + files.set(payload.file.path, payload.file); + nextState.files = files; + } + + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + lastError: error.message, + pendingRequest: null, + })); + }); + }, [filePreviewRequest, updateExplorerState]); const navigateExplorerBack = useCallback((agentId: string) => { let targetPath: string | null = null; @@ -1485,6 +1718,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { }, [updateExplorerState, ws]); const value: SessionContextValue = { + serverId, ws, audioPlayer, isPlayingAudio, @@ -1530,6 +1764,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { respondToPermission, }; + const sessionValueRef = useRef(value); + const sessionListenersRef = useRef(new Set<() => void>()); + useEffect(() => { + sessionValueRef.current = value; + sessionListenersRef.current.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error("[SessionProvider] Session listener failed", error); + } + }); + notifySessionDirectoryChange(); + }, [value, notifySessionDirectoryChange]); + + const sessionAccessor = useCallback(() => sessionValueRef.current, []); + const subscribeToSession = useCallback((listener: () => void) => { + sessionListenersRef.current.add(listener); + return () => { + sessionListenersRef.current.delete(listener); + }; + }, []); + const sessionDirectoryEntry = useMemo( + () => ({ + getSnapshot: sessionAccessor, + subscribe: subscribeToSession, + }), + [sessionAccessor, subscribeToSession] + ); + + useEffect(() => { + registerSessionAccessor(serverId, sessionDirectoryEntry); + return () => { + unregisterSessionAccessor(serverId); + }; + }, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); + return ( {children} diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts new file mode 100644 index 000000000..72f2bfb3e --- /dev/null +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import type { Agent } from "@/contexts/session-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { useSessionDirectory } from "@/hooks/use-session-directory"; + +export interface AggregatedAgentGroup { + serverId: string; + serverLabel: string; + agents: Agent[]; +} + +const sortAgents = (agents: Agent[]): Agent[] => { + return [...agents].sort((left, right) => { + const leftRunning = left.status === "running"; + const rightRunning = right.status === "running"; + if (leftRunning && !rightRunning) { + return -1; + } + if (!leftRunning && rightRunning) { + return 1; + } + const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime(); + const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime(); + return rightTime - leftTime; + }); +}; + +export function useAggregatedAgents(fallbackAgents: Map): AggregatedAgentGroup[] { + const { activeDaemonId, connectionStates } = useDaemonConnections(); + const sessionDirectory = useSessionDirectory(); + const activeServerId = activeDaemonId ?? "default"; + + return useMemo(() => { + const groups = new Map(); + const daemonOrder = new Map(); + Array.from(connectionStates.keys()).forEach((serverId, index) => { + daemonOrder.set(serverId, index); + }); + + if (sessionDirectory.size > 0) { + sessionDirectory.forEach((session, serverId) => { + if (!session) { + return; + } + const label = connectionStates.get(serverId)?.daemon.label ?? serverId; + const sessionAgents = Array.from(session.agents.values()); + if (sessionAgents.length === 0) { + return; + } + const existing = groups.get(serverId); + if (existing) { + existing.agents.push(...sessionAgents); + } else { + groups.set(serverId, { serverLabel: label, agents: sessionAgents }); + } + }); + } + + if (groups.size === 0) { + const label = connectionStates.get(activeServerId)?.daemon.label ?? activeServerId; + const fallbackList = Array.from(fallbackAgents.values()); + if (fallbackList.length > 0) { + groups.set(activeServerId, { serverLabel: label, agents: fallbackList }); + } + } + + const aggregatedGroups = Array.from(groups.entries()).map(([serverId, { serverLabel, agents }]) => ({ + serverId, + serverLabel, + agents: sortAgents(agents), + })); + + aggregatedGroups.sort((left, right) => { + const leftIndex = daemonOrder.get(left.serverId); + const rightIndex = daemonOrder.get(right.serverId); + if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + if (leftIndex !== undefined) { + return -1; + } + if (rightIndex !== undefined) { + return 1; + } + return left.serverLabel.localeCompare(right.serverLabel); + }); + + return aggregatedGroups; + }, [sessionDirectory, fallbackAgents, activeServerId, connectionStates]); +} diff --git a/packages/app/src/hooks/use-daemon-request.ts b/packages/app/src/hooks/use-daemon-request.ts new file mode 100644 index 000000000..eff23c059 --- /dev/null +++ b/packages/app/src/hooks/use-daemon-request.ts @@ -0,0 +1,548 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { SessionOutboundMessage, WSInboundMessage } from "@server/server/messages"; +import type { UseWebSocketReturn } from "./use-websocket"; +import { generateMessageId } from "@/types/stream"; + +type RequestStatus = "idle" | "loading" | "success" | "error"; + +type MaybeUndefined = T | undefined; +type ExecuteParams = TParams extends void ? void | undefined : TParams; + +export interface RequestContext { + params: MaybeUndefined; + requestId: string; + key: string; + attempt: number; +} + +type RetryDelay = + | number + | ((attempt: number) => number); + +export interface UseDaemonRequestOptions< + TParams = void, + TData = unknown, + TMessage extends SessionOutboundMessage = SessionOutboundMessage +> { + ws: UseWebSocketReturn; + /** + * Session message type to subscribe to for responses. + */ + responseType: TMessage["type"]; + /** + * Builder that receives params + the generated requestId and returns the outbound WS message. + */ + buildRequest: (context: { params: MaybeUndefined; requestId: string }) => WSInboundMessage; + /** + * Extracts the typed data from the inbound response. + */ + selectData: (message: TMessage, context: RequestContext) => TData; + /** + * Override response matching behavior. Defaults to comparing payload.requestId when available. + */ + matchResponse?: (message: TMessage, context: RequestContext) => boolean; + /** + * Returns the request key for dedupe. Defaults to JSON.stringify(params) or "default". + */ + getRequestKey?: (params: MaybeUndefined) => string; + /** + * Returns an error (string or Error) when the payload represents a failure. + */ + extractError?: (message: TMessage, context: RequestContext) => string | Error | null; + /** + * Milliseconds before a request automatically times out. Pass null to disable. + */ + timeoutMs?: number | null; + /** + * Number of retry attempts after the initial send. + */ + retryCount?: number; + /** + * Delay between retries (ms) or function that maps attempt -> delay. + */ + retryDelayMs?: RetryDelay; + /** + * Keep previously resolved data while loading. + */ + keepPreviousData?: boolean; + /** + * Provide initial data before running the request. + */ + initialData?: TData | null; + /** + * Disable deduplication (send every execute call even if one is in-flight). + */ + dedupe?: boolean; +} + +export interface ExecuteRequestOptions { + timeoutMs?: number | null; + retryCount?: number; + retryDelayMs?: RetryDelay; + dedupe?: boolean; + requestKeyOverride?: string; +} + +export interface UseDaemonRequestResult { + status: RequestStatus; + data: TData | null; + error: Error | null; + isIdle: boolean; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + requestId: string | null; + updatedAt: number | null; + execute: (params?: ExecuteParams, options?: ExecuteRequestOptions) => Promise; + reset: () => void; + cancel: (reason?: string) => void; +} + +interface ActiveRequest { + key: string; + params: MaybeUndefined; + requestId: string; + attempt: number; + promise: Promise; + resolve: (data: TData) => void; + reject: (error: Error) => void; + timeoutHandle: ReturnType | null; + retryHandle: ReturnType | null; + aborted: boolean; + options: ResolvedBehavior; +} + +interface ResolvedBehavior { + timeoutMs: number | null; + retryCount: number; + retryDelayMs: RetryDelay; + dedupe: boolean; +} + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_RETRY_DELAY_MS = 750; + +function useLatest(value: T) { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }, [value]); + return ref; +} + +function resolveKey(params: unknown): string { + if (params === undefined || params === null) { + return "default"; + } + + if (typeof params === "string" || typeof params === "number" || typeof params === "boolean") { + return String(params); + } + + try { + return JSON.stringify(params); + } catch { + return "default"; + } +} + +function ensureError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(typeof error === "string" ? error : "Unknown request error"); +} + +export function useDaemonRequest< + TParams = void, + TData = unknown, + TMessage extends SessionOutboundMessage = SessionOutboundMessage +>(options: UseDaemonRequestOptions): UseDaemonRequestResult { + const { + ws, + responseType, + buildRequest, + selectData, + matchResponse, + getRequestKey, + extractError, + timeoutMs = DEFAULT_TIMEOUT_MS, + retryCount = 0, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + keepPreviousData = true, + initialData = null, + dedupe = true, + } = options; + + const buildRequestRef = useLatest(buildRequest); + const selectDataRef = useLatest(selectData); + const matchResponseRef = useLatest(matchResponse); + const extractErrorRef = useLatest(extractError); + const getRequestKeyRef = useLatest(getRequestKey); + + const [state, setState] = useState<{ + status: RequestStatus; + data: TData | null; + error: Error | null; + requestId: string | null; + updatedAt: number | null; + }>(() => ({ + status: initialData === null ? "idle" : "success", + data: initialData, + error: null, + requestId: null, + updatedAt: initialData === null ? null : Date.now(), + })); + + const activeRequestRef = useRef | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + const active = activeRequestRef.current; + if (active) { + if (active.timeoutHandle) { + clearTimeout(active.timeoutHandle); + } + if (active.retryHandle) { + clearTimeout(active.retryHandle); + } + activeRequestRef.current = null; + } + }; + }, []); + + const cleanupActiveRequest = useCallback(() => { + const current = activeRequestRef.current; + if (!current) { + return; + } + if (current.timeoutHandle) { + clearTimeout(current.timeoutHandle); + } + if (current.retryHandle) { + clearTimeout(current.retryHandle); + } + activeRequestRef.current = null; + }, []); + + const applyState = useCallback( + (updater: (prev: typeof state) => typeof state) => { + if (!isMountedRef.current) { + return; + } + setState(updater); + }, + [] + ); + + const getMatchFn = useCallback( + (context: RequestContext) => { + const userMatch = matchResponseRef.current; + if (userMatch) { + return (message: TMessage) => userMatch(message, context); + } + return (message: TMessage) => { + const payload = (message as { payload?: { requestId?: unknown } }).payload; + if ( + payload && + typeof payload === "object" && + "requestId" in payload && + typeof (payload as { requestId?: unknown }).requestId === "string" + ) { + return (payload as { requestId?: string }).requestId === context.requestId; + } + return true; + }; + }, + [matchResponseRef] + ); + + const resolvedBehavior = useCallback( + (overrides?: ExecuteRequestOptions): ResolvedBehavior => ({ + timeoutMs: + overrides?.timeoutMs !== undefined + ? overrides.timeoutMs + : timeoutMs ?? null, + retryCount: overrides?.retryCount ?? retryCount, + retryDelayMs: overrides?.retryDelayMs ?? retryDelayMs, + dedupe: overrides?.dedupe ?? dedupe, + }), + [timeoutMs, retryCount, retryDelayMs, dedupe] + ); + + const sendAttempt = useCallback( + (request: ActiveRequest) => { + if (request.aborted) { + return; + } + + request.attempt += 1; + + if (request.timeoutHandle) { + clearTimeout(request.timeoutHandle); + } + + try { + const message = buildRequestRef.current({ + params: request.params, + requestId: request.requestId, + }); + ws.send(message); + } catch (error) { + const err = ensureError(error); + request.reject(err); + cleanupActiveRequest(); + applyState((prev) => ({ + status: "error", + data: keepPreviousData ? prev.data : null, + error: err, + requestId: null, + updatedAt: Date.now(), + })); + return; + } + + const timeoutDuration = + typeof request.options.timeoutMs === "number" + ? request.options.timeoutMs + : null; + + if (timeoutDuration && timeoutDuration > 0) { + request.timeoutHandle = setTimeout(() => { + if (request.aborted) { + return; + } + const timeoutError = new Error( + `Request timed out after ${timeoutDuration}ms` + ); + const maxAttempts = request.options.retryCount + 1; + if (request.attempt >= maxAttempts) { + cleanupActiveRequest(); + request.reject(timeoutError); + applyState((prev) => ({ + status: "error", + data: keepPreviousData ? prev.data : null, + error: timeoutError, + requestId: null, + updatedAt: Date.now(), + })); + return; + } + const delay = + typeof request.options.retryDelayMs === "function" + ? request.options.retryDelayMs(request.attempt) + : request.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + request.retryHandle = setTimeout(() => { + sendAttempt(request); + }, Math.max(0, delay)); + }, timeoutDuration); + } + }, + [applyState, buildRequestRef, cleanupActiveRequest, keepPreviousData, ws] + ); + + const startRequest = useCallback( + (params?: ExecuteParams, overrides?: ExecuteRequestOptions) => { + const normalizedParams = params as MaybeUndefined; + const behavior = resolvedBehavior(overrides); + const computedKey = + overrides?.requestKeyOverride ?? + getRequestKeyRef.current?.(normalizedParams) ?? + resolveKey(normalizedParams); + const active = activeRequestRef.current; + if (behavior.dedupe && active && active.key === computedKey) { + return active.promise; + } + + let resolveFn!: (value: TData) => void; + let rejectFn!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + + const request: ActiveRequest = { + key: computedKey, + params: normalizedParams, + requestId: generateMessageId(), + attempt: 0, + promise, + resolve: resolveFn, + reject: rejectFn, + timeoutHandle: null, + retryHandle: null, + aborted: false, + options: behavior, + }; + + activeRequestRef.current = request; + + applyState((prev) => ({ + status: "loading", + data: keepPreviousData ? prev.data : null, + error: null, + requestId: request.requestId, + updatedAt: prev.updatedAt, + })); + + sendAttempt(request); + return promise; + }, + [applyState, getRequestKeyRef, keepPreviousData, resolvedBehavior, sendAttempt] + ); + + useEffect(() => { + const unsubscribe = ws.on(responseType, (message) => { + const active = activeRequestRef.current; + if (!active) { + return; + } + const castedMessage = message as TMessage; + const context: RequestContext = { + params: active.params, + requestId: active.requestId, + key: active.key, + attempt: active.attempt, + }; + const matcher = getMatchFn(context); + if (!matcher(castedMessage)) { + return; + } + + if (active.timeoutHandle) { + clearTimeout(active.timeoutHandle); + active.timeoutHandle = null; + } + if (active.retryHandle) { + clearTimeout(active.retryHandle); + active.retryHandle = null; + } + + const maybeError = extractErrorRef.current + ? extractErrorRef.current(castedMessage, context) + : null; + + if (maybeError) { + const error = ensureError(maybeError); + const maxAttempts = active.options.retryCount + 1; + if (active.attempt >= maxAttempts) { + cleanupActiveRequest(); + active.reject(error); + applyState((prev) => ({ + status: "error", + data: keepPreviousData ? prev.data : null, + error, + requestId: null, + updatedAt: Date.now(), + })); + return; + } + + const delay = + typeof active.options.retryDelayMs === "function" + ? active.options.retryDelayMs(active.attempt) + : active.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + active.retryHandle = setTimeout(() => { + sendAttempt(active); + }, Math.max(0, delay)); + return; + } + + let data: TData; + try { + data = selectDataRef.current(castedMessage, context); + } catch (error) { + const err = ensureError(error); + cleanupActiveRequest(); + active.reject(err); + applyState((prev) => ({ + status: "error", + data: keepPreviousData ? prev.data : null, + error: err, + requestId: null, + updatedAt: Date.now(), + })); + return; + } + + cleanupActiveRequest(); + active.resolve(data); + applyState(() => ({ + status: "success", + data, + error: null, + requestId: context.requestId, + updatedAt: Date.now(), + })); + }); + + return unsubscribe; + }, [ + applyState, + cleanupActiveRequest, + extractErrorRef, + getMatchFn, + keepPreviousData, + responseType, + selectDataRef, + sendAttempt, + ws, + ]); + + const execute = useCallback( + (params?: ExecuteParams, overrides?: ExecuteRequestOptions) => startRequest(params, overrides), + [startRequest] + ); + + const reset = useCallback(() => { + cleanupActiveRequest(); + applyState(() => ({ + status: "idle", + data: initialData, + error: null, + requestId: null, + updatedAt: null, + })); + }, [applyState, cleanupActiveRequest, initialData]); + + const cancel = useCallback( + (reason?: string) => { + const active = activeRequestRef.current; + if (!active) { + return; + } + active.aborted = true; + cleanupActiveRequest(); + const error = new Error(reason ?? "Request cancelled"); + active.reject(error); + applyState((prev) => ({ + status: "idle", + data: keepPreviousData ? prev.data : null, + error: null, + requestId: null, + updatedAt: prev.updatedAt, + })); + }, + [applyState, cleanupActiveRequest, keepPreviousData] + ); + + return useMemo( + () => ({ + status: state.status, + data: state.data, + error: state.error, + isIdle: state.status === "idle", + isLoading: state.status === "loading", + isSuccess: state.status === "success", + isError: state.status === "error", + requestId: state.requestId, + updatedAt: state.updatedAt, + execute, + reset, + cancel, + }), + [execute, reset, cancel, state] + ); +} diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts new file mode 100644 index 000000000..3003e5991 --- /dev/null +++ b/packages/app/src/hooks/use-daemon-session.ts @@ -0,0 +1,70 @@ +import { useRef } from "react"; +import { Alert } from "react-native"; +import type { SessionContextValue } from "@/contexts/session-context"; +import { useSession } from "@/contexts/session-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { useSessionDirectory } from "./use-session-directory"; + +export class DaemonSessionUnavailableError extends Error { + serverId: string; + + constructor(serverId: string) { + super(`Daemon session "${serverId}" is unavailable`); + this.name = "DaemonSessionUnavailableError"; + this.serverId = serverId; + } +} + +export function getSessionForServer( + serverId: string, + directory: Map +): SessionContextValue { + const session = directory.get(serverId) ?? null; + if (!session) { + throw new DaemonSessionUnavailableError(serverId); + } + return session; +} + +type UseDaemonSessionOptions = { + suppressUnavailableAlert?: boolean; +}; + +export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) { + const activeSession = useSession(); + const sessionDirectory = useSessionDirectory(); + const { connectionStates } = useDaemonConnections(); + const alertedDaemonsRef = useRef>(new Set()); + const loggedDaemonsRef = useRef>(new Set()); + const { suppressUnavailableAlert = false } = options ?? {}; + + const targetServerId = serverId ?? activeSession.serverId; + const isActiveSession = targetServerId === activeSession.serverId; + + if (isActiveSession || !targetServerId) { + return activeSession; + } + + try { + return getSessionForServer(targetServerId, sessionDirectory); + } catch (error) { + if (error instanceof DaemonSessionUnavailableError) { + const connection = connectionStates.get(targetServerId); + const label = connection?.daemon.label ?? targetServerId; + const status = connection?.status ?? "unknown"; + const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; + const message = `${label} isn't connected yet (${status}). Switch to it or enable auto-connect so Paseo can reach it.${lastError}`; + + if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) { + alertedDaemonsRef.current.add(targetServerId); + Alert.alert("Daemon unavailable", message.trim()); + } + + if (!loggedDaemonsRef.current.has(targetServerId)) { + loggedDaemonsRef.current.add(targetServerId); + console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); + } + } + throw error; + } +} diff --git a/packages/app/src/hooks/use-session-directory.ts b/packages/app/src/hooks/use-session-directory.ts new file mode 100644 index 000000000..a0e7fa4ea --- /dev/null +++ b/packages/app/src/hooks/use-session-directory.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo, useState } from "react"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import type { SessionContextValue } from "@/contexts/session-context"; + +export function useSessionDirectory(): Map { + const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections(); + const [revision, setRevision] = useState(0); + + useEffect(() => { + return subscribeToSessionDirectory(() => { + setRevision((current) => current + 1); + }); + }, [subscribeToSessionDirectory]); + + return useMemo(() => { + const entries = new Map(); + sessionAccessors.forEach((entry, serverId) => { + try { + entries.set(serverId, entry.getSnapshot()); + } catch (error) { + console.error(`[useSessionDirectory] Failed to read session accessor for "${serverId}"`, error); + entries.set(serverId, null); + } + }); + return entries; + }, [sessionAccessors, revision]); +} + +export function useSessionForServer(serverId: string | null): SessionContextValue | null { + const directory = useSessionDirectory(); + if (!serverId) { + return null; + } + return directory.get(serverId) ?? null; +} diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 4bbafc8cb..79e21cbeb 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -1,78 +1,114 @@ -import { useState, useEffect, useCallback } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; -const STORAGE_KEY = '@paseo:settings'; +const APP_SETTINGS_KEY = "@paseo:app-settings"; +const LEGACY_SETTINGS_KEY = "@paseo:settings"; +const APP_SETTINGS_QUERY_KEY = ["app-settings"]; -export interface Settings { - serverUrl: string; +export interface AppSettings { useSpeaker: boolean; keepScreenOn: boolean; - theme: 'dark' | 'light' | 'auto'; + theme: "dark" | "light" | "auto"; } -const DEFAULT_SETTINGS: Settings = { - serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws', +const DEFAULT_APP_SETTINGS: AppSettings = { useSpeaker: true, keepScreenOn: true, - theme: 'dark', + theme: "dark", }; -export interface UseSettingsReturn { - settings: Settings; +export interface UseAppSettingsReturn { + settings: AppSettings; isLoading: boolean; - updateSettings: (updates: Partial) => Promise; + error: unknown | null; + updateSettings: (updates: Partial) => Promise; resetSettings: () => Promise; } -export function useSettings(): UseSettingsReturn { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [isLoading, setIsLoading] = useState(true); +export function useAppSettings(): UseAppSettingsReturn { + const queryClient = useQueryClient(); + const { data, isPending, error } = useQuery({ + queryKey: APP_SETTINGS_QUERY_KEY, + queryFn: loadSettingsFromStorage, + staleTime: Infinity, + gcTime: Infinity, + }); - // Load settings from AsyncStorage on mount - useEffect(() => { - loadSettings(); - }, []); - - async function loadSettings() { - try { - const stored = await AsyncStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored) as Partial; - setSettings({ ...DEFAULT_SETTINGS, ...parsed }); + const updateSettings = useCallback( + async (updates: Partial) => { + try { + const prev = queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS; + const next = { ...prev, ...updates }; + queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + } catch (err) { + console.error("[AppSettings] Failed to save settings:", err); + throw err; } - } catch (error) { - console.error('[Settings] Failed to load settings:', error); - // Continue with default settings - } finally { - setIsLoading(false); - } - } - - const updateSettings = useCallback(async (updates: Partial) => { - try { - const newSettings = { ...settings, ...updates }; - setSettings(newSettings); - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings)); - } catch (error) { - console.error('[Settings] Failed to save settings:', error); - throw error; - } - }, [settings]); + }, + [queryClient] + ); const resetSettings = useCallback(async () => { try { - setSettings(DEFAULT_SETTINGS); - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS)); - } catch (error) { - console.error('[Settings] Failed to reset settings:', error); - throw error; + const next = { ...DEFAULT_APP_SETTINGS }; + queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + } catch (err) { + console.error("[AppSettings] Failed to reset settings:", err); + throw err; } - }, []); + }, [queryClient]); return { - settings, - isLoading, + settings: data ?? DEFAULT_APP_SETTINGS, + isLoading: isPending, + error: error ?? null, updateSettings, resetSettings, }; } + +async function loadSettingsFromStorage(): Promise { + try { + const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + return { ...DEFAULT_APP_SETTINGS, ...parsed }; + } + + const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); + if (legacyStored) { + const legacyParsed = JSON.parse(legacyStored) as Record; + const next = { + ...DEFAULT_APP_SETTINGS, + ...pickAppSettingsFromLegacy(legacyParsed), + } satisfies AppSettings; + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + return next; + } + + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS)); + return DEFAULT_APP_SETTINGS; + } catch (error) { + console.error("[AppSettings] Failed to load settings:", error); + throw error; + } +} + +function pickAppSettingsFromLegacy(legacy: Record): Partial { + const result: Partial = {}; + if (typeof legacy.useSpeaker === "boolean") { + result.useSpeaker = legacy.useSpeaker; + } + if (typeof legacy.keepScreenOn === "boolean") { + result.keepScreenOn = legacy.keepScreenOn; + } + if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { + result.theme = legacy.theme; + } + return result; +} + +export const useSettings = useAppSettings; diff --git a/packages/app/src/hooks/use-websocket.ts b/packages/app/src/hooks/use-websocket.ts index 7ce7f7281..8f330e7d8 100644 --- a/packages/app/src/hooks/use-websocket.ts +++ b/packages/app/src/hooks/use-websocket.ts @@ -1,54 +1,123 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import type { WSInboundMessage, WSOutboundMessage, - SessionOutboundMessage -} from '@server/server/messages'; + SessionOutboundMessage, +} from "@server/server/messages"; export interface UseWebSocketReturn { isConnected: boolean; + isConnecting: boolean; conversationId: string | null; + lastError: string | null; send: (message: WSInboundMessage) => void; - on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void; + on: ( + type: SessionOutboundMessage["type"], + handler: (message: SessionOutboundMessage) => void + ) => () => void; sendPing: () => void; sendUserMessage: (message: string) => void; } +const RECONNECT_BASE_DELAY_MS = 1500; +const RECONNECT_MAX_DELAY_MS = 30000; + export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn { const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(true); const [currentConversationId, setCurrentConversationId] = useState(null); + const [lastError, setLastError] = useState(null); const wsRef = useRef(null); - const handlersRef = useRef void>>>(new Map()); + const handlersRef = + useRef void>>>(new Map()); const reconnectTimeoutRef = useRef | undefined>(undefined); + const reconnectAttemptRef = useRef(0); + const shouldReconnectRef = useRef(true); const connect = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { + if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { return; } + let scheduledReconnect = false; + const scheduleReconnect = (reason?: string) => { + if (scheduledReconnect || !shouldReconnectRef.current) { + return; + } + scheduledReconnect = true; + + if (wsRef.current) { + try { + wsRef.current.close(); + } catch { + // no-op + } + wsRef.current = null; + } + + if (typeof reason === "string" && reason.trim().length > 0) { + setLastError(reason.trim()); + } + + setIsConnected(false); + setIsConnecting(false); + + const attempt = reconnectAttemptRef.current; + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS); + reconnectAttemptRef.current = attempt + 1; + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = undefined; + if (!shouldReconnectRef.current) { + return; + } + setIsConnecting(true); + connect(); + }, delay); + }; + try { // Add conversation ID to URL if provided const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url; const ws = new WebSocket(wsUrl); + wsRef.current = ws; + setIsConnecting(true); ws.onopen = () => { - console.log('[WS] Connected to server'); + console.log("[WS] Connected to server"); setIsConnected(true); + setIsConnecting(false); + setLastError(null); + reconnectAttemptRef.current = 0; }; - ws.onclose = () => { - console.log('[WS] Disconnected from server'); + ws.onclose = (event) => { + console.log("[WS] Disconnected from server"); + const reason = + typeof event?.reason === "string" && event.reason.trim().length > 0 + ? event.reason.trim() + : `Socket closed (code ${event?.code ?? "unknown"})`; setIsConnected(false); - - // Attempt to reconnect after 3 seconds - reconnectTimeoutRef.current = setTimeout(() => { - console.log('[WS] Attempting to reconnect...'); - connect(); - }, 3000); + scheduleReconnect(reason); }; - ws.onerror = (error) => { - console.warn('[WS] Error:', error); + ws.onerror = (errorEvent) => { + let reason = "WebSocket error"; + if ( + errorEvent && + typeof errorEvent === "object" && + "message" in errorEvent && + typeof (errorEvent as { message: unknown }).message === "string" + ) { + const message = ((errorEvent as { message: string }).message || "").trim(); + reason = message.length > 0 ? message : reason; + } + console.warn("[WS] Error:", errorEvent); + scheduleReconnect(reason); }; ws.onmessage = (event) => { @@ -56,12 +125,12 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe const wsMessage: WSOutboundMessage = JSON.parse(event.data); // Only session messages trigger handlers - if (wsMessage.type === 'session') { + if (wsMessage.type === "session") { const sessionMessage = wsMessage.message; console.log(`[WS] Received session message type: ${sessionMessage.type}`); // Track conversation ID when loaded - if (sessionMessage.type === 'conversation_loaded') { + if (sessionMessage.type === "conversation_loaded") { setCurrentConversationId(sessionMessage.payload.conversationId); } @@ -81,20 +150,23 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe console.log(`[WS] Received ${wsMessage.type}`); } } catch (err) { - console.error('[WS] Failed to parse message:', err); + console.error("[WS] Failed to parse message:", err); } }; - - wsRef.current = ws; } catch (err) { - console.warn('[WS] Failed to create WebSocket:', err); + console.warn("[WS] Failed to create WebSocket:", err); + const reason = err instanceof Error ? err.message : "Failed to create WebSocket"; + scheduleReconnect(reason); } }, [url, conversationId]); useEffect(() => { + shouldReconnectRef.current = true; + setIsConnecting(true); connect(); return () => { + shouldReconnectRef.current = false; if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } @@ -108,41 +180,41 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)); } else { - console.warn('[WS] Cannot send message - not connected'); + console.warn("[WS] Cannot send message - not connected"); } }, []); - const on = useCallback(( - type: SessionOutboundMessage['type'], - handler: (message: SessionOutboundMessage) => void - ) => { - if (!handlersRef.current.has(type)) { - handlersRef.current.set(type, new Set()); - } - handlersRef.current.get(type)!.add(handler); - - // Return cleanup function - return () => { - const handlers = handlersRef.current.get(type); - if (handlers) { - handlers.delete(handler); - if (handlers.size === 0) { - handlersRef.current.delete(type); - } + const on = useCallback( + (type: SessionOutboundMessage["type"], handler: (message: SessionOutboundMessage) => void) => { + if (!handlersRef.current.has(type)) { + handlersRef.current.set(type, new Set()); } - }; - }, []); + handlersRef.current.get(type)!.add(handler); + + // Return cleanup function + return () => { + const handlers = handlersRef.current.get(type); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + handlersRef.current.delete(type); + } + } + }; + }, + [] + ); const sendPing = useCallback(() => { - send({ type: 'ping' }); + send({ type: "ping" }); }, [send]); const sendUserMessage = useCallback( (message: string) => { send({ - type: 'session', + type: "session", message: { - type: 'user_text', + type: "user_text", text: message, }, }); @@ -153,12 +225,14 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe return useMemo( () => ({ isConnected, + isConnecting, conversationId: currentConversationId, + lastError, send, on, sendPing, sendUserMessage, }), - [isConnected, currentConversationId, send, on, sendPing, sendUserMessage] + [isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage] ); } diff --git a/packages/app/src/utils/daemons.ts b/packages/app/src/utils/daemons.ts new file mode 100644 index 000000000..9a0fb3947 --- /dev/null +++ b/packages/app/src/utils/daemons.ts @@ -0,0 +1,31 @@ +import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; + +export function formatConnectionStatus(status: ConnectionStatus): string { + switch (status) { + case "online": + return "Online"; + case "connecting": + return "Connecting"; + case "offline": + return "Offline"; + case "error": + return "Error"; + default: + return "Idle"; + } +} + +export type ConnectionStatusTone = "success" | "warning" | "error" | "muted"; + +export function getConnectionStatusTone(status: ConnectionStatus): ConnectionStatusTone { + switch (status) { + case "online": + return "success"; + case "connecting": + return "warning"; + case "error": + return "error"; + default: + return "muted"; + } +} diff --git a/plan.md b/plan.md index 6e1c8efac..23d56e98c 100644 --- a/plan.md +++ b/plan.md @@ -1,92 +1,92 @@ -# Guidelines +# Multi-Daemon Production Rollout Plan -- Implement the first available task, top down -- Only do a single task and exit, the other agents will implement the other tasks -- Commit after each task with a descriptive commit message. -- Add context after each task completion, indented under the task, to help the reviewer understand the changes. +## Guiding Principles +- The app always reflects the union of every connected daemon—no manual switching to “see” data. +- Actions (create/resume agent, browse files, realtime tools) automatically route to the correct daemon based on the agent/workspace context. +- Connection health, loading states, and mutations are observable and resilient (React Query or equivalent). -# Tasks +## Workstreams & Tasks -- [x] Claude hydration regression must be proven with a real end-to-end test: spin up an actual Claude agent (no mocks), ask it to run tool calls that create/edit/read a temp project, verify the live stream shows the tool results, shut the agent down, hydrate from disk (`~/.claude/projects/...`), and assert the exact diff/read/command output replays in the UI. Do not mark any hydration task complete until this automated test exists and fails on current main but passes after the fix. - - Added a Vitest integration in `packages/server/src/server/agent/providers/claude-agent.test.ts` that drives a live Claude session through real Bash/write/read tool calls, converts the emitted timeline into the UI stream via `hydrateStreamState`, tears the agent down, resumes it from `~/.claude/projects/...`, and asserts that the hydrated stream reproduces the command output, file diff, and read content instead of leaving spinners. The helper waits for the persisted `.jsonl` file (covering `/var` ↔ `/private` realpaths), cleans up temp state, and the run is wired into `npm run test --workspace=@voice/server -- src/server/agent/providers/claude-agent.test.ts` (passes locally). -- [x] Hydrated Claude tool calls still show the spinner forever after refreshing a chat; Claude CLI already persists the tool payloads under `~/.claude/projects/`, but our hydrate path isn’t loading them. Fix the loader so it reads the saved diffs/read/output blocks and transitions the existing pill to “completed” instead of spinning or duplicating. - - Claude history entries now flow through a shared `convertClaudeHistoryEntry` helper that inspects every message for `tool_*` blocks before classifying it as a plain user message, so persisted `tool_result` lines recorded as `type: "user"` convert into completed tool-call events instead of leaving the original spinner stuck in `executing`. Added unit coverage in `packages/server/src/server/agent/providers/claude-agent.test.ts` proving user tool results hydrate correctly and ran `npm run test --workspace=@voice/server -- src/server/agent/providers/claude-agent.test.ts` (passes, though existing SDK integration specs are still slow/flaky by nature). -- [x] We still see duplicate tool call pills (loading + completed/failed) in both Codex and Claude sessions, live and hydrated. Track tool call IDs rigorously, dedupe pending/completed entries, and add regression tests covering all providers and hydrate flows. - - Hardened the timeline reducer (`packages/app/src/types/stream.ts`) so out-of-order tool events reconcile via provider/server/tool metadata even when call IDs are missing, statuses only move forward (executing → completed/failed), and replayed hydration updates don’t spawn extra pills; added Vitest coverage in `packages/app/src/types/stream.test.ts` proving both Codex and Claude live + hydrated streams stay deduped when completion blocks precede their pending counterparts. Tests run via `npx vitest packages/app/src/types/stream.test.ts`. -- [x] Permission request cards in the stream header are emitting duplicate key warnings—derive stable per-agent keys (for example `${agentId}:${request.id}` with fallbacks) so React stops complaining. - - Added a `key` field on `PendingPermission` along with a shared helper in `SessionContext` that derives `${agentId}:` identifiers for every server-sourced request, stores them in the map, and removes them when the server resolves the request. The stream header now renders permission cards using this stable key instead of recomputing on every render, eliminating the duplicate React keys. -- [x] The so-called tests are still fake: convert the stream harness into real Vitest suites co-located with the code, make `npm test` (Vitest) the single entrypoint, and ensure those tests fail today because hydrated tool-call results are missing. - - Added `packages/app/src/types/stream.harness.test.ts`, which codifies the manual stream harness into Vitest and captures the regression: the live sequence populates tool diffs/reads/command output, but the hydrated snapshot lacks them, so `npm run test --workspace=@voice/app` now fails on the second assertion until the hydration loader replays tool payloads from disk. -- [x] Hydrated sessions continue to drop user messages; capture this in a failing test for both Codex and Claude (hydrate snapshot + live replay) and only mark complete once the test passes. - - Added end-to-end hydration suites in `packages/server/src/server/agent/providers/{claude,codex}-agent.test.ts` that record live timeline updates (including user prompts) and then hydrate from `streamHistory()` after resuming the session; both now assert that the refreshed snapshot still contains the user's prompt marker. Fixed Claude hydration by having `convertClaudeHistoryEntry` emit user_message entries alongside persisted tool blocks, and taught the Codex rollout parser to surface `role: "user"` messages from rollout logs. -- [x] Audit the recent “Vitest migration” claim: confirm `npm test` actually runs the new suites end to end, add coverage that specifically asserts tool call results render after hydration, and keep the task open until CI proves it. - - Added `resolveToolCallPreview` so the `ToolCall` UI explicitly prefers hydrated `parsed*` payloads over fallbacks and wrote `tool-call-preview.test.ts` to cover hydration-vs-fallback behavior. - - Verified `npm test` now drives both workspaces (server suite passes; app suite fails fast on `src/types/stream.harness.test.ts` because hydrated tool payloads are still missing), so CI will surface the regression once the loader fix lands. -- [x] AgentInput image picker crashes with "Attempting to launch an unregistered ActivityResultLauncher" when calling Expo ImagePicker; register the launcher properly (or switch to the new async hook) so picking images doesn’t throw and we can attach screenshots again. - - Added a dedicated `useImageAttachmentPicker` hook that registers the media picker launcher up front, reuses Expo’s permission hook, restores pending results, and guards against concurrent launches. AgentInput now consumes this hook so tapping the attachment button opens the picker without crashing on Android; `npm run typecheck --workspace=@voice/app` currently fails upstream in `stream.test.ts` before our change, noted in the summary. -- [x] Do not mark the Claude hydration fix complete until there’s an end-to-end test that (1) runs Claude, (2) executes tool calls, (3) persists the conversation under `~/.claude/projects/...`, and (4) hydrates from disk verifying the tool call results render. No test, no checkbox. - - Strengthened `packages/server/src/server/agent/providers/claude-agent.test.ts` so the existing e2e hydration suite now asserts the live stream parses command/edit/read payloads and that the resumed stream reproduces those parsed diffs/reads/command outputs from `~/.claude/projects`, guaranteeing the UI pills render identical results after hydration. -- [x] ERROR Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version. .$tool_1763217454926_uyr9rm +### 1. Session Directory & Data Consistency +- [x] Rebuild `useSessionDirectory` so it stays in sync with realtime session state (agents, permissions, stream updates) instead of caching an accessor forever. + - Added session-level subscriptions so the directory re-renders on daemon updates and ran `npm run typecheck` to verify. +- [x] Expose a lightweight subscription/API for background `SessionProvider`s so any change invalidates aggregated consumers without forcing rerenders of the main tree. + - Added central session-directory listeners in the daemon connections context, had SessionProviders emit invalidations, updated `useSessionDirectory` to use the new API, and ran `npm run typecheck`. +- [x] Audit every consumer that still reaches for `useSession()` directly (AgentStreamView, AgentInputArea, file explorers, realtime, etc.) and ensure they receive the session instance that corresponds to the agent/daemon they’re operating on. + - Agent detail UI now pulls daemon-scoped sessions via `useDaemonSession`, so sending messages, toggling modes, and inline file explorers all operate against the route’s `serverId` (see `packages/app/src/components/agent-input-area.tsx:72`, `packages/app/src/components/agent-stream-view.tsx:31`, `packages/app/src/components/agent-status-bar.tsx:6`, `packages/app/src/app/agent/[serverId]/[agentId].tsx:256`) and `npm run typecheck` passes. - - `Stream` now generates fallback tool ids via `createUniqueTimelineId`, ensuring hydrated/past tool calls without callIds never reuse the same key even when their metadata matches; added `testFallbackToolCallIdsStayUnique` in `packages/app/src/types/stream.test.ts` to cover the regression and verified via `npx vitest packages/app/src/types/stream.test.ts`. +### 2. Aggregated Agent Experience +- [x] Replace the home screen’s `agents.size` gate with `useAggregatedAgents`, render the merged list (grouped/sorted by daemon), and remove the daemon picker UI from the header. + - Home now builds grouped daemon sections via `useAggregatedAgents`, `AgentList` renders each section with the correct serverId routing, the header exposes dedicated import/create buttons without manual daemon switching, and `npm run typecheck` passes. + - When closing the agent action sheet we now clear the stored `serverId` so `useDaemonSession` falls back to the active daemon and the Home screen no longer crashes if that background daemon disconnects after a long-press. +- [x] Ensure all agent rows carry their `serverId` through navigation (Agent screen, diff viewer, file explorer, orchestrator) so every deep link includes `/agent/[serverId]/[agentId]`. + - Annotated every agent snapshot with its daemon `serverId`, updated shared row components (`AgentList`, `AgentSidebar`, `ActiveProcesses`) to read it when navigating/deleting, and re-ran `npm run typecheck`. +- [x] Update stream detail routes (git diff, file explorer) to validate the daemon session from params and gracefully show status/loading/error states if the background session is unavailable. + - Wrapped both routes in session guards that render connection-aware placeholders when the target daemon is offline/unavailable, moved the existing logic into gated child components, and re-ran `npm run typecheck` to verify. +- [x] Guard the main agent route when the requested daemon session is unavailable so deep links or quick daemon switches don't crash the screen. + - Added a connection-aware guard around `/agent/[serverId]/[agentId]` that renders a friendly placeholder when the session is offline/unavailable and re-ran `npm run typecheck`. +- [x] Fix the agent screen dropdown positioning so we don't double-apply the safe-area offset and remove the leaked debug logging. + - `packages/app/src/app/agent/[serverId]/[agentId].tsx:320-339` adds `insets.top` to `measureInWindow` coordinates (which already include the status bar) and emits `[Menu]` console logs on every open, so the action menu renders ~40px too low on notched devices and spams the JS console. + - Removed the extra `insets.top` offset, cleaned up the `[Menu]` debug logs, and re-ran `npm run typecheck`. +- [x] Make inline file path navigation pick up the correct daemon id even when two daemons generate the same `agentId`. + - Added `resolvedServerId` to the `handleInlinePathPress` dependency list in `packages/app/src/components/agent-stream-view.tsx:83-116`, so navigating after switching daemons now routes to the correct file explorer target; re-ran `npm run typecheck`. +- [x] Sync the active daemon context with the agent route so realtime/audio flows hit the same websocket as the rendered agent, even when arriving from a deep link. + - `AgentScreen` looks up the requested session via `useDaemonSession` but never calls `setActiveDaemonId`, so opening `/agent/[serverB]/[agentId]` while daemon A is active leaves the global `SessionProvider`/`RealtimeProvider` pointed at A (see `packages/app/src/app/agent/[serverId]/[agentId].tsx:74-138`). + - `AgentInputArea` forwards realtime/voice interactions through `useRealtime()` and the active `ws` (`packages/app/src/components/agent-input-area.tsx:656-744`), so with the mismatch above those commands get sent to daemon A instead of the daemon hosting the open agent. + - Added a route-aware effect in `packages/app/src/app/agent/[serverId]/[agentId].tsx` that synchronizes `setActiveDaemonId` with the screen's `serverId` param so the root Session/Realtime providers follow deep links, and ran `npm run typecheck --workspace=@paseo/app`. +- [x] Restore the legacy `/agent/[id]` route as a compatibility shim so old deep links (without a daemon id) keep working while we transition the UI. + - Added `packages/app/src/app/agent/[id].tsx`, which scans the session directory for the requested agent, auto-redirects when there’s a single match, and lets the user choose when multiple daemons share that id; registered the screen in `_layout.tsx` and re-ran `npm run typecheck --workspace=@paseo/app`. +- [x] Keep background agent actions from swapping the active daemon just to open the action sheet or delete. + - `AgentList` still called `setActiveDaemonId` on long-press and before deletion (`packages/app/src/components/agent-list.tsx:19-63`), so managing a background daemon’s agent on Home would tear down the active websocket/realtime session. Removed those calls and rely on `useDaemonSession` so the aggregated view no longer hijacks the global session for contextual actions. +- [x] Allow guarded screens to opt out of the `useDaemonSession` alert so offline placeholders don’t trigger duplicate system popups. + - Added a `suppressUnavailableAlert` option to `useDaemonSession`, had Agent, Git diff, and File Explorer guards opt in so they only render their inline placeholders, and ran `npm run typecheck --workspace=@paseo/app`. +- [x] Force the root `SessionProvider` to remount whenever the active daemon changes so session state never leaks between servers. + - Added `key={activeDaemon.id}` in `_layout.tsx`, ensuring each daemon gets a fresh session tree so cached agents/permissions don’t show up under the wrong daemon while switching routes. - ```tsx - Code: agent-stream-view.tsx - 317 | return ( - 318 | - > 319 | &2 + exit 1 +fi + +REPO_ROOT=$(cd "$(dirname "$PLAN_FILE")" && pwd) +PLAN_PATH="$REPO_ROOT/$(basename "$PLAN_FILE")" +IMPLEMENT_PROMPT=$(cat <