From db87f331fbf2d8e735095f9c1bac2c0e5fc8110e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 26 Nov 2025 18:30:04 +0100 Subject: [PATCH] Remove primary host plumbing --- packages/app/src/app/_layout.tsx | 24 +-- packages/app/src/app/index.tsx | 4 +- packages/app/src/app/orchestrator.tsx | 31 ++- packages/app/src/app/settings.tsx | 107 +++++++-- .../app/src/components/agent-input-area.tsx | 57 +++-- .../app/src/components/agent-status-bar.tsx | 8 +- .../app/src/components/agent-stream-view.tsx | 21 +- .../app/src/components/create-agent-modal.tsx | 6 +- packages/app/src/components/global-footer.tsx | 115 +++++++++- .../components/multi-daemon-session-host.tsx | 44 +--- .../app/src/components/realtime-controls.tsx | 3 - .../contexts/daemon-connections-context.tsx | 203 +++--------------- .../app/src/contexts/realtime-context.tsx | 160 ++++++++------ packages/app/src/contexts/session-context.tsx | 12 +- .../app/src/hooks/use-aggregated-agents.ts | 11 +- packages/app/src/hooks/use-daemon-session.ts | 31 ++- plan.md | 5 +- 17 files changed, 455 insertions(+), 387 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 422b2ab98..588b3cf71 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -3,15 +3,14 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; 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 { 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 { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { DaemonConnectionsProvider } 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"; @@ -46,26 +45,19 @@ function AppContainer({ children }: { children: ReactNode }) { } function ProvidersWrapper({ children }: { children: ReactNode }) { - const { settings, isLoading: settingsLoading } = useAppSettings(); - const { activeDaemon, isLoading: connectionsLoading } = useDaemonConnections(); + const { isLoading: settingsLoading } = useAppSettings(); + const { daemons, isLoading: registryLoading } = useDaemonRegistry(); + const isLoading = settingsLoading || registryLoading; - if (settingsLoading || connectionsLoading) { + if (isLoading) { return ; } - if (!activeDaemon) { + if (daemons.length === 0) { return ; } - return ( - - {children} - - ); + return {children}; } function LoadingView() { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index d25aeca01..3d7ad105d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -8,7 +8,6 @@ import { HomeHeader } from "@/components/headers/home-header"; 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"; @@ -17,9 +16,8 @@ import { useLocalSearchParams } from "expo-router"; export default function HomeScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const { agents } = useSession(); const { connectionStates } = useDaemonConnections(); - const aggregatedAgents = useAggregatedAgents(agents); + const aggregatedAgents = useAggregatedAgents(); const [showCreateModal, setShowCreateModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [createModalMounted, setCreateModalMounted] = useState(false); diff --git a/packages/app/src/app/orchestrator.tsx b/packages/app/src/app/orchestrator.tsx index bb54d8857..65cd3963b 100644 --- a/packages/app/src/app/orchestrator.tsx +++ b/packages/app/src/app/orchestrator.tsx @@ -1,21 +1,42 @@ import { View } from "react-native"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; import { OrchestratorMessagesView } from "@/components/orchestrator-messages-view"; -import { useSession } from "@/contexts/session-context"; +import { useSessionDirectory } from "@/hooks/use-session-directory"; import type { ScrollView } from "react-native"; import type { Artifact } from "@/components/artifact-drawer"; +import type { MessageEntry } from "@/contexts/session-context"; export default function OrchestratorScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const { messages, currentAssistantMessage } = useSession(); + const sessionDirectory = useSessionDirectory(); const scrollViewRef = useRef(null); const [currentArtifact, setCurrentArtifact] = useState(null); + const aggregatedMessages = useMemo(() => { + const merged: MessageEntry[] = []; + sessionDirectory.forEach((session) => { + if (!session) { + return; + } + merged.push(...session.messages); + }); + merged.sort((left, right) => left.timestamp - right.timestamp); + return merged; + }, [sessionDirectory]); + + const streamingAssistantMessage = useMemo(() => { + for (const session of sessionDirectory.values()) { + if (session?.currentAssistantMessage) { + return session.currentAssistantMessage; + } + } + return ""; + }, [sessionDirectory]); // Keyboard animation const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -42,8 +63,8 @@ export default function OrchestratorScreen() { diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 9cdca8a9b..fced3f170 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import type { MutableRefObject } from "react"; import { View, @@ -59,6 +59,31 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.semibold, marginBottom: theme.spacing[4], }, + hostSelectorRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + marginBottom: theme.spacing[3], + }, + hostSelectorChip: { + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.card, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + hostSelectorChipSelected: { + backgroundColor: theme.colors.foreground, + borderColor: theme.colors.foreground, + }, + hostSelectorChipText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + hostSelectorChipTextSelected: { + color: theme.colors.background, + }, label: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, @@ -324,12 +349,19 @@ type DaemonTestState = { export default function SettingsScreen() { const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = 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 { connectionStates, updateConnectionStatus } = useDaemonConnections(); + const [selectedDaemonId, setSelectedDaemonId] = useState(null); + const selectedDaemon = useMemo(() => { + if (!selectedDaemonId) { + return null; + } + return daemons.find((daemon) => daemon.id === selectedDaemonId) ?? null; + }, [daemons, selectedDaemonId]); + const selectedDaemonSession = useSessionForServer(selectedDaemonId); + const selectedDaemonConnection = selectedDaemonId ? connectionStates.get(selectedDaemonId) : null; + const selectedDaemonStatusLabel = selectedDaemonConnection ? formatConnectionStatus(selectedDaemonConnection.status) : null; - const [serverUrl, setServerUrl] = useState(activeDaemon?.wsUrl ?? ""); + const [serverUrl, setServerUrl] = useState(selectedDaemon?.wsUrl ?? ""); const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker); const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn); const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme); @@ -346,18 +378,28 @@ export default function SettingsScreen() { const [isSavingDaemon, setIsSavingDaemon] = useState(false); const [daemonTestStates, setDaemonTestStates] = useState>(() => new Map()); const isLoading = settingsLoading || daemonLoading; - const baselineServerUrl = activeDaemon?.wsUrl ?? ""; + const baselineServerUrl = selectedDaemon?.wsUrl ?? ""; const isMountedRef = useRef(true); - const isServerConfigLocked = Boolean(activeDaemon && !activeDaemonSession); - const serverDescriptionText = activeDaemon - ? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${ + const isServerConfigLocked = Boolean(selectedDaemon && !selectedDaemonSession); + const serverDescriptionText = selectedDaemon + ? `${selectedDaemon.label}${selectedDaemonStatusLabel ? ` - ${selectedDaemonStatusLabel}` : ""}${ isServerConfigLocked ? " - Session unavailable" : "" }` : "Add a host to configure its server URL."; const serverHelperText = isServerConfigLocked - ? `Connect to ${activeDaemon?.label ?? "this host"} to edit its server URL.` + ? `Connect to ${selectedDaemon?.label ?? "this host"} to edit its server URL.` : "Must be a valid WebSocket URL (ws:// or wss://)"; + useEffect(() => { + if (daemons.length === 0) { + setSelectedDaemonId(null); + return; + } + if (!selectedDaemonId || !daemons.some((daemon) => daemon.id === selectedDaemonId)) { + setSelectedDaemonId(daemons[0].id); + } + }, [daemons, selectedDaemonId]); + useEffect(() => { return () => { isMountedRef.current = false; @@ -472,10 +514,10 @@ export default function SettingsScreen() { }; if (daemonForm.id) { await updateDaemon(daemonForm.id, payload); - setActiveDaemonId(daemonForm.id, { source: "settings_save_daemon" }); + setSelectedDaemonId(daemonForm.id); } else { const created = await addDaemon(payload); - setActiveDaemonId(created.id, { source: "settings_save_daemon" }); + setSelectedDaemonId(created.id); } handleCloseDaemonForm(); } catch (error) { @@ -484,7 +526,7 @@ export default function SettingsScreen() { } finally { setIsSavingDaemon(false); } - }, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm, setActiveDaemonId]); + }, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm]); const handleRemoveDaemon = useCallback( (profile: DaemonProfile) => { @@ -549,8 +591,8 @@ export default function SettingsScreen() { }, [settings]); useEffect(() => { - setServerUrl(activeDaemon?.wsUrl ?? ""); - }, [activeDaemon?.wsUrl]); + setServerUrl(selectedDaemon?.wsUrl ?? ""); + }, [selectedDaemon?.wsUrl]); // Track changes useEffect(() => { @@ -600,17 +642,19 @@ export default function SettingsScreen() { theme, }); - if (activeDaemon) { - await updateDaemon(activeDaemon.id, { + if (selectedDaemon) { + await updateDaemon(selectedDaemon.id, { wsUrl: trimmedUrl, - label: activeDaemon.label || deriveDaemonLabel(trimmedUrl), + label: selectedDaemon.label || deriveDaemonLabel(trimmedUrl), }); + setSelectedDaemonId(selectedDaemon.id); } else { - await addDaemon({ + const created = await addDaemon({ label: deriveDaemonLabel(trimmedUrl), wsUrl: trimmedUrl, autoConnect: true, }); + setSelectedDaemonId(created.id); } Alert.alert( @@ -666,7 +710,7 @@ export default function SettingsScreen() { if (isServerConfigLocked) { Alert.alert( "Session unavailable", - `${activeDaemon?.label ?? "This host"} is not connected. Connect to it before testing the URL.` + `${selectedDaemon?.label ?? "This host"} is not connected. Connect to it before testing the URL.` ); return; } @@ -720,6 +764,27 @@ export default function SettingsScreen() { {/* Server Configuration */} Server Configuration + {daemons.length > 0 ? ( + + {daemons.map((daemon) => { + const isSelected = daemon.id === selectedDaemonId; + return ( + setSelectedDaemonId(daemon.id)} + > + + {daemon.label} + + + ); + })} + + ) : null} {serverDescriptionText} WebSocket URL diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 602542e7d..3126fcfa2 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -10,7 +10,7 @@ import { Text, ActivityIndicator, } from "react-native"; -import { useState, useEffect, useRef, useLayoutEffect, useCallback } from "react"; +import { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native"; import Animated, { @@ -31,6 +31,9 @@ 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"; +import type { UseWebSocketReturn } from "@/hooks/use-websocket"; +import type { SessionContextValue, Agent } from "@/contexts/session-context"; +import type { SessionOutboundMessage } from "@server/server/messages"; type QueuedMessage = { id: string; @@ -71,17 +74,35 @@ type TextAreaHandle = { export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const { theme } = useUnistyles(); - const { - ws, - sendAgentMessage, - sendAgentAudio, - agents, - cancelAgentRun, - getDraftInput, - saveDraftInput, - queuedMessages: queuedMessagesByAgent, - setQueuedMessages: setQueuedMessagesByAgent, - } = useDaemonSession(serverId); + const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); + const inertWebSocket = useMemo( + () => ({ + isConnected: false, + isConnecting: false, + conversationId: null, + lastError: null, + send: () => {}, + on: () => () => {}, + sendPing: () => {}, + sendUserMessage: () => {}, + }), + [] + ); + const noopSendAgentMessage = useCallback(async () => {}, []); + const noopSendAgentAudio = useCallback(async () => {}, []); + const noopCancelAgentRun = useCallback(() => {}, []); + const noopGetDraftInput = useCallback(() => undefined, []); + const noopSaveDraftInput = useCallback(() => {}, []); + const noopSetQueuedMessages = useCallback(() => {}, []); + const ws = session?.ws ?? inertWebSocket; + const sendAgentMessage = session?.sendAgentMessage ?? noopSendAgentMessage; + const sendAgentAudio = session?.sendAgentAudio ?? noopSendAgentAudio; + const agents = session?.agents ?? new Map(); + const cancelAgentRun = session?.cancelAgentRun ?? noopCancelAgentRun; + const getDraftInput = session?.getDraftInput ?? noopGetDraftInput; + const saveDraftInput = session?.saveDraftInput ?? noopSaveDraftInput; + const queuedMessagesByAgent = session?.queuedMessages ?? new Map(); + const setQueuedMessagesByAgent = session?.setQueuedMessages ?? noopSetQueuedMessages; const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [userInput, setUserInput] = useState(""); @@ -268,7 +289,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { } useEffect(() => { - const unsubscribe = ws.on("transcription_result", (message) => { + const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => { if (message.type !== "transcription_result") { return; } @@ -626,10 +647,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { useEffect(() => { const existing = getDraftInput(agentId); const isSameText = existing?.text === userInput; - const existingImages = existing?.images ?? []; + const existingImages: Array<{ uri: string; mimeType?: string }> = existing?.images ?? []; const isSameImages = existingImages.length === selectedImages.length && - existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType); + existingImages.every((img: { uri: string; mimeType?: string }, idx: number) => { + return img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType; + }); if (isSameText && isSameImages) { return; @@ -658,10 +681,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { if (isRealtimeMode) { await stopRealtime(); } else { - if (!ws.isConnected) { + if (!ws.isConnected || !serverId) { return; } - await startRealtime(); + await startRealtime(serverId); } } catch (error) { console.error("[AgentInput] Failed to toggle realtime mode:", error); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 97f42fdd4..15c20ad12 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,9 +1,10 @@ import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronDown } from "lucide-react-native"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { ModeSelectorModal } from "./mode-selector-modal"; import { useDaemonSession } from "@/hooks/use-daemon-session"; +import type { SessionContextValue, Agent } from "@/contexts/session-context"; interface AgentStatusBarProps { agentId: string; @@ -12,7 +13,10 @@ interface AgentStatusBarProps { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { const { theme } = useUnistyles(); - const { agents, setAgentMode } = useDaemonSession(serverId); + const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); + const noopSetAgentMode = useCallback(() => {}, []); + const agents = session?.agents ?? new Map(); + const setAgentMode = session?.setAgentMode ?? noopSetAgentMode; const [showModeSelector, setShowModeSelector] = useState(false); const agent = agents.get(agentId); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 029ecd10c..f350b871e 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -71,9 +71,24 @@ export function AgentStreamView({ const isNearBottomRef = useRef(true); const isUserScrollingRef = useRef(false); const router = useRouter(); - const session = useDaemonSession(serverId); - const { requestDirectoryListing, requestFilePreview, ws } = session; - const resolvedServerId = serverId ?? session.serverId; + const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); + const inertWebSocket = useMemo( + () => ({ + isConnected: false, + isConnecting: false, + conversationId: null, + lastError: null, + send: () => {}, + on: () => () => {}, + sendPing: () => {}, + sendUserMessage: () => {}, + }), + [] + ); + const ws = session?.ws ?? inertWebSocket; + const requestDirectoryListing = session?.requestDirectoryListing ?? (() => {}); + const requestFilePreview = session?.requestFilePreview ?? (() => {}); + const resolvedServerId = serverId ?? session?.serverId ?? agent.serverId ?? ""; // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index a04878ee8..cdae03882 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -62,7 +62,7 @@ import { useDaemonRequest } from "@/hooks/use-daemon-request"; import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages"; import { formatConnectionStatus } from "@/utils/daemons"; import { trackAnalyticsEvent } from "@/utils/analytics"; -import { useSession, type SessionContextValue } from "@/contexts/session-context"; +import type { SessionContextValue } from "@/contexts/session-context"; import { useSessionDirectory, useSessionForServer } from "@/hooks/use-session-directory"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; @@ -224,10 +224,8 @@ function AgentFlowModal({ return daemonEntries[0]?.daemon.id ?? null; }, [serverId, daemonEntries]); const [selectedServerId, setSelectedServerId] = useState(initialServerId); - const activeSession = useSession(); const selectedSession = useSessionForServer(selectedServerId); - const session = - selectedServerId === null ? activeSession : selectedSession ?? null; + const session = selectedSession ?? null; const sessionDirectory = useSessionDirectory(); useEffect(() => { diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/global-footer.tsx index afd48c014..61ec0221e 100644 --- a/packages/app/src/components/global-footer.tsx +++ b/packages/app/src/components/global-footer.tsx @@ -1,11 +1,11 @@ -import { useState } from "react"; -import { View, Pressable, Text, Platform } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import { View, Pressable, Text, Platform, Modal, Alert } 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, Download } from "lucide-react-native"; import { useRealtime } from "@/contexts/realtime-context"; -import { useSession } from "@/contexts/session-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { RealtimeControls } from "./realtime-controls"; import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal"; @@ -22,10 +22,11 @@ export function GlobalFooter() { const pathname = usePathname(); const router = useRouter(); const { isRealtimeMode, startRealtime } = useRealtime(); - const { ws } = useSession(); + const { connectionStates } = useDaemonConnections(); const { controls } = useFooterControls(); const [showCreateModal, setShowCreateModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); + const [showRealtimeHostPicker, setShowRealtimeHostPicker] = 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"; @@ -53,6 +54,40 @@ export function GlobalFooter() { [bottomInset], ); + const realtimeEligibleHosts = useMemo(() => { + return Array.from(connectionStates.values()).filter((entry) => entry.status === "online"); + }, [connectionStates]); + + const handleStartRealtime = useCallback(() => { + if (realtimeEligibleHosts.length === 0) { + Alert.alert("No connected hosts", "Connect a host before starting realtime mode."); + return; + } + if (realtimeEligibleHosts.length === 1) { + void startRealtime(realtimeEligibleHosts[0].daemon.id).catch((error) => { + console.error("[GlobalFooter] Failed to start realtime", error); + Alert.alert("Realtime failed", "Unable to start realtime mode for this host."); + }); + return; + } + setShowRealtimeHostPicker(true); + }, [realtimeEligibleHosts, startRealtime]); + + const handleSelectRealtimeHost = useCallback( + (daemonId: string) => { + setShowRealtimeHostPicker(false); + void startRealtime(daemonId).catch((error) => { + console.error("[GlobalFooter] Failed to start realtime", error); + Alert.alert("Realtime failed", "Unable to start realtime mode for this host."); + }); + }, + [startRealtime] + ); + + const handleDismissHostPicker = useCallback(() => { + setShowRealtimeHostPicker(false); + }, []); + if (showAgentControls) { return ( [ styles.footerButton, - !ws.isConnected && styles.buttonDisabled, - pressed && !ws.isConnected && styles.buttonPressed, + realtimeEligibleHosts.length === 0 && styles.buttonDisabled, + pressed && realtimeEligibleHosts.length === 0 && styles.buttonPressed, ]} > @@ -191,6 +226,31 @@ export function GlobalFooter() { isVisible={showImportModal} onClose={() => setShowImportModal(false)} /> + + + + + Choose a host + {realtimeEligibleHosts.map((entry) => ( + handleSelectRealtimeHost(entry.daemon.id)} + > + {entry.daemon.label} + + ))} + + Cancel + + + + ); } @@ -240,4 +300,43 @@ const styles = StyleSheet.create((theme) => ({ buttonPressed: { opacity: 0.5, }, + hostPickerOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "flex-end", + }, + hostPickerBackdrop: { + flex: 1, + }, + hostPickerContainer: { + backgroundColor: theme.colors.card, + padding: theme.spacing[4], + borderTopLeftRadius: theme.borderRadius.xl, + borderTopRightRadius: theme.borderRadius.xl, + gap: theme.spacing[3], + }, + hostPickerTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + hostPickerButton: { + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.muted, + }, + hostPickerButtonText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + hostPickerCancel: { + paddingVertical: theme.spacing[3], + }, + hostPickerCancelText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, })); diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 5543ef575..61c8047ab 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -1,23 +1,11 @@ import { useEffect, useMemo, useRef } 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, sessionAccessorRoles } = useDaemonConnections(); const autoConnectStatesRef = useRef>(new Map()); - const primaryDaemonIds = useMemo(() => { - const ids = new Set(); - sessionAccessorRoles.forEach((roles, daemonId) => { - if (roles.has("primary")) { - ids.add(daemonId); - } - }); - return ids; - }, [sessionAccessorRoles]); - useEffect(() => { const trackedStates = autoConnectStatesRef.current; const activeIds = new Set(); @@ -44,38 +32,18 @@ export function MultiDaemonSessionHost() { } }, [daemons]); - const backgroundDaemons = useMemo(() => { - const shouldConnect = new Map(); + const connectedDaemons = useMemo(() => { + return daemons.filter((daemon) => daemon.autoConnect !== false); + }, [daemons]); - for (const daemon of daemons) { - if (!daemon.autoConnect) { - continue; - } - if (daemon.id === activeDaemonId) { - continue; - } - if (primaryDaemonIds.has(daemon.id)) { - continue; - } - shouldConnect.set(daemon.id, daemon); - } - - return Array.from(shouldConnect.values()); - }, [daemons, activeDaemonId, primaryDaemonIds]); - - if (backgroundDaemons.length === 0) { + if (connectedDaemons.length === 0) { return null; } return ( <> - {backgroundDaemons.map((daemon) => ( - + {connectedDaemons.map((daemon) => ( + {null} ))} diff --git a/packages/app/src/components/realtime-controls.tsx b/packages/app/src/components/realtime-controls.tsx index 8a164dae0..535ed35c7 100644 --- a/packages/app/src/components/realtime-controls.tsx +++ b/packages/app/src/components/realtime-controls.tsx @@ -3,7 +3,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; import { useRealtime } from "@/contexts/realtime-context"; -import { useSession } from "@/contexts/session-context"; import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; const CONTROL_BUTTON_SIZE = 48; @@ -11,7 +10,6 @@ const VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2; export function RealtimeControls() { const { theme } = useUnistyles(); - const { audioPlayer } = useSession(); const { volume, isMuted, @@ -23,7 +21,6 @@ export function RealtimeControls() { } = useRealtime(); function handleStop() { - audioPlayer.stop(); stopRealtime(); } diff --git a/packages/app/src/contexts/daemon-connections-context.tsx b/packages/app/src/contexts/daemon-connections-context.tsx index e6617c8e7..1ba69d9d0 100644 --- a/packages/app/src/contexts/daemon-connections-context.tsx +++ b/packages/app/src/contexts/daemon-connections-context.tsx @@ -1,18 +1,13 @@ 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 { trackAnalyticsEvent } from "@/utils/analytics"; import type { SessionContextValue } from "./session-context"; export interface SessionDirectoryEntry { getSnapshot: () => SessionContextValue | null; subscribe: (listener: () => void) => () => void; } - -export type SessionAccessorRole = "primary" | "background"; -type SessionAccessorRegistry = Map>; +type SessionAccessorRegistry = Map; export type ConnectionState = | { status: "idle"; lastError: null; lastOnlineAt: string | null } @@ -35,33 +30,18 @@ export type DaemonConnectionRecord = { } & ConnectionState; interface DaemonConnectionsContextValue { - activeDaemonId: string | null; - activeDaemon: DaemonProfile | null; connectionStates: Map; isLoading: boolean; - setActiveDaemonId: (daemonId: string, options?: SetActiveDaemonOptions) => void; updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void; sessionAccessors: Map; - sessionAccessorRoles: Map>; - registerSessionAccessor: ( - daemonId: string, - entry: SessionDirectoryEntry, - role?: SessionAccessorRole - ) => void; - unregisterSessionAccessor: (daemonId: string, role?: SessionAccessorRole) => void; + 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 type SetActiveDaemonOptions = { - source?: string; -}; - function createDefaultConnectionState(): ConnectionState { return { status: "idle", @@ -132,25 +112,10 @@ export function useDaemonConnections(): DaemonConnectionsContextValue { export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { const { daemons, isLoading: registryLoading } = useDaemonRegistry(); - const [activeDaemonId, setActiveDaemonIdState] = useState(null); const [connectionStates, setConnectionStates] = useState>(new Map()); const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState(new Map()); - const queryClient = useQueryClient(); - const activeDaemonPreference = useQuery({ - queryKey: ACTIVE_DAEMON_QUERY_KEY, - queryFn: loadActiveDaemonPreference, - staleTime: Infinity, - gcTime: Infinity, - }); const sessionDirectoryListenersRef = useRef void>>(new Set()); - useEffect(() => { - if (activeDaemonPreference.isPending) { - return; - } - setActiveDaemonIdState(activeDaemonPreference.data ?? null); - }, [activeDaemonPreference.data, activeDaemonPreference.isPending]); - // Ensure connection states stay in sync with registry entries useEffect(() => { setConnectionStates((prev) => { @@ -172,77 +137,18 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) let changed = false; const next: SessionAccessorRegistry = new Map(); - for (const [daemonId, roleMap] of prev.entries()) { + for (const [daemonId, entry] of prev.entries()) { if (!validDaemonIds.has(daemonId)) { changed = true; continue; } - next.set(daemonId, roleMap); + next.set(daemonId, entry); } return changed ? next : prev; }); }, [daemons]); - // Keep active daemon aligned with registry default - const persistActiveDaemonId = useCallback(async (daemonId: string | null) => { - queryClient.setQueryData(ACTIVE_DAEMON_QUERY_KEY, daemonId); - try { - if (daemonId) { - await AsyncStorage.setItem(ACTIVE_DAEMON_STORAGE_KEY, daemonId); - } else { - await AsyncStorage.removeItem(ACTIVE_DAEMON_STORAGE_KEY); - } - } catch (error) { - console.error("[DaemonConnections] Failed to persist active daemon", error); - } - }, [queryClient]); - - const setActiveDaemonId = useCallback( - (daemonId: string, options?: SetActiveDaemonOptions) => { - setActiveDaemonIdState((previous) => { - if (previous !== daemonId) { - trackAnalyticsEvent({ - type: "daemon_active_changed", - daemonId, - previousDaemonId: previous, - source: options?.source, - }); - } - return 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[0]; - if (fallback) { - setActiveDaemonId(fallback.id, { source: "auto_fallback" }); - } - }, [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, update: ConnectionStateUpdate) => { @@ -269,41 +175,28 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) [] ); - const registerSessionAccessor = useCallback( - (daemonId: string, entry: SessionDirectoryEntry, role: SessionAccessorRole = "primary") => { - setSessionAccessorRegistry((prev) => { - const next = new Map(prev); - const existing = new Map(next.get(daemonId) ?? []); - existing.set(role, entry); - next.set(daemonId, existing); - return next; - }); - }, - [] - ); + const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => { + setSessionAccessorRegistry((prev) => { + const next = new Map(prev); + const hasExisting = next.has(daemonId); + next.set(daemonId, entry); + if (hasExisting) { + console.warn(`[DaemonConnections] Duplicate session accessor detected for "${daemonId}". Overwriting.`); + } + return next; + }); + }, []); - const unregisterSessionAccessor = useCallback( - (daemonId: string, role: SessionAccessorRole = "primary") => { - setSessionAccessorRegistry((prev) => { - const existing = prev.get(daemonId); - if (!existing) { - return prev; - } - - const nextRoleMap = new Map(existing); - nextRoleMap.delete(role); - - const next = new Map(prev); - if (nextRoleMap.size === 0) { - next.delete(daemonId); - } else { - next.set(daemonId, nextRoleMap); - } - return next; - }); - }, - [] - ); + const unregisterSessionAccessor = useCallback((daemonId: string) => { + setSessionAccessorRegistry((prev) => { + if (!prev.has(daemonId)) { + return prev; + } + const next = new Map(prev); + next.delete(daemonId); + return next; + }); + }, []); const subscribeToSessionDirectory = useCallback((listener: () => void) => { sessionDirectoryListenersRef.current.add(listener); @@ -323,43 +216,13 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) } }, []); - const { sessionAccessors, sessionAccessorRoles } = useMemo(() => { - const flattened = new Map(); - const roleSets = new Map>(); - - sessionAccessorRegistry.forEach((roleMap, daemonId) => { - if (roleMap.size === 0) { - return; - } - - const roles = new Set(); - roleMap.forEach((_entry, role) => roles.add(role)); - roleSets.set(daemonId, roles); - - const primaryEntry = roleMap.get("primary"); - if (primaryEntry) { - flattened.set(daemonId, primaryEntry); - return; - } - - const backgroundEntry = roleMap.get("background"); - if (backgroundEntry) { - flattened.set(daemonId, backgroundEntry); - } - }); - - return { sessionAccessors: flattened, sessionAccessorRoles: roleSets }; - }, [sessionAccessorRegistry]); + const sessionAccessors = useMemo(() => new Map(sessionAccessorRegistry), [sessionAccessorRegistry]); const value: DaemonConnectionsContextValue = { - activeDaemonId, - activeDaemon, connectionStates, - isLoading: registryLoading || activeDaemonPreference.isPending, - setActiveDaemonId, + isLoading: registryLoading, updateConnectionStatus, sessionAccessors, - sessionAccessorRoles, registerSessionAccessor, unregisterSessionAccessor, subscribeToSessionDirectory, @@ -372,13 +235,3 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode }) ); } - -async function loadActiveDaemonPreference(): Promise { - try { - const stored = await AsyncStorage.getItem(ACTIVE_DAEMON_STORAGE_KEY); - return stored ?? null; - } catch (error) { - console.error("[DaemonConnections] Failed to read active daemon preference", error); - throw error; - } -} diff --git a/packages/app/src/contexts/realtime-context.tsx b/packages/app/src/contexts/realtime-context.tsx index f8dd17812..0ae489419 100644 --- a/packages/app/src/contexts/realtime-context.tsx +++ b/packages/app/src/contexts/realtime-context.tsx @@ -1,6 +1,7 @@ -import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react"; +import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef, useMemo } from "react"; import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; -import { useSession } from "./session-context"; +import { useSessionDirectory } from "@/hooks/use-session-directory"; +import type { SessionContextValue } from "./session-context"; import { generateMessageId } from "@/types/stream"; import type { WSInboundMessage } from "@server/server/messages"; @@ -11,9 +12,10 @@ interface RealtimeContextValue { isDetecting: boolean; isSpeaking: boolean; segmentDuration: number; - startRealtime: () => Promise; + startRealtime: (serverId: string) => Promise; stopRealtime: () => Promise; toggleMute: () => void; + activeServerId: string | null; } const RealtimeContext = createContext(null); @@ -31,13 +33,9 @@ interface RealtimeProviderProps { } export function RealtimeProvider({ children }: RealtimeProviderProps) { - const { - ws, - audioPlayer, - isPlayingAudio, - setMessages, - setVoiceDetectionFlags, - } = useSession(); + const sessionDirectory = useSessionDirectory(); + const [activeServerId, setActiveServerId] = useState(null); + const realtimeSessionRef = useRef(null); const [isRealtimeMode, setIsRealtimeMode] = useState(false); const bargeInPlaybackStopRef = useRef(null); @@ -45,22 +43,29 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { onSpeechStart: () => { console.log("[Realtime] Speech detected"); // Stop audio playback if playing - if (isPlayingAudio) { + const session = realtimeSessionRef.current; + const sessionAudioPlayer = session?.audioPlayer; + const sessionWs = session?.ws; + const sessionIsPlayingAudio = session?.isPlayingAudio ?? false; + + if (sessionIsPlayingAudio && sessionAudioPlayer) { if (bargeInPlaybackStopRef.current === null) { bargeInPlaybackStopRef.current = Date.now(); } - audioPlayer.stop(); + sessionAudioPlayer.stop(); } // Abort any in-flight orchestrator turn before the new speech segment streams try { - const abortMessage: WSInboundMessage = { - type: "session", - message: { - type: "abort_request", - }, - }; - ws.send(abortMessage); + if (sessionWs) { + const abortMessage: WSInboundMessage = { + type: "session", + message: { + type: "abort_request", + }, + }; + sessionWs.send(abortMessage); + } console.log("[Realtime] Sent abort_request before streaming audio"); } catch (error) { console.error("[Realtime] Failed to send abort_request:", error); @@ -78,8 +83,9 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { ); // Send audio segment to server (realtime always goes to orchestrator) + const session = realtimeSessionRef.current; try { - ws.send({ + session?.ws.send({ type: "session", message: { type: "realtime_audio_chunk", @@ -94,16 +100,19 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { }, onError: (error) => { console.error("[Realtime] Audio error:", error); - setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "error", - message: `Realtime audio error: ${error.message}`, - }, - ]); + const session = realtimeSessionRef.current; + if (session) { + session.setMessages((prev) => [ + ...prev, + { + type: "activity", + id: generateMessageId(), + timestamp: Date.now(), + activityType: "error", + message: `Realtime audio error: ${error.message}`, + }, + ]); + } }, volumeThreshold: 0.3, silenceDuration: 2000, @@ -113,8 +122,22 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { // Update voice detection flags whenever they change useEffect(() => { - setVoiceDetectionFlags(realtimeAudio.isDetecting, realtimeAudio.isSpeaking); - }, [realtimeAudio.isDetecting, realtimeAudio.isSpeaking, setVoiceDetectionFlags]); + const session = realtimeSessionRef.current; + session?.setVoiceDetectionFlags(realtimeAudio.isDetecting, realtimeAudio.isSpeaking); + }, [realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); + + const activeSession = useMemo(() => { + if (!activeServerId) { + return null; + } + return sessionDirectory.get(activeServerId) ?? null; + }, [activeServerId, sessionDirectory]); + + useEffect(() => { + realtimeSessionRef.current = activeSession; + }, [activeSession]); + + const isPlayingAudio = activeSession?.isPlayingAudio ?? false; useEffect(() => { if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) { @@ -128,47 +151,61 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { } }, [isPlayingAudio]); - const startRealtime = useCallback(async () => { - try { - await realtimeAudio.start(); - setIsRealtimeMode(true); - console.log("[Realtime] Mode enabled"); + const startRealtime = useCallback( + async (serverId: string) => { + const session = sessionDirectory.get(serverId) ?? null; + if (!session) { + throw new Error(`Host ${serverId} is not connected`); + } - // Notify server - const modeMessage: WSInboundMessage = { - type: "session", - message: { - type: "set_realtime_mode", - enabled: true, - }, - }; - ws.send(modeMessage); - } catch (error: any) { - console.error("[Realtime] Failed to start:", error); - throw error; - } - }, [realtimeAudio, ws]); + try { + realtimeSessionRef.current = session; + setActiveServerId(serverId); + await realtimeAudio.start(); + setIsRealtimeMode(true); + console.log("[Realtime] Mode enabled"); + + const modeMessage: WSInboundMessage = { + type: "session", + message: { + type: "set_realtime_mode", + enabled: true, + }, + }; + session.ws.send(modeMessage); + } catch (error: any) { + console.error("[Realtime] Failed to start:", error); + setActiveServerId((current) => (current === serverId ? null : current)); + throw error; + } + }, + [realtimeAudio, sessionDirectory] + ); const stopRealtime = useCallback(async () => { try { + const session = realtimeSessionRef.current; + session?.audioPlayer.stop(); await realtimeAudio.stop(); setIsRealtimeMode(false); + setActiveServerId(null); console.log("[Realtime] Mode disabled"); - // Notify server - const modeMessage: WSInboundMessage = { - type: "session", - message: { - type: "set_realtime_mode", - enabled: false, - }, - }; - ws.send(modeMessage); + if (session) { + const modeMessage: WSInboundMessage = { + type: "session", + message: { + type: "set_realtime_mode", + enabled: false, + }, + }; + session.ws.send(modeMessage); + } } catch (error: any) { console.error("[Realtime] Failed to stop:", error); throw error; } - }, [realtimeAudio, ws]); + }, [realtimeAudio]); const value: RealtimeContextValue = { isRealtimeMode, @@ -180,6 +217,7 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) { startRealtime, stopRealtime, toggleMute: realtimeAudio.toggleMute, + activeServerId, }; return ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 7583e178f..d06aa2e71 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -29,7 +29,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { ScrollView } from "react-native"; import * as FileSystem from 'expo-file-system'; -import { useDaemonConnections, type SessionAccessorRole } from "./daemon-connections-context"; +import { useDaemonConnections } from "./daemon-connections-context"; const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => { const fallbackId = @@ -388,11 +388,9 @@ interface SessionProviderProps { children: ReactNode; serverUrl: string; serverId: string; - role?: SessionAccessorRole; } -export function SessionProvider({ children, serverUrl, serverId, role }: SessionProviderProps) { - const sessionRole = role ?? "primary"; +export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) { const ws = useWebSocket(serverUrl); const wsIsConnected = ws.isConnected; const { @@ -1796,11 +1794,11 @@ export function SessionProvider({ children, serverUrl, serverId, role }: Session ); useEffect(() => { - registerSessionAccessor(serverId, sessionDirectoryEntry, sessionRole); + registerSessionAccessor(serverId, sessionDirectoryEntry); return () => { - unregisterSessionAccessor(serverId, sessionRole); + unregisterSessionAccessor(serverId); }; - }, [serverId, sessionRole, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); + }, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); return ( diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index a193a8083..117eeb26a 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -9,6 +9,8 @@ export interface AggregatedAgentGroup { agents: Agent[]; } +const EMPTY_AGENT_MAP: Map = new Map(); + const sortAgents = (agents: Agent[]): Agent[] => { return [...agents].sort((left, right) => { const leftRunning = left.status === "running"; @@ -25,9 +27,10 @@ const sortAgents = (agents: Agent[]): Agent[] => { }); }; -export function useAggregatedAgents(fallbackAgents: Map): AggregatedAgentGroup[] { +export function useAggregatedAgents(fallbackAgents?: Map): AggregatedAgentGroup[] { const { connectionStates } = useDaemonConnections(); const sessionDirectory = useSessionDirectory(); + const fallback = fallbackAgents ?? EMPTY_AGENT_MAP; return useMemo(() => { const groups = new Map(); @@ -55,10 +58,10 @@ export function useAggregatedAgents(fallbackAgents: Map): Aggrega }); } - if (groups.size === 0) { + if (groups.size === 0 && fallback.size > 0) { const fallbackServerId = connectionStates.keys().next().value ?? "default"; const label = connectionStates.get(fallbackServerId)?.daemon.label ?? fallbackServerId; - const fallbackList = Array.from(fallbackAgents.values()); + const fallbackList = Array.from(fallback.values()); if (fallbackList.length > 0) { groups.set(fallbackServerId, { serverLabel: label, agents: fallbackList }); } @@ -86,5 +89,5 @@ export function useAggregatedAgents(fallbackAgents: Map): Aggrega }); return aggregatedGroups; - }, [sessionDirectory, fallbackAgents, connectionStates]); + }, [sessionDirectory, fallback, connectionStates]); } diff --git a/packages/app/src/hooks/use-daemon-session.ts b/packages/app/src/hooks/use-daemon-session.ts index 9d104f74e..7522989a3 100644 --- a/packages/app/src/hooks/use-daemon-session.ts +++ b/packages/app/src/hooks/use-daemon-session.ts @@ -1,7 +1,6 @@ import { useRef } from "react"; import { Alert } from "react-native"; import type { SessionContextValue } from "@/contexts/session-context"; -import { useSession } from "@/contexts/session-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionDirectory } from "./use-session-directory"; @@ -32,45 +31,41 @@ type UseDaemonSessionOptions = { }; export function useDaemonSession( - serverId?: string, + serverId?: string | null, options?: UseDaemonSessionOptions & { allowUnavailable?: false } -): SessionContextValue; +): SessionContextValue | null; export function useDaemonSession( - serverId: string | undefined, + serverId: string | null | undefined, options: UseDaemonSessionOptions & { allowUnavailable: true } ): SessionContextValue | null; -export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) { - const activeSession = useSession(); +export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) { const sessionDirectory = useSessionDirectory(); const { connectionStates } = useDaemonConnections(); const alertedDaemonsRef = useRef>(new Set()); const loggedDaemonsRef = useRef>(new Set()); const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; - const targetServerId = serverId ?? activeSession.serverId; - const isActiveSession = targetServerId === activeSession.serverId; - - if (isActiveSession || !targetServerId) { - return activeSession; + if (!serverId) { + return null; } try { - return getSessionForServer(targetServerId, sessionDirectory); + return getSessionForServer(serverId, sessionDirectory); } catch (error) { if (error instanceof DaemonSessionUnavailableError) { - const connection = connectionStates.get(targetServerId); - const label = connection?.daemon.label ?? targetServerId; + const connection = connectionStates.get(serverId); + const label = connection?.daemon.label ?? serverId; const status = connection?.status ?? "unknown"; const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; const message = `${label} isn't connected yet (${status}). Switch to it or enable auto-connect so Paseo can reach it.${lastError}`; - if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) { - alertedDaemonsRef.current.add(targetServerId); + if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) { + alertedDaemonsRef.current.add(serverId); Alert.alert("Host unavailable", message.trim()); } - if (!loggedDaemonsRef.current.has(targetServerId)) { - loggedDaemonsRef.current.add(targetServerId); + if (!loggedDaemonsRef.current.has(serverId)) { + loggedDaemonsRef.current.add(serverId); console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); } diff --git a/plan.md b/plan.md index 9069a1c23..cb2ab70ad 100644 --- a/plan.md +++ b/plan.md @@ -19,13 +19,14 @@ The multi-daemon infrastructure is in place: session directory with daemon-scope - Internal code can keep "daemon" terminology; this is UI-only. - Updated every user-facing string (settings, modals, placeholders, defaults) to say "host" and spot-checked via targeted searches; no automated tests were run. - Context: Remaining "daemon" labels in git diff, file explorer, and agent detail screens now say "host" (updated `packages/app/src/app/git-diff.tsx`, `file-explorer.tsx`, `agent/[id].tsx`, and `agent/[serverId]/[agentId].tsx`). + - Review: Confirmed the agent redirect, file explorer, and git diff screens reflect the updated host wording with no lingering user-facing "daemon" strings. ### 2. Remove Active/Primary/Auto-Connect Concepts - [x] Remove the concept of "active daemon" from the UI and simplify to just "hosts". - Summary: Home/footer actions, create/import flows, and agent navigation now route directly via explicit host IDs with analytics/docs/aggregated list updates, verified with `npm run typecheck --workspace=@paseo/app`. - Context: `GlobalFooter`, the create/import modals, the agent screen, and the agent list previously read/write `activeDaemonId`, forcing a global active host before routing; they now rely on explicit host IDs instead. -- [ ] Remove the concept of "primary daemon"—no default host for actions. - - Context: `DaemonConnectionsProvider` still persists `activeDaemonId` in AsyncStorage and `MultiDaemonSessionHost` filters by `primary` roles to decide which hosts stay backgrounded, so the “primary/default” pipeline is intact and needs to be unwound. +- [x] Remove the concept of "primary daemon"—no default host for actions. + - Eliminated the persisted `activeDaemonId`, flattened the session directory, and refactored `_layout`, realtime, settings, and agent UI to always resolve hosts explicitly so every host gets its own `SessionProvider`; verified via `npm run typecheck --workspace=@paseo/app`. - [ ] Remove the "auto-connect" toggle from host settings—hosts always auto-connect when added. - [ ] Clean up any related state/UI that exposes these concepts to users.