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
);
}
+
+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