Remove primary host plumbing

This commit is contained in:
Mohamed Boudra
2025-11-26 18:30:04 +01:00
parent 5807f62f1a
commit db87f331fb
17 changed files with 455 additions and 387 deletions

View File

@@ -3,15 +3,14 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { SessionProvider } from "@/contexts/session-context";
import { RealtimeProvider } from "@/contexts/realtime-context"; import { RealtimeProvider } from "@/contexts/realtime-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator, Text } from "react-native"; import { View, ActivityIndicator, Text } from "react-native";
import { GlobalFooter } from "@/components/global-footer"; import { GlobalFooter } from "@/components/global-footer";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { FooterControlsProvider } from "@/contexts/footer-controls-context"; import { FooterControlsProvider } from "@/contexts/footer-controls-context";
import { DaemonRegistryProvider } from "@/contexts/daemon-registry-context"; import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context"; import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
@@ -46,26 +45,19 @@ function AppContainer({ children }: { children: ReactNode }) {
} }
function ProvidersWrapper({ children }: { children: ReactNode }) { function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings(); const { isLoading: settingsLoading } = useAppSettings();
const { activeDaemon, isLoading: connectionsLoading } = useDaemonConnections(); const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
if (settingsLoading || connectionsLoading) { if (isLoading) {
return <LoadingView />; return <LoadingView />;
} }
if (!activeDaemon) { if (daemons.length === 0) {
return <MissingDaemonView />; return <MissingDaemonView />;
} }
return ( return <RealtimeProvider>{children}</RealtimeProvider>;
<SessionProvider
key={activeDaemon.id}
serverUrl={activeDaemon.wsUrl}
serverId={activeDaemon.id}
>
<RealtimeProvider>{children}</RealtimeProvider>
</SessionProvider>
);
} }
function LoadingView() { function LoadingView() {

View File

@@ -8,7 +8,6 @@ import { HomeHeader } from "@/components/headers/home-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { AgentList } from "@/components/agent-list"; import { AgentList } from "@/components/agent-list";
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal"; import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
import { useSession } from "@/contexts/session-context";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone, type ConnectionStatusTone } from "@/utils/daemons"; import { formatConnectionStatus, getConnectionStatusTone, type ConnectionStatusTone } from "@/utils/daemons";
@@ -17,9 +16,8 @@ import { useLocalSearchParams } from "expo-router";
export default function HomeScreen() { export default function HomeScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { agents } = useSession();
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const aggregatedAgents = useAggregatedAgents(agents); const aggregatedAgents = useAggregatedAgents();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [createModalMounted, setCreateModalMounted] = useState(false); const [createModalMounted, setCreateModalMounted] = useState(false);

View File

@@ -1,21 +1,42 @@
import { View } from "react-native"; 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { OrchestratorMessagesView } from "@/components/orchestrator-messages-view"; 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 { ScrollView } from "react-native";
import type { Artifact } from "@/components/artifact-drawer"; import type { Artifact } from "@/components/artifact-drawer";
import type { MessageEntry } from "@/contexts/session-context";
export default function OrchestratorScreen() { export default function OrchestratorScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { messages, currentAssistantMessage } = useSession(); const sessionDirectory = useSessionDirectory();
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const [currentArtifact, setCurrentArtifact] = useState<Artifact | null>(null); const [currentArtifact, setCurrentArtifact] = useState<Artifact | null>(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 // Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -42,8 +63,8 @@ export default function OrchestratorScreen() {
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}> <ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<OrchestratorMessagesView <OrchestratorMessagesView
ref={scrollViewRef} ref={scrollViewRef}
messages={messages} messages={aggregatedMessages}
currentAssistantMessage={currentAssistantMessage} currentAssistantMessage={streamingAssistantMessage}
onArtifactClick={handleArtifactClick} onArtifactClick={handleArtifactClick}
/> />
</ReanimatedAnimated.View> </ReanimatedAnimated.View>

View File

@@ -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 type { MutableRefObject } from "react";
import { import {
View, View,
@@ -59,6 +59,31 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[4], 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: { label: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -324,12 +349,19 @@ type DaemonTestState = {
export default function SettingsScreen() { export default function SettingsScreen() {
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry(); const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
const { activeDaemon, activeDaemonId, setActiveDaemonId, connectionStates, updateConnectionStatus } = useDaemonConnections(); const { connectionStates, updateConnectionStatus } = useDaemonConnections();
const activeDaemonSession = useSessionForServer(activeDaemonId ?? null); const [selectedDaemonId, setSelectedDaemonId] = useState<string | null>(null);
const activeDaemonConnection = activeDaemonId ? connectionStates.get(activeDaemonId) : null; const selectedDaemon = useMemo(() => {
const activeDaemonStatusLabel = activeDaemonConnection ? formatConnectionStatus(activeDaemonConnection.status) : null; 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 [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn); const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme); const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
@@ -346,18 +378,28 @@ export default function SettingsScreen() {
const [isSavingDaemon, setIsSavingDaemon] = useState(false); const [isSavingDaemon, setIsSavingDaemon] = useState(false);
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map()); const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
const isLoading = settingsLoading || daemonLoading; const isLoading = settingsLoading || daemonLoading;
const baselineServerUrl = activeDaemon?.wsUrl ?? ""; const baselineServerUrl = selectedDaemon?.wsUrl ?? "";
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const isServerConfigLocked = Boolean(activeDaemon && !activeDaemonSession); const isServerConfigLocked = Boolean(selectedDaemon && !selectedDaemonSession);
const serverDescriptionText = activeDaemon const serverDescriptionText = selectedDaemon
? `${activeDaemon.label}${activeDaemonStatusLabel ? ` - ${activeDaemonStatusLabel}` : ""}${ ? `${selectedDaemon.label}${selectedDaemonStatusLabel ? ` - ${selectedDaemonStatusLabel}` : ""}${
isServerConfigLocked ? " - Session unavailable" : "" isServerConfigLocked ? " - Session unavailable" : ""
}` }`
: "Add a host to configure its server URL."; : "Add a host to configure its server URL.";
const serverHelperText = isServerConfigLocked 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://)"; : "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(() => { useEffect(() => {
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
@@ -472,10 +514,10 @@ export default function SettingsScreen() {
}; };
if (daemonForm.id) { if (daemonForm.id) {
await updateDaemon(daemonForm.id, payload); await updateDaemon(daemonForm.id, payload);
setActiveDaemonId(daemonForm.id, { source: "settings_save_daemon" }); setSelectedDaemonId(daemonForm.id);
} else { } else {
const created = await addDaemon(payload); const created = await addDaemon(payload);
setActiveDaemonId(created.id, { source: "settings_save_daemon" }); setSelectedDaemonId(created.id);
} }
handleCloseDaemonForm(); handleCloseDaemonForm();
} catch (error) { } catch (error) {
@@ -484,7 +526,7 @@ export default function SettingsScreen() {
} finally { } finally {
setIsSavingDaemon(false); setIsSavingDaemon(false);
} }
}, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm, setActiveDaemonId]); }, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm]);
const handleRemoveDaemon = useCallback( const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => { (profile: DaemonProfile) => {
@@ -549,8 +591,8 @@ export default function SettingsScreen() {
}, [settings]); }, [settings]);
useEffect(() => { useEffect(() => {
setServerUrl(activeDaemon?.wsUrl ?? ""); setServerUrl(selectedDaemon?.wsUrl ?? "");
}, [activeDaemon?.wsUrl]); }, [selectedDaemon?.wsUrl]);
// Track changes // Track changes
useEffect(() => { useEffect(() => {
@@ -600,17 +642,19 @@ export default function SettingsScreen() {
theme, theme,
}); });
if (activeDaemon) { if (selectedDaemon) {
await updateDaemon(activeDaemon.id, { await updateDaemon(selectedDaemon.id, {
wsUrl: trimmedUrl, wsUrl: trimmedUrl,
label: activeDaemon.label || deriveDaemonLabel(trimmedUrl), label: selectedDaemon.label || deriveDaemonLabel(trimmedUrl),
}); });
setSelectedDaemonId(selectedDaemon.id);
} else { } else {
await addDaemon({ const created = await addDaemon({
label: deriveDaemonLabel(trimmedUrl), label: deriveDaemonLabel(trimmedUrl),
wsUrl: trimmedUrl, wsUrl: trimmedUrl,
autoConnect: true, autoConnect: true,
}); });
setSelectedDaemonId(created.id);
} }
Alert.alert( Alert.alert(
@@ -666,7 +710,7 @@ export default function SettingsScreen() {
if (isServerConfigLocked) { if (isServerConfigLocked) {
Alert.alert( Alert.alert(
"Session unavailable", "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; return;
} }
@@ -720,6 +764,27 @@ export default function SettingsScreen() {
{/* Server Configuration */} {/* Server Configuration */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Server Configuration</Text> <Text style={styles.sectionTitle}>Server Configuration</Text>
{daemons.length > 0 ? (
<View style={styles.hostSelectorRow}>
{daemons.map((daemon) => {
const isSelected = daemon.id === selectedDaemonId;
return (
<Pressable
key={daemon.id}
style={[styles.hostSelectorChip, isSelected && styles.hostSelectorChipSelected]}
onPress={() => setSelectedDaemonId(daemon.id)}
>
<Text
style={[styles.hostSelectorChipText, isSelected && styles.hostSelectorChipTextSelected]}
numberOfLines={1}
>
{daemon.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
<Text style={styles.helperText}>{serverDescriptionText}</Text> <Text style={styles.helperText}>{serverDescriptionText}</Text>
<Text style={styles.label}>WebSocket URL</Text> <Text style={styles.label}>WebSocket URL</Text>

View File

@@ -10,7 +10,7 @@ import {
Text, Text,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native"; import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native";
import Animated, { import Animated, {
@@ -31,6 +31,9 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
import { useDaemonSession } from "@/hooks/use-daemon-session"; 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 = { type QueuedMessage = {
id: string; id: string;
@@ -71,17 +74,35 @@ type TextAreaHandle = {
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
ws, const inertWebSocket = useMemo<UseWebSocketReturn>(
sendAgentMessage, () => ({
sendAgentAudio, isConnected: false,
agents, isConnecting: false,
cancelAgentRun, conversationId: null,
getDraftInput, lastError: null,
saveDraftInput, send: () => {},
queuedMessages: queuedMessagesByAgent, on: () => () => {},
setQueuedMessages: setQueuedMessagesByAgent, sendPing: () => {},
} = useDaemonSession(serverId); sendUserMessage: () => {},
}),
[]
);
const noopSendAgentMessage = useCallback<SessionContextValue["sendAgentMessage"]>(async () => {}, []);
const noopSendAgentAudio = useCallback<SessionContextValue["sendAgentAudio"]>(async () => {}, []);
const noopCancelAgentRun = useCallback<SessionContextValue["cancelAgentRun"]>(() => {}, []);
const noopGetDraftInput = useCallback<SessionContextValue["getDraftInput"]>(() => undefined, []);
const noopSaveDraftInput = useCallback<SessionContextValue["saveDraftInput"]>(() => {}, []);
const noopSetQueuedMessages = useCallback<SessionContextValue["setQueuedMessages"]>(() => {}, []);
const ws = session?.ws ?? inertWebSocket;
const sendAgentMessage = session?.sendAgentMessage ?? noopSendAgentMessage;
const sendAgentAudio = session?.sendAgentAudio ?? noopSendAgentAudio;
const agents = session?.agents ?? new Map<string, Agent>();
const cancelAgentRun = session?.cancelAgentRun ?? noopCancelAgentRun;
const getDraftInput = session?.getDraftInput ?? noopGetDraftInput;
const saveDraftInput = session?.saveDraftInput ?? noopSaveDraftInput;
const queuedMessagesByAgent = session?.queuedMessages ?? new Map<string, QueuedMessage[]>();
const setQueuedMessagesByAgent = session?.setQueuedMessages ?? noopSetQueuedMessages;
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
const [userInput, setUserInput] = useState(""); const [userInput, setUserInput] = useState("");
@@ -268,7 +289,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
} }
useEffect(() => { useEffect(() => {
const unsubscribe = ws.on("transcription_result", (message) => { const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => {
if (message.type !== "transcription_result") { if (message.type !== "transcription_result") {
return; return;
} }
@@ -626,10 +647,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
useEffect(() => { useEffect(() => {
const existing = getDraftInput(agentId); const existing = getDraftInput(agentId);
const isSameText = existing?.text === userInput; const isSameText = existing?.text === userInput;
const existingImages = existing?.images ?? []; const existingImages: Array<{ uri: string; mimeType?: string }> = existing?.images ?? [];
const isSameImages = const isSameImages =
existingImages.length === selectedImages.length && 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) { if (isSameText && isSameImages) {
return; return;
@@ -658,10 +681,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
if (isRealtimeMode) { if (isRealtimeMode) {
await stopRealtime(); await stopRealtime();
} else { } else {
if (!ws.isConnected) { if (!ws.isConnected || !serverId) {
return; return;
} }
await startRealtime(); await startRealtime(serverId);
} }
} catch (error) { } catch (error) {
console.error("[AgentInput] Failed to toggle realtime mode:", error); console.error("[AgentInput] Failed to toggle realtime mode:", error);

View File

@@ -1,9 +1,10 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native"; import { ChevronDown } from "lucide-react-native";
import { useState } from "react"; import { useState, useCallback } from "react";
import { ModeSelectorModal } from "./mode-selector-modal"; import { ModeSelectorModal } from "./mode-selector-modal";
import { useDaemonSession } from "@/hooks/use-daemon-session"; import { useDaemonSession } from "@/hooks/use-daemon-session";
import type { SessionContextValue, Agent } from "@/contexts/session-context";
interface AgentStatusBarProps { interface AgentStatusBarProps {
agentId: string; agentId: string;
@@ -12,7 +13,10 @@ interface AgentStatusBarProps {
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { agents, setAgentMode } = useDaemonSession(serverId); const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
const noopSetAgentMode = useCallback<SessionContextValue["setAgentMode"]>(() => {}, []);
const agents = session?.agents ?? new Map<string, Agent>();
const setAgentMode = session?.setAgentMode ?? noopSetAgentMode;
const [showModeSelector, setShowModeSelector] = useState(false); const [showModeSelector, setShowModeSelector] = useState(false);
const agent = agents.get(agentId); const agent = agents.get(agentId);

View File

@@ -71,9 +71,24 @@ export function AgentStreamView({
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false); const isUserScrollingRef = useRef(false);
const router = useRouter(); const router = useRouter();
const session = useDaemonSession(serverId); const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
const { requestDirectoryListing, requestFilePreview, ws } = session; const inertWebSocket = useMemo<UseWebSocketReturn>(
const resolvedServerId = serverId ?? session.serverId; () => ({
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 // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";

View File

@@ -62,7 +62,7 @@ import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages"; import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
import { trackAnalyticsEvent } from "@/utils/analytics"; 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 { useSessionDirectory, useSessionForServer } from "@/hooks/use-session-directory";
import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import type { UseWebSocketReturn } from "@/hooks/use-websocket";
@@ -224,10 +224,8 @@ function AgentFlowModal({
return daemonEntries[0]?.daemon.id ?? null; return daemonEntries[0]?.daemon.id ?? null;
}, [serverId, daemonEntries]); }, [serverId, daemonEntries]);
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId); const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
const activeSession = useSession();
const selectedSession = useSessionForServer(selectedServerId); const selectedSession = useSessionForServer(selectedServerId);
const session = const session = selectedSession ?? null;
selectedServerId === null ? activeSession : selectedSession ?? null;
const sessionDirectory = useSessionDirectory(); const sessionDirectory = useSessionDirectory();
useEffect(() => { useEffect(() => {

View File

@@ -1,11 +1,11 @@
import { useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { View, Pressable, Text, Platform } from "react-native"; import { View, Pressable, Text, Platform, Modal, Alert } from "react-native";
import { usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines, Users, Plus, Download } from "lucide-react-native"; import { AudioLines, Users, Plus, Download } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context"; 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 { useFooterControls, FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
import { RealtimeControls } from "./realtime-controls"; import { RealtimeControls } from "./realtime-controls";
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal"; import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
@@ -22,10 +22,11 @@ export function GlobalFooter() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { isRealtimeMode, startRealtime } = useRealtime(); const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession(); const { connectionStates } = useDaemonConnections();
const { controls } = useFooterControls(); const { controls } = useFooterControls();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false);
// Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes // Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -53,6 +54,40 @@ export function GlobalFooter() {
[bottomInset], [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) { if (showAgentControls) {
return ( return (
<Animated.View <Animated.View
@@ -162,12 +197,12 @@ export function GlobalFooter() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={startRealtime} onPress={handleStartRealtime}
disabled={!ws.isConnected} disabled={realtimeEligibleHosts.length === 0}
style={({ pressed }) => [ style={({ pressed }) => [
styles.footerButton, styles.footerButton,
!ws.isConnected && styles.buttonDisabled, realtimeEligibleHosts.length === 0 && styles.buttonDisabled,
pressed && !ws.isConnected && styles.buttonPressed, pressed && realtimeEligibleHosts.length === 0 && styles.buttonPressed,
]} ]}
> >
<View style={styles.footerIconWrapper}> <View style={styles.footerIconWrapper}>
@@ -191,6 +226,31 @@ export function GlobalFooter() {
isVisible={showImportModal} isVisible={showImportModal}
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}
/> />
<Modal
visible={showRealtimeHostPicker}
transparent
animationType="fade"
onRequestClose={handleDismissHostPicker}
>
<View style={styles.hostPickerOverlay}>
<Pressable style={styles.hostPickerBackdrop} onPress={handleDismissHostPicker} />
<View style={styles.hostPickerContainer}>
<Text style={styles.hostPickerTitle}>Choose a host</Text>
{realtimeEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
style={styles.hostPickerButton}
onPress={() => handleSelectRealtimeHost(entry.daemon.id)}
>
<Text style={styles.hostPickerButtonText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable style={styles.hostPickerCancel} onPress={handleDismissHostPicker}>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</View>
</Modal>
</> </>
); );
} }
@@ -240,4 +300,43 @@ const styles = StyleSheet.create((theme) => ({
buttonPressed: { buttonPressed: {
opacity: 0.5, 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",
},
})); }));

View File

@@ -1,23 +1,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
export function MultiDaemonSessionHost() { export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry(); const { daemons } = useDaemonRegistry();
const { activeDaemonId, sessionAccessorRoles } = useDaemonConnections();
const autoConnectStatesRef = useRef<Map<string, boolean>>(new Map()); const autoConnectStatesRef = useRef<Map<string, boolean>>(new Map());
const primaryDaemonIds = useMemo(() => {
const ids = new Set<string>();
sessionAccessorRoles.forEach((roles, daemonId) => {
if (roles.has("primary")) {
ids.add(daemonId);
}
});
return ids;
}, [sessionAccessorRoles]);
useEffect(() => { useEffect(() => {
const trackedStates = autoConnectStatesRef.current; const trackedStates = autoConnectStatesRef.current;
const activeIds = new Set<string>(); const activeIds = new Set<string>();
@@ -44,38 +32,18 @@ export function MultiDaemonSessionHost() {
} }
}, [daemons]); }, [daemons]);
const backgroundDaemons = useMemo<DaemonProfile[]>(() => { const connectedDaemons = useMemo<DaemonProfile[]>(() => {
const shouldConnect = new Map<string, DaemonProfile>(); return daemons.filter((daemon) => daemon.autoConnect !== false);
}, [daemons]);
for (const daemon of daemons) { if (connectedDaemons.length === 0) {
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) {
return null; return null;
} }
return ( return (
<> <>
{backgroundDaemons.map((daemon) => ( {connectedDaemons.map((daemon) => (
<SessionProvider <SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
key={daemon.id}
serverUrl={daemon.wsUrl}
serverId={daemon.id}
role="background"
>
{null} {null}
</SessionProvider> </SessionProvider>
))} ))}

View File

@@ -3,7 +3,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native"; import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter"; import { VolumeMeter } from "./volume-meter";
import { useRealtime } from "@/contexts/realtime-context"; import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
const CONTROL_BUTTON_SIZE = 48; const CONTROL_BUTTON_SIZE = 48;
@@ -11,7 +10,6 @@ const VERTICAL_PADDING = (FOOTER_HEIGHT - CONTROL_BUTTON_SIZE) / 2;
export function RealtimeControls() { export function RealtimeControls() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { audioPlayer } = useSession();
const { const {
volume, volume,
isMuted, isMuted,
@@ -23,7 +21,6 @@ export function RealtimeControls() {
} = useRealtime(); } = useRealtime();
function handleStop() { function handleStop() {
audioPlayer.stop();
stopRealtime(); stopRealtime();
} }

View File

@@ -1,18 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } 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 { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
import { trackAnalyticsEvent } from "@/utils/analytics";
import type { SessionContextValue } from "./session-context"; import type { SessionContextValue } from "./session-context";
export interface SessionDirectoryEntry { export interface SessionDirectoryEntry {
getSnapshot: () => SessionContextValue | null; getSnapshot: () => SessionContextValue | null;
subscribe: (listener: () => void) => () => void; subscribe: (listener: () => void) => () => void;
} }
type SessionAccessorRegistry = Map<string, SessionDirectoryEntry>;
export type SessionAccessorRole = "primary" | "background";
type SessionAccessorRegistry = Map<string, Map<SessionAccessorRole, SessionDirectoryEntry>>;
export type ConnectionState = export type ConnectionState =
| { status: "idle"; lastError: null; lastOnlineAt: string | null } | { status: "idle"; lastError: null; lastOnlineAt: string | null }
@@ -35,33 +30,18 @@ export type DaemonConnectionRecord = {
} & ConnectionState; } & ConnectionState;
interface DaemonConnectionsContextValue { interface DaemonConnectionsContextValue {
activeDaemonId: string | null;
activeDaemon: DaemonProfile | null;
connectionStates: Map<string, DaemonConnectionRecord>; connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean; isLoading: boolean;
setActiveDaemonId: (daemonId: string, options?: SetActiveDaemonOptions) => void;
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void; updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>; sessionAccessors: Map<string, SessionDirectoryEntry>;
sessionAccessorRoles: Map<string, Set<SessionAccessorRole>>; registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
registerSessionAccessor: ( unregisterSessionAccessor: (daemonId: string) => void;
daemonId: string,
entry: SessionDirectoryEntry,
role?: SessionAccessorRole
) => void;
unregisterSessionAccessor: (daemonId: string, role?: SessionAccessorRole) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void; subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void; notifySessionDirectoryChange: () => void;
} }
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null); const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(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 { function createDefaultConnectionState(): ConnectionState {
return { return {
status: "idle", status: "idle",
@@ -132,25 +112,10 @@ export function useDaemonConnections(): DaemonConnectionsContextValue {
export function DaemonConnectionsProvider({ children }: { children: ReactNode }) { export function DaemonConnectionsProvider({ children }: { children: ReactNode }) {
const { daemons, isLoading: registryLoading } = useDaemonRegistry(); const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [activeDaemonId, setActiveDaemonIdState] = useState<string | null>(null);
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map()); const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(new Map()); const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(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()); 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 // Ensure connection states stay in sync with registry entries
useEffect(() => { useEffect(() => {
setConnectionStates((prev) => { setConnectionStates((prev) => {
@@ -172,77 +137,18 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
let changed = false; let changed = false;
const next: SessionAccessorRegistry = new Map(); const next: SessionAccessorRegistry = new Map();
for (const [daemonId, roleMap] of prev.entries()) { for (const [daemonId, entry] of prev.entries()) {
if (!validDaemonIds.has(daemonId)) { if (!validDaemonIds.has(daemonId)) {
changed = true; changed = true;
continue; continue;
} }
next.set(daemonId, roleMap); next.set(daemonId, entry);
} }
return changed ? next : prev; return changed ? next : prev;
}); });
}, [daemons]); }, [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, 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( const updateConnectionStatus = useCallback(
(daemonId: string, update: ConnectionStateUpdate) => { (daemonId: string, update: ConnectionStateUpdate) => {
@@ -269,41 +175,28 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
[] []
); );
const registerSessionAccessor = useCallback( const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
(daemonId: string, entry: SessionDirectoryEntry, role: SessionAccessorRole = "primary") => { setSessionAccessorRegistry((prev) => {
setSessionAccessorRegistry((prev) => { const next = new Map(prev);
const next = new Map(prev); const hasExisting = next.has(daemonId);
const existing = new Map(next.get(daemonId) ?? []); next.set(daemonId, entry);
existing.set(role, entry); if (hasExisting) {
next.set(daemonId, existing); console.warn(`[DaemonConnections] Duplicate session accessor detected for "${daemonId}". Overwriting.`);
return next; }
}); return next;
}, });
[] }, []);
);
const unregisterSessionAccessor = useCallback( const unregisterSessionAccessor = useCallback((daemonId: string) => {
(daemonId: string, role: SessionAccessorRole = "primary") => { setSessionAccessorRegistry((prev) => {
setSessionAccessorRegistry((prev) => { if (!prev.has(daemonId)) {
const existing = prev.get(daemonId); return prev;
if (!existing) { }
return prev; const next = new Map(prev);
} next.delete(daemonId);
return next;
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 subscribeToSessionDirectory = useCallback((listener: () => void) => { const subscribeToSessionDirectory = useCallback((listener: () => void) => {
sessionDirectoryListenersRef.current.add(listener); sessionDirectoryListenersRef.current.add(listener);
@@ -323,43 +216,13 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
} }
}, []); }, []);
const { sessionAccessors, sessionAccessorRoles } = useMemo(() => { const sessionAccessors = useMemo(() => new Map(sessionAccessorRegistry), [sessionAccessorRegistry]);
const flattened = new Map<string, SessionDirectoryEntry>();
const roleSets = new Map<string, Set<SessionAccessorRole>>();
sessionAccessorRegistry.forEach((roleMap, daemonId) => {
if (roleMap.size === 0) {
return;
}
const roles = new Set<SessionAccessorRole>();
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 value: DaemonConnectionsContextValue = { const value: DaemonConnectionsContextValue = {
activeDaemonId,
activeDaemon,
connectionStates, connectionStates,
isLoading: registryLoading || activeDaemonPreference.isPending, isLoading: registryLoading,
setActiveDaemonId,
updateConnectionStatus, updateConnectionStatus,
sessionAccessors, sessionAccessors,
sessionAccessorRoles,
registerSessionAccessor, registerSessionAccessor,
unregisterSessionAccessor, unregisterSessionAccessor,
subscribeToSessionDirectory, subscribeToSessionDirectory,
@@ -372,13 +235,3 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
</DaemonConnectionsContext.Provider> </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;
}
}

View File

@@ -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 { 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 { generateMessageId } from "@/types/stream";
import type { WSInboundMessage } from "@server/server/messages"; import type { WSInboundMessage } from "@server/server/messages";
@@ -11,9 +12,10 @@ interface RealtimeContextValue {
isDetecting: boolean; isDetecting: boolean;
isSpeaking: boolean; isSpeaking: boolean;
segmentDuration: number; segmentDuration: number;
startRealtime: () => Promise<void>; startRealtime: (serverId: string) => Promise<void>;
stopRealtime: () => Promise<void>; stopRealtime: () => Promise<void>;
toggleMute: () => void; toggleMute: () => void;
activeServerId: string | null;
} }
const RealtimeContext = createContext<RealtimeContextValue | null>(null); const RealtimeContext = createContext<RealtimeContextValue | null>(null);
@@ -31,13 +33,9 @@ interface RealtimeProviderProps {
} }
export function RealtimeProvider({ children }: RealtimeProviderProps) { export function RealtimeProvider({ children }: RealtimeProviderProps) {
const { const sessionDirectory = useSessionDirectory();
ws, const [activeServerId, setActiveServerId] = useState<string | null>(null);
audioPlayer, const realtimeSessionRef = useRef<SessionContextValue | null>(null);
isPlayingAudio,
setMessages,
setVoiceDetectionFlags,
} = useSession();
const [isRealtimeMode, setIsRealtimeMode] = useState(false); const [isRealtimeMode, setIsRealtimeMode] = useState(false);
const bargeInPlaybackStopRef = useRef<number | null>(null); const bargeInPlaybackStopRef = useRef<number | null>(null);
@@ -45,22 +43,29 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
onSpeechStart: () => { onSpeechStart: () => {
console.log("[Realtime] Speech detected"); console.log("[Realtime] Speech detected");
// Stop audio playback if playing // 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) { if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now(); bargeInPlaybackStopRef.current = Date.now();
} }
audioPlayer.stop(); sessionAudioPlayer.stop();
} }
// Abort any in-flight orchestrator turn before the new speech segment streams // Abort any in-flight orchestrator turn before the new speech segment streams
try { try {
const abortMessage: WSInboundMessage = { if (sessionWs) {
type: "session", const abortMessage: WSInboundMessage = {
message: { type: "session",
type: "abort_request", message: {
}, type: "abort_request",
}; },
ws.send(abortMessage); };
sessionWs.send(abortMessage);
}
console.log("[Realtime] Sent abort_request before streaming audio"); console.log("[Realtime] Sent abort_request before streaming audio");
} catch (error) { } catch (error) {
console.error("[Realtime] Failed to send abort_request:", 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) // Send audio segment to server (realtime always goes to orchestrator)
const session = realtimeSessionRef.current;
try { try {
ws.send({ session?.ws.send({
type: "session", type: "session",
message: { message: {
type: "realtime_audio_chunk", type: "realtime_audio_chunk",
@@ -94,16 +100,19 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
}, },
onError: (error) => { onError: (error) => {
console.error("[Realtime] Audio error:", error); console.error("[Realtime] Audio error:", error);
setMessages((prev) => [ const session = realtimeSessionRef.current;
...prev, if (session) {
{ session.setMessages((prev) => [
type: "activity", ...prev,
id: generateMessageId(), {
timestamp: Date.now(), type: "activity",
activityType: "error", id: generateMessageId(),
message: `Realtime audio error: ${error.message}`, timestamp: Date.now(),
}, activityType: "error",
]); message: `Realtime audio error: ${error.message}`,
},
]);
}
}, },
volumeThreshold: 0.3, volumeThreshold: 0.3,
silenceDuration: 2000, silenceDuration: 2000,
@@ -113,8 +122,22 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
// Update voice detection flags whenever they change // Update voice detection flags whenever they change
useEffect(() => { useEffect(() => {
setVoiceDetectionFlags(realtimeAudio.isDetecting, realtimeAudio.isSpeaking); const session = realtimeSessionRef.current;
}, [realtimeAudio.isDetecting, realtimeAudio.isSpeaking, setVoiceDetectionFlags]); 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(() => { useEffect(() => {
if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) { if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) {
@@ -128,47 +151,61 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
} }
}, [isPlayingAudio]); }, [isPlayingAudio]);
const startRealtime = useCallback(async () => { const startRealtime = useCallback(
try { async (serverId: string) => {
await realtimeAudio.start(); const session = sessionDirectory.get(serverId) ?? null;
setIsRealtimeMode(true); if (!session) {
console.log("[Realtime] Mode enabled"); throw new Error(`Host ${serverId} is not connected`);
}
// Notify server try {
const modeMessage: WSInboundMessage = { realtimeSessionRef.current = session;
type: "session", setActiveServerId(serverId);
message: { await realtimeAudio.start();
type: "set_realtime_mode", setIsRealtimeMode(true);
enabled: true, console.log("[Realtime] Mode enabled");
},
}; const modeMessage: WSInboundMessage = {
ws.send(modeMessage); type: "session",
} catch (error: any) { message: {
console.error("[Realtime] Failed to start:", error); type: "set_realtime_mode",
throw error; enabled: true,
} },
}, [realtimeAudio, ws]); };
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 () => { const stopRealtime = useCallback(async () => {
try { try {
const session = realtimeSessionRef.current;
session?.audioPlayer.stop();
await realtimeAudio.stop(); await realtimeAudio.stop();
setIsRealtimeMode(false); setIsRealtimeMode(false);
setActiveServerId(null);
console.log("[Realtime] Mode disabled"); console.log("[Realtime] Mode disabled");
// Notify server if (session) {
const modeMessage: WSInboundMessage = { const modeMessage: WSInboundMessage = {
type: "session", type: "session",
message: { message: {
type: "set_realtime_mode", type: "set_realtime_mode",
enabled: false, enabled: false,
}, },
}; };
ws.send(modeMessage); session.ws.send(modeMessage);
}
} catch (error: any) { } catch (error: any) {
console.error("[Realtime] Failed to stop:", error); console.error("[Realtime] Failed to stop:", error);
throw error; throw error;
} }
}, [realtimeAudio, ws]); }, [realtimeAudio]);
const value: RealtimeContextValue = { const value: RealtimeContextValue = {
isRealtimeMode, isRealtimeMode,
@@ -180,6 +217,7 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
startRealtime, startRealtime,
stopRealtime, stopRealtime,
toggleMute: realtimeAudio.toggleMute, toggleMute: realtimeAudio.toggleMute,
activeServerId,
}; };
return ( return (

View File

@@ -29,7 +29,7 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { ScrollView } from "react-native"; import { ScrollView } from "react-native";
import * as FileSystem from 'expo-file-system'; 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 derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
const fallbackId = const fallbackId =
@@ -388,11 +388,9 @@ interface SessionProviderProps {
children: ReactNode; children: ReactNode;
serverUrl: string; serverUrl: string;
serverId: string; serverId: string;
role?: SessionAccessorRole;
} }
export function SessionProvider({ children, serverUrl, serverId, role }: SessionProviderProps) { export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
const sessionRole = role ?? "primary";
const ws = useWebSocket(serverUrl); const ws = useWebSocket(serverUrl);
const wsIsConnected = ws.isConnected; const wsIsConnected = ws.isConnected;
const { const {
@@ -1796,11 +1794,11 @@ export function SessionProvider({ children, serverUrl, serverId, role }: Session
); );
useEffect(() => { useEffect(() => {
registerSessionAccessor(serverId, sessionDirectoryEntry, sessionRole); registerSessionAccessor(serverId, sessionDirectoryEntry);
return () => { return () => {
unregisterSessionAccessor(serverId, sessionRole); unregisterSessionAccessor(serverId);
}; };
}, [serverId, sessionRole, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]); }, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
return ( return (
<SessionContext.Provider value={value}> <SessionContext.Provider value={value}>

View File

@@ -9,6 +9,8 @@ export interface AggregatedAgentGroup {
agents: Agent[]; agents: Agent[];
} }
const EMPTY_AGENT_MAP: Map<string, Agent> = new Map();
const sortAgents = (agents: Agent[]): Agent[] => { const sortAgents = (agents: Agent[]): Agent[] => {
return [...agents].sort((left, right) => { return [...agents].sort((left, right) => {
const leftRunning = left.status === "running"; const leftRunning = left.status === "running";
@@ -25,9 +27,10 @@ const sortAgents = (agents: Agent[]): Agent[] => {
}); });
}; };
export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): AggregatedAgentGroup[] { export function useAggregatedAgents(fallbackAgents?: Map<string, Agent>): AggregatedAgentGroup[] {
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory(); const sessionDirectory = useSessionDirectory();
const fallback = fallbackAgents ?? EMPTY_AGENT_MAP;
return useMemo(() => { return useMemo(() => {
const groups = new Map<string, { serverLabel: string; agents: Agent[] }>(); const groups = new Map<string, { serverLabel: string; agents: Agent[] }>();
@@ -55,10 +58,10 @@ export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): Aggrega
}); });
} }
if (groups.size === 0) { if (groups.size === 0 && fallback.size > 0) {
const fallbackServerId = connectionStates.keys().next().value ?? "default"; const fallbackServerId = connectionStates.keys().next().value ?? "default";
const label = connectionStates.get(fallbackServerId)?.daemon.label ?? fallbackServerId; const label = connectionStates.get(fallbackServerId)?.daemon.label ?? fallbackServerId;
const fallbackList = Array.from(fallbackAgents.values()); const fallbackList = Array.from(fallback.values());
if (fallbackList.length > 0) { if (fallbackList.length > 0) {
groups.set(fallbackServerId, { serverLabel: label, agents: fallbackList }); groups.set(fallbackServerId, { serverLabel: label, agents: fallbackList });
} }
@@ -86,5 +89,5 @@ export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): Aggrega
}); });
return aggregatedGroups; return aggregatedGroups;
}, [sessionDirectory, fallbackAgents, connectionStates]); }, [sessionDirectory, fallback, connectionStates]);
} }

View File

@@ -1,7 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import { Alert } from "react-native"; import { Alert } from "react-native";
import type { SessionContextValue } from "@/contexts/session-context"; import type { SessionContextValue } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionDirectory } from "./use-session-directory"; import { useSessionDirectory } from "./use-session-directory";
@@ -32,45 +31,41 @@ type UseDaemonSessionOptions = {
}; };
export function useDaemonSession( export function useDaemonSession(
serverId?: string, serverId?: string | null,
options?: UseDaemonSessionOptions & { allowUnavailable?: false } options?: UseDaemonSessionOptions & { allowUnavailable?: false }
): SessionContextValue; ): SessionContextValue | null;
export function useDaemonSession( export function useDaemonSession(
serverId: string | undefined, serverId: string | null | undefined,
options: UseDaemonSessionOptions & { allowUnavailable: true } options: UseDaemonSessionOptions & { allowUnavailable: true }
): SessionContextValue | null; ): SessionContextValue | null;
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) { export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) {
const activeSession = useSession();
const sessionDirectory = useSessionDirectory(); const sessionDirectory = useSessionDirectory();
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const alertedDaemonsRef = useRef<Set<string>>(new Set()); const alertedDaemonsRef = useRef<Set<string>>(new Set());
const loggedDaemonsRef = useRef<Set<string>>(new Set()); const loggedDaemonsRef = useRef<Set<string>>(new Set());
const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {}; const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {};
const targetServerId = serverId ?? activeSession.serverId; if (!serverId) {
const isActiveSession = targetServerId === activeSession.serverId; return null;
if (isActiveSession || !targetServerId) {
return activeSession;
} }
try { try {
return getSessionForServer(targetServerId, sessionDirectory); return getSessionForServer(serverId, sessionDirectory);
} catch (error) { } catch (error) {
if (error instanceof DaemonSessionUnavailableError) { if (error instanceof DaemonSessionUnavailableError) {
const connection = connectionStates.get(targetServerId); const connection = connectionStates.get(serverId);
const label = connection?.daemon.label ?? targetServerId; const label = connection?.daemon.label ?? serverId;
const status = connection?.status ?? "unknown"; const status = connection?.status ?? "unknown";
const lastError = connection?.lastError ? `\n${connection.lastError}` : ""; 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}`; 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)) { if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) {
alertedDaemonsRef.current.add(targetServerId); alertedDaemonsRef.current.add(serverId);
Alert.alert("Host unavailable", message.trim()); Alert.alert("Host unavailable", message.trim());
} }
if (!loggedDaemonsRef.current.has(targetServerId)) { if (!loggedDaemonsRef.current.has(serverId)) {
loggedDaemonsRef.current.add(targetServerId); loggedDaemonsRef.current.add(serverId);
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`); console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
} }

View File

@@ -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. - 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. - 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`). - 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 ### 2. Remove Active/Primary/Auto-Connect Concepts
- [x] Remove the concept of "active daemon" from the UI and simplify to just "hosts". - [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`. - 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. - 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. - [x] 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. - 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. - [ ] 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. - [ ] Clean up any related state/UI that exposes these concepts to users.