mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Remove primary host plumbing
This commit is contained in:
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
plan.md
5
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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user