Files
paseo/packages/app/src/contexts/daemon-connections-context.tsx
T
Mohamed BoudraandClaude 4ab79b1a50 feat: add multi-daemon support with session routing and connection management
Introduce DaemonRegistry and DaemonConnections contexts for managing multiple server connections. Add session directory for aggregating agents across daemons, with automatic routing based on agent/server context.

Key changes:
- Add daemon profile management (add/edit/remove/set default)
- Implement connection state tracking with reconnection and exponential backoff
- Create useAggregatedAgents hook for unified agent list across daemons
- Add useDaemonSession and useDaemonRequest hooks for daemon-scoped operations
- Update agent routes to include serverId for proper session routing
- Add connection health indicators on home screen and settings
- Persist session snapshots per daemon for faster hydration on reconnect
- Guard screens gracefully when target daemon is offline
- Add React Query for consistent loading/error state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 08:25:02 +01:00

240 lines
7.7 KiB
TypeScript

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<string, DaemonConnectionRecord>;
isLoading: boolean;
setActiveDaemonId: (daemonId: string) => void;
updateConnectionStatus: (
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>;
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
unregisterSessionAccessor: (daemonId: string) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void;
}
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(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<string | null>(null);
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessors, setSessionAccessors] = useState<Map<string, SessionDirectoryEntry>>(new Map());
const queryClient = useQueryClient();
const activeDaemonPreference = useQuery({
queryKey: ACTIVE_DAEMON_QUERY_KEY,
queryFn: loadActiveDaemonPreference,
staleTime: Infinity,
gcTime: Infinity,
});
const sessionDirectoryListenersRef = useRef<Set<() => void>>(new Set());
useEffect(() => {
if (activeDaemonPreference.isPending) {
return;
}
setActiveDaemonIdState(activeDaemonPreference.data ?? null);
}, [activeDaemonPreference.data, activeDaemonPreference.isPending]);
// Ensure connection states stay in sync with registry entries
useEffect(() => {
setConnectionStates((prev) => {
const next = new Map<string, DaemonConnectionRecord>();
for (const daemon of daemons) {
const existing = prev.get(daemon.id);
next.set(daemon.id, {
daemon,
status: existing?.status ?? "idle",
lastOnlineAt: existing?.lastOnlineAt,
lastError: existing?.lastError,
});
}
return next;
});
}, [daemons]);
useEffect(() => {
setSessionAccessors((prev) => {
const next = new Map(prev);
for (const key of Array.from(next.keys())) {
if (!daemons.some((daemon) => daemon.id === key)) {
next.delete(key);
}
}
return next;
});
}, [daemons]);
// Keep active daemon aligned with registry default
const persistActiveDaemonId = useCallback(async (daemonId: string | null) => {
queryClient.setQueryData<string | null>(ACTIVE_DAEMON_QUERY_KEY, daemonId);
try {
if (daemonId) {
await AsyncStorage.setItem(ACTIVE_DAEMON_STORAGE_KEY, daemonId);
} else {
await AsyncStorage.removeItem(ACTIVE_DAEMON_STORAGE_KEY);
}
} catch (error) {
console.error("[DaemonConnections] Failed to persist active daemon", error);
}
}, [queryClient]);
const setActiveDaemonId = useCallback(
(daemonId: string) => {
setActiveDaemonIdState(daemonId);
void persistActiveDaemonId(daemonId);
},
[persistActiveDaemonId]
);
useEffect(() => {
if (activeDaemonPreference.isPending) {
return;
}
if (daemons.length === 0) {
setActiveDaemonIdState(null);
void persistActiveDaemonId(null);
return;
}
if (activeDaemonId && daemons.some((daemon) => daemon.id === activeDaemonId)) {
return;
}
const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0];
setActiveDaemonId(fallback.id);
}, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]);
const activeDaemon = useMemo(() => {
if (!activeDaemonId) {
return null;
}
return daemons.find((daemon) => daemon.id === activeDaemonId) ?? null;
}, [activeDaemonId, daemons]);
const updateConnectionStatus = useCallback(
(
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => {
setConnectionStates((prev) => {
const next = new Map(prev);
const existing = next.get(daemonId);
if (!existing) {
return prev;
}
const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError"));
next.set(daemonId, {
...existing,
status,
lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null,
lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt,
});
return next;
});
},
[]
);
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.set(daemonId, entry);
return next;
});
}, []);
const unregisterSessionAccessor = useCallback((daemonId: string) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.delete(daemonId);
return next;
});
}, []);
const subscribeToSessionDirectory = useCallback((listener: () => void) => {
sessionDirectoryListenersRef.current.add(listener);
return () => {
sessionDirectoryListenersRef.current.delete(listener);
};
}, []);
const notifySessionDirectoryChange = useCallback(() => {
const listeners = Array.from(sessionDirectoryListenersRef.current);
for (const listener of listeners) {
try {
listener();
} catch (error) {
console.error("[DaemonConnections] Session directory listener failed", error);
}
}
}, []);
const value: DaemonConnectionsContextValue = {
activeDaemonId,
activeDaemon,
connectionStates,
isLoading: registryLoading || activeDaemonPreference.isPending,
setActiveDaemonId,
updateConnectionStatus,
sessionAccessors,
registerSessionAccessor,
unregisterSessionAccessor,
subscribeToSessionDirectory,
notifySessionDirectoryChange,
};
return (
<DaemonConnectionsContext.Provider value={value}>
{children}
</DaemonConnectionsContext.Provider>
);
}
async function loadActiveDaemonPreference(): Promise<string | null> {
try {
const stored = await AsyncStorage.getItem(ACTIVE_DAEMON_STORAGE_KEY);
return stored ?? null;
} catch (error) {
console.error("[DaemonConnections] Failed to read active daemon preference", error);
throw error;
}
}