diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 734d11027..e77784e6f 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -5,7 +5,7 @@ export default { expo: { name: "Paseo", slug: "voice-mobile", - version: "1.0.0", + version: "0.1.0", orientation: "portrait", icon: "./assets/images/icon.png", scheme: "paseo", diff --git a/packages/app/package.json b/packages/app/package.json index ca86970e8..71a7b1106 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@paseo/app", "main": "index.ts", - "version": "1.0.0", + "version": "0.1.0", "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index ea430729f..076c6d4c8 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -425,7 +425,6 @@ export default function RootLayout() { options={{ gestureEnabled: false }} /> - diff --git a/packages/app/src/app/audio-test.tsx b/packages/app/src/app/audio-test.tsx deleted file mode 100644 index 6e154ed95..000000000 --- a/packages/app/src/app/audio-test.tsx +++ /dev/null @@ -1,527 +0,0 @@ -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; -import { useState, useEffect } from 'react'; -import { StyleSheet } from 'react-native-unistyles'; -import { useAudioRecorder } from '../hooks/use-audio-recorder'; -import { useAudioPlayer } from '../hooks/use-audio-player'; -import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; - -const styles = StyleSheet.create((theme) => ({ - scrollView: { - flex: 1, - backgroundColor: theme.colors.palette.white, - }, - scrollViewDark: { - backgroundColor: theme.colors.surface0, - }, - container: { - padding: theme.spacing[6], - }, - title: { - fontSize: theme.fontSize['3xl'], - fontWeight: theme.fontWeight.bold, - color: theme.colors.palette.gray[900], - marginBottom: theme.spacing[2], - }, - titleDark: { - color: theme.colors.foreground, - }, - subtitle: { - fontSize: theme.fontSize.base, - color: theme.colors.palette.gray[600], - marginBottom: theme.spacing[8], - }, - subtitleDark: { - color: theme.colors.foregroundMuted, - }, - section: { - marginBottom: theme.spacing[8], - }, - sectionTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.palette.gray[900], - marginBottom: theme.spacing[2], - }, - sectionTitleDark: { - color: theme.colors.foreground, - }, - permissionCard: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - }, - permissionGranted: { - backgroundColor: theme.colors.palette.green[100], - }, - permissionGrantedDark: { - backgroundColor: theme.colors.palette.green[900], - }, - permissionDenied: { - backgroundColor: theme.colors.palette.red[100], - }, - permissionDeniedDark: { - backgroundColor: theme.colors.palette.red[900], - }, - permissionText: { - fontSize: theme.fontSize.base, - }, - permissionTextGranted: { - color: theme.colors.palette.green[800], - }, - permissionTextGrantedDark: { - color: theme.colors.palette.green[200], - }, - permissionTextDenied: { - color: theme.colors.palette.red[800], - }, - permissionTextDeniedDark: { - color: theme.colors.palette.red[200], - }, - statusCard: { - marginBottom: theme.spacing[4], - padding: theme.spacing[4], - backgroundColor: theme.colors.palette.gray[100], - borderRadius: theme.borderRadius.lg, - }, - statusCardDark: { - backgroundColor: theme.colors.surface2, - }, - statusLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.palette.gray[700], - marginBottom: theme.spacing[1], - }, - statusLabelDark: { - color: theme.colors.foregroundMuted, - }, - statusValue: { - fontSize: theme.fontSize.base, - color: theme.colors.palette.gray[900], - }, - statusValueDark: { - color: theme.colors.foreground, - }, - controlsRow: { - flexDirection: 'row', - gap: theme.spacing[3], - marginBottom: theme.spacing[4], - }, - button: { - flex: 1, - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - alignItems: 'center', - }, - buttonBlue: { - backgroundColor: theme.colors.palette.blue[500], - }, - buttonBlueDark: { - backgroundColor: theme.colors.palette.blue[600], - }, - buttonRed: { - backgroundColor: theme.colors.palette.red[500], - }, - buttonRedDark: { - backgroundColor: theme.colors.palette.red[600], - }, - buttonGreen: { - backgroundColor: theme.colors.palette.green[500], - }, - buttonGreenDark: { - backgroundColor: theme.colors.palette.green[600], - }, - buttonPurple: { - backgroundColor: theme.colors.palette.purple[500], - }, - buttonPurpleDark: { - backgroundColor: theme.colors.palette.purple[600], - }, - buttonOrange: { - backgroundColor: theme.colors.palette.orange[500], - }, - buttonOrangeDark: { - backgroundColor: theme.colors.palette.orange[600], - }, - buttonDisabled: { - backgroundColor: theme.colors.palette.gray[300], - }, - buttonDisabledDark: { - backgroundColor: theme.colors.surface2, - }, - buttonText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.palette.white, - }, - buttonTextDisabled: { - color: theme.colors.palette.gray[500], - }, - buttonTextDisabledDark: { - color: theme.colors.foregroundMuted, - }, - fullWidthButton: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - alignItems: 'center', - }, - infoCard: { - padding: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.palette.blue[50], - }, - infoCardDark: { - backgroundColor: theme.colors.palette.blue[950], - }, - infoCardLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.palette.blue[700], - marginBottom: theme.spacing[1], - }, - infoCardLabelDark: { - color: theme.colors.palette.blue[300], - }, - infoCardValue: { - fontSize: theme.fontSize.base, - color: theme.colors.palette.blue[900], - }, - infoCardValueDark: { - color: theme.colors.palette.blue[100], - }, - infoCardSubtext: { - fontSize: theme.fontSize.sm, - color: theme.colors.palette.blue[600], - marginTop: theme.spacing[1], - }, - infoCardSubtextDark: { - color: theme.colors.palette.blue[400], - }, - configCard: { - padding: theme.spacing[4], - backgroundColor: theme.colors.palette.gray[50], - borderRadius: theme.borderRadius.lg, - }, - configCardDark: { - backgroundColor: theme.colors.surface2, - }, - configTitle: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.palette.gray[700], - marginBottom: theme.spacing[2], - }, - configTitleDark: { - color: theme.colors.foregroundMuted, - }, - configText: { - fontSize: theme.fontSize.sm, - color: theme.colors.palette.gray[600], - marginBottom: theme.spacing[1], - }, - configTextDark: { - color: theme.colors.foregroundMuted, - }, - gap3: { - gap: theme.spacing[3], - }, -})); - -export default function AudioTestScreen() { - const [permissionGranted, setPermissionGranted] = useState(false); - const [recordingStatus, setRecordingStatus] = useState<'idle' | 'recording' | 'processing'>('idle'); - const [lastRecordingSize, setLastRecordingSize] = useState(null); - const [playbackStatus, setPlaybackStatus] = useState('No audio playing'); - const [lastRecordedBlob, setLastRecordedBlob] = useState(null); - - const audioRecorder = useAudioRecorder({ - sampleRate: 16000, - numberOfChannels: 1, - }); - - const audioPlayer = useAudioPlayer(); - - useEffect(() => { - checkAndRequestPermissions(); - }, []); - - async function checkAndRequestPermissions() { - try { - const { status } = await getRecordingPermissionsAsync(); - - if (status === 'granted') { - setPermissionGranted(true); - } else { - const { granted } = await requestRecordingPermissionsAsync(); - setPermissionGranted(granted); - - if (!granted) { - Alert.alert( - 'Permission Required', - 'Microphone permission is required to test audio recording.' - ); - } - } - } catch (error) { - console.error('[AudioTest] Permission error:', error); - Alert.alert('Error', 'Failed to check/request microphone permissions'); - } - } - - async function handleStartRecording() { - if (!permissionGranted) { - Alert.alert('Permission Required', 'Please grant microphone permission first'); - return; - } - - try { - setRecordingStatus('recording'); - setLastRecordingSize(null); - await audioRecorder.start(); - } catch (error: any) { - console.error('[AudioTest] Start recording error:', error); - Alert.alert('Recording Error', error.message); - setRecordingStatus('idle'); - } - } - - async function handleStopRecording() { - try { - setRecordingStatus('processing'); - const audioBlob = await audioRecorder.stop(); - - setLastRecordingSize(audioBlob.size); - setLastRecordedBlob(audioBlob); - setRecordingStatus('idle'); - - Alert.alert( - 'Recording Complete', - `Audio recorded successfully!\nSize: ${audioBlob.size} bytes\nType: ${audioBlob.type}` - ); - } catch (error: any) { - console.error('[AudioTest] Stop recording error:', error); - Alert.alert('Recording Error', error.message); - setRecordingStatus('idle'); - } - } - - async function handlePlayLastRecording() { - if (!lastRecordedBlob) { - Alert.alert('No Recording', 'Please record audio first'); - return; - } - - try { - setPlaybackStatus('Playing last recording...'); - const duration = await audioPlayer.play(lastRecordedBlob); - setPlaybackStatus(`Playback complete (${duration.toFixed(2)}s)`); - } catch (error: any) { - console.error('[AudioTest] Playback error:', error); - Alert.alert('Playback Error', error.message); - setPlaybackStatus('Playback failed'); - } - } - - async function handlePlayTestAudio() { - // Create a test audio blob (silent audio for testing) - // In a real scenario, this would be audio from the server - try { - setPlaybackStatus('Playing test audio...'); - - // For now, we'll show an alert since we don't have a test audio file - Alert.alert( - 'Test Audio', - 'This would play a test audio file. Try recording and playing back instead.' - ); - setPlaybackStatus('Test audio not implemented'); - } catch (error: any) { - console.error('[AudioTest] Test playback error:', error); - Alert.alert('Playback Error', error.message); - setPlaybackStatus('Test playback failed'); - } - } - - function handleStopPlayback() { - audioPlayer.stop(); - setPlaybackStatus('Playback stopped'); - } - - return ( - - - {/* Header */} - - Audio Test - - - Test audio recording and playback functionality - - - {/* Permission Status */} - - - Permission Status - - - - {permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'} - - - - - {/* Recording Section */} - - - Audio Recording - - - {/* Recording Status */} - - - Status - - - {recordingStatus === 'idle' && 'Ready to record'} - {recordingStatus === 'recording' && '🔴 Recording...'} - {recordingStatus === 'processing' && 'Processing...'} - - - - {/* Recording Controls */} - - - - Start Recording - - - - - - Stop Recording - - - - - {/* Last Recording Info */} - {lastRecordingSize !== null && ( - - - Last Recording - - - Size: {lastRecordingSize} bytes - - - Type: audio/m4a - - - )} - - - {/* Playback Section */} - - - Audio Playback - - - {/* Playback Status */} - - - Status - - - {playbackStatus} - - - - {/* Playback Controls */} - - - - Play Last Recording - - - - - - Play Test Audio - - - - - - Stop Playback - - - - - - {/* Audio Settings Info */} - - - Audio Configuration - - - • Sample Rate: 16000 Hz - - - • Channels: 1 (Mono) - - - • Format: M4A/AAC - - - • Optimized for voice/speech - - - - - ); -} diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index ea3b96ae7..e374764c6 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, Text, View } from "react-native"; -import { useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useSessionStore } from "@/stores/session-store"; +import { NameHostModal } from "@/components/name-host-modal"; +import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints"; +import { ConnectionOfferSchema } from "@server/shared/connection-offer"; const styles = StyleSheet.create((theme) => ({ container: { @@ -133,11 +137,56 @@ export default function PairScanScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { upsertDaemonFromOfferUrl } = useDaemonRegistry(); + const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>(); + const source = typeof params.source === "string" ? params.source : "settings"; + const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; + const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry(); const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); const lastScannedRef = useRef(null); + const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const pendingNameHostname = useSessionStore( + useCallback( + (state) => { + if (!pendingNameHost) return null; + return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + }, + [pendingNameHost] + ) + ); + + const returnToSource = useCallback( + (serverId: string) => { + if (source === "onboarding") { + router.replace({ pathname: "/", params: { serverId } }); + return; + } + if (source === "editHost" && targetServerId) { + router.replace({ pathname: "/settings", params: { editHost: targetServerId } }); + return; + } + // settings (default): return to previous screen + try { + router.back(); + } catch { + router.replace("/settings"); + } + }, + [router, source, targetServerId] + ); + + const closeToSource = useCallback(() => { + if (source === "editHost" && targetServerId) { + router.replace({ pathname: "/settings", params: { editHost: targetServerId } }); + return; + } + try { + router.back(); + } catch { + router.replace("/settings"); + } + }, [router, source, targetServerId]); useEffect(() => { if (Platform.OS === "web") return; @@ -147,6 +196,7 @@ export default function PairScanScreen() { const handleScan = useCallback( async (result: BarcodeScanningResult) => { + if (pendingNameHost) return; if (isPairing) return; const offerUrl = extractOfferUrlFromScan(result); if (!offerUrl) return; @@ -156,8 +206,26 @@ export default function PairScanScreen() { try { setIsPairing(true); + const idx = offerUrl.indexOf("#offer="); + const encoded = offerUrl.slice(idx + "#offer=".length).trim(); + const offerPayload = decodeOfferFragmentPayload(encoded); + const offer = ConnectionOfferSchema.parse(offerPayload); + + if (targetServerId && offer.serverId !== targetServerId) { + lastScannedRef.current = null; + Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`); + return; + } + + const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId); const profile = await upsertDaemonFromOfferUrl(offerUrl); - router.replace({ pathname: "/", params: { serverId: profile.serverId } }); + + if (isNewHost) { + setPendingNameHost({ serverId: profile.serverId, hostname: null }); + return; + } + + returnToSource(profile.serverId); } catch (error) { lastScannedRef.current = null; const message = error instanceof Error ? error.message : "Unable to pair host"; @@ -166,7 +234,7 @@ export default function PairScanScreen() { setIsPairing(false); } }, - [isPairing, router, upsertDaemonFromOfferUrl] + [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl] ); if (Platform.OS === "web") { @@ -184,7 +252,7 @@ export default function PairScanScreen() { QR scanning isn't supported in the web build. Use "Paste link" instead. - router.replace("/settings")}> + Back to Settings @@ -197,9 +265,28 @@ export default function PairScanScreen() { return ( + {pendingNameHost ? ( + { + const serverId = pendingNameHost.serverId; + setPendingNameHost(null); + returnToSource(serverId); + }} + onSave={(label) => { + const serverId = pendingNameHost.serverId; + void updateHost(serverId, { label }).finally(() => { + setPendingNameHost(null); + returnToSource(serverId); + }); + }} + /> + ) : null} Scan QR - router.back()}> + Close diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index aee1d0fd9..d243c050d 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -5,16 +5,15 @@ import { Text, ScrollView, TextInput, - Switch, Pressable, Alert, - ActivityIndicator, Platform, } from "react-native"; -import { router } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; +import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Sun, Moon, Monitor } from "lucide-react-native"; +import { Sun, Moon, Monitor, MoreVertical } from "lucide-react-native"; import { Fonts } from "@/constants/theme"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; @@ -26,9 +25,16 @@ import { useSessionStore } from "@/stores/session-store"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; +import { NameHostModal } from "@/components/name-host-modal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; -import { normalizeHostPort } from "@/utils/daemon-endpoints"; -import { probeDaemonEndpoint } from "@/utils/test-daemon-connection"; const delay = (ms: number) => new Promise((resolve) => { @@ -110,6 +116,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", }, + hostHeaderRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, hostLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.base, @@ -147,44 +158,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, }, - // Host actions - hostActionsRow: { - flexDirection: "row", - alignItems: "center", - borderTopWidth: 1, - borderTopColor: theme.colors.border, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - gap: theme.spacing[1], - }, - hostActionButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + menuButton: { + width: 36, + height: 32, borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", }, - hostActionText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, + menuButtonPressed: { + backgroundColor: theme.colors.surface3, }, - hostActionPrimary: { - backgroundColor: theme.colors.palette.blue[500], - }, - hostActionPrimaryText: { - color: theme.colors.palette.white, - }, - hostActionDestructiveText: { - color: theme.colors.palette.red[500], - }, - hostActionDisabled: { + disabled: { opacity: theme.opacity[50], }, - hostActionSeparator: { - width: 1, - height: 16, - backgroundColor: theme.colors.border, - marginHorizontal: theme.spacing[1], - }, testResultText: { fontSize: theme.fontSize.xs, }, @@ -388,6 +374,7 @@ const styles = StyleSheet.create((theme) => ({ export default function SettingsScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); + const params = useLocalSearchParams<{ editHost?: string }>(); const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { daemons, @@ -395,19 +382,30 @@ export default function SettingsScreen() { updateHost, removeHost, removeConnection, - upsertDirectConnection, } = useDaemonRegistry(); const { connectionStates } = useDaemonConnections(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); + const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState(null); + const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState(null); + const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); const [editingDaemon, setEditingDaemon] = useState(null); const [editLabel, setEditLabel] = useState(""); - const [newEndpointRaw, setNewEndpointRaw] = useState(""); - const [isAddingConnection, setIsAddingConnection] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false); const isLoading = settingsLoading || daemonLoading; const isMountedRef = useRef(true); + const lastHandledEditHostRef = useRef(null); + const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; + const pendingNameHostname = useSessionStore( + useCallback( + (state) => { + if (!pendingNameHost) return null; + return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + }, + [pendingNameHost] + ) + ); useEffect(() => { return () => { @@ -435,16 +433,42 @@ export default function SettingsScreen() { const handleEditDaemon = useCallback((profile: HostProfile) => { setEditingDaemon(profile); setEditLabel(profile.label ?? ""); - setNewEndpointRaw(""); }, []); const handleCloseEditDaemon = useCallback(() => { if (isSavingEdit) return; setEditingDaemon(null); setEditLabel(""); - setNewEndpointRaw(""); }, [isSavingEdit]); + useEffect(() => { + const editHost = typeof params.editHost === "string" ? params.editHost.trim() : ""; + if (!editHost) return; + if (lastHandledEditHostRef.current === editHost) return; + const profile = daemons.find((daemon) => daemon.serverId === editHost) ?? null; + if (!profile) return; + lastHandledEditHostRef.current = editHost; + handleEditDaemon(profile); + }, [daemons, handleEditDaemon, params.editHost]); + + useEffect(() => { + if (!pendingEditReopenServerId) return; + if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return; + const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null; + setPendingEditReopenServerId(null); + setAddConnectionTargetServerId(null); + if (profile) { + handleEditDaemon(profile); + } + }, [ + daemons, + handleEditDaemon, + isAddHostMethodVisible, + isDirectHostVisible, + isPasteLinkVisible, + pendingEditReopenServerId, + ]); + const handleSaveEditDaemon = useCallback(async () => { if (!editingDaemon) return; if (isSavingEdit) return; @@ -474,52 +498,6 @@ export default function SettingsScreen() { [removeConnection] ); - const handleAddDirectConnectionToHost = useCallback(async () => { - if (!editingDaemon) return; - if (isAddingConnection) return; - - const raw = newEndpointRaw.trim(); - if (!raw) { - Alert.alert("Host required", "Enter host:port."); - return; - } - if (raw.includes("://") || raw.includes("/")) { - Alert.alert("Invalid host", "Enter host:port only (no ws://, no /ws)."); - return; - } - - let endpoint: string; - try { - endpoint = normalizeHostPort(raw); - } catch (error) { - const message = error instanceof Error ? error.message : "Invalid host:port"; - Alert.alert("Invalid host", message); - return; - } - - try { - setIsAddingConnection(true); - const { serverId } = await probeDaemonEndpoint(endpoint, { timeoutMs: 6000 }); - if (serverId !== editingDaemon.serverId) { - Alert.alert( - "Wrong daemon", - `That endpoint belongs to ${serverId}, but this host is ${editingDaemon.serverId}.` - ); - return; - } - await upsertDirectConnection({ - serverId, - endpoint, - }); - setNewEndpointRaw(""); - } catch (error) { - const message = error instanceof Error ? error.message : "Unable to add connection"; - Alert.alert("Connection failed", message); - } finally { - setIsAddingConnection(false); - } - }, [editingDaemon, isAddingConnection, newEndpointRaw, upsertDirectConnection]); - const handleRemoveDaemon = useCallback( (profile: HostProfile) => { if (Platform.OS === "web") { @@ -560,20 +538,6 @@ export default function SettingsScreen() { [removeHost] ); - const handleToggleUseSpeaker = useCallback( - (value: boolean) => { - void updateSettings({ ...settings, useSpeaker: value }); - }, - [settings, updateSettings] - ); - - const handleToggleKeepScreenOn = useCallback( - (value: boolean) => { - void updateSettings({ ...settings, keepScreenOn: value }); - }, - [settings, updateSettings] - ); - const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { void updateSettings({ theme: newTheme }); @@ -616,7 +580,7 @@ export default function SettingsScreen() { } const restartConfirmationMessage = - "This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts."; + "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; if (isLoading) { return ( @@ -665,7 +629,11 @@ export default function SettingsScreen() { setIsAddHostMethodVisible(true)} + onPress={() => { + setAddConnectionTargetServerId(null); + setPendingEditReopenServerId(null); + setIsAddHostMethodVisible(true); + }} > + Add connection @@ -676,25 +644,50 @@ export default function SettingsScreen() { onClose={() => setIsAddHostMethodVisible(false)} onDirectConnection={() => setIsDirectHostVisible(true)} onPasteLink={() => setIsPasteLinkVisible(true)} - onScanQr={() => router.push("/pair-scan")} + onScanQr={() => { + const target = addConnectionTargetServerId; + const source = target ? "editHost" : "settings"; + const qs = target ? `?source=${source}&targetServerId=${encodeURIComponent(target)}` : `?source=${source}`; + router.push(`/pair-scan${qs}`); + }} /> setIsDirectHostVisible(false)} - onSaved={(profile) => { - router.replace({ pathname: "/", params: { serverId: profile.serverId } }); + onSaved={({ serverId, hostname, isNewHost }) => { + if (isNewHost) { + setPendingNameHost({ serverId, hostname }); + } }} /> setIsPasteLinkVisible(false)} - onSaved={(profile) => { - router.replace({ pathname: "/", params: { serverId: profile.serverId } }); + onSaved={({ serverId, hostname, isNewHost }) => { + if (isNewHost) { + setPendingNameHost({ serverId, hostname }); + } }} /> + {pendingNameHost ? ( + setPendingNameHost(null)} + onSave={(label) => { + void updateHost(pendingNameHost.serverId, { label }).finally(() => { + setPendingNameHost(null); + }); + }} + /> + ) : null} + - Add direct connection - void handleAddDirectConnectionToHost()} - disabled={isAddingConnection || !newEndpointRaw.trim()} + style={[styles.formButton, styles.formButtonPrimary, { alignSelf: "flex-start" }]} + onPress={() => { + const serverId = editingDaemon.serverId; + handleCloseEditDaemon(); + setAddConnectionTargetServerId(serverId); + setPendingEditReopenServerId(serverId); + setIsAddHostMethodVisible(true); + }} + testID="edit-host-add-connection" > - {isAddingConnection ? "Adding..." : "Add"} + Add connection @@ -785,14 +769,14 @@ export default function SettingsScreen() { Cancel void handleSaveEditDaemon()} disabled={isSavingEdit} > @@ -846,66 +830,11 @@ export default function SettingsScreen() { - {/* Audio Settings */} - - Audio - - - - - Use speaker - - Play audio through speaker instead of earpiece - - - - - - - - Keep screen on - - Prevent screen from sleeping during voice sessions - - - - - - - - {/* Developer */} - - Developer - - router.push("/audio-test")} - > - - Audio test - - Test audio recording and playback - - - - - - {/* Footer */} - Voice Dev Mobile - Version 1.0.0 + Paseo + Version {appVersion} Reset to defaults @@ -1080,9 +1009,45 @@ function DaemonCard({ {daemon.label} - - - {badgeText} + + + + {badgeText} + + + + [ + styles.menuButton, + pressed ? styles.menuButtonPressed : null, + ]} + accessibilityRole="button" + accessibilityLabel={`Host actions for ${daemon.label}`} + > + + + + onEdit(daemon)} testID={`daemon-menu-edit-${daemon.serverId}`}> + Edit + + onRemove(daemon)} testID={`daemon-menu-remove-${daemon.serverId}`}> + Remove + + + + Advanced + + Restart daemon + + + @@ -1113,26 +1078,6 @@ function DaemonCard({ {connectionsSummary ? {connectionsSummary} : null} {connectionError ? {connectionError} : null} - - - {isRestarting ? ( - - ) : ( - Restart - )} - - onEdit(daemon)}> - Edit - - - onRemove(daemon)}> - Remove - - ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 7dab9d61d..d21625837 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -146,27 +146,25 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: export interface AddHostModalProps { visible: boolean; onClose: () => void; - onSaved?: (profile: HostProfile) => void; + targetServerId?: string; + onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; } -export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) { +export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddHostModalProps) { const { theme } = useUnistyles(); - const { upsertDirectConnection } = useDaemonRegistry(); + const { daemons, upsertDirectConnection } = useDaemonRegistry(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]); - const labelInputRef = useRef(null); const hostInputRef = useRef(null); - const [label, setLabel] = useState(""); const [endpointRaw, setEndpointRaw] = useState(""); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const handleClose = useCallback(() => { if (isSaving) return; - setLabel(""); setEndpointRaw(""); setErrorMessage(""); onClose(); @@ -198,14 +196,23 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) { setIsSaving(true); setErrorMessage(""); - const { serverId } = await probeDaemonEndpoint(endpoint); + const { serverId, hostname } = await probeDaemonEndpoint(endpoint); + if (targetServerId && serverId !== targetServerId) { + const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; + setErrorMessage(message); + if (!isMobile) { + Alert.alert("Wrong daemon", message); + } + return; + } + + const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); const profile = await upsertDirectConnection({ serverId, endpoint, - label: label.trim(), }); - onSaved?.(profile); + onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); } catch (error) { const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error); @@ -223,30 +230,12 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) { } finally { setIsSaving(false); } - }, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]); + }, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]); return ( Connect to a daemon by entering host:port. - - Label (optional) - hostInputRef.current?.focus()} - /> - - Host ({ + helper: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + field: { + marginTop: theme.spacing[3], + gap: theme.spacing[2], + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + input: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[3], + marginTop: theme.spacing[4], + }, + button: { + flex: 1, + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + primaryButton: { + backgroundColor: theme.colors.palette.blue[500], + }, + buttonText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, + primaryButtonText: { + color: theme.colors.palette.white, + }, +})); + +export interface NameHostModalProps { + visible: boolean; + serverId: string; + hostname: string | null; + onSkip: () => void; + onSave: (label: string) => void; +} + +export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) { + const { theme } = useUnistyles(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]); + + const [label, setLabel] = useState(""); + const hasEditedRef = useRef(false); + + const suggested = (hostname?.trim() || serverId).trim(); + + useEffect(() => { + if (!visible) return; + setLabel(suggested); + hasEditedRef.current = false; + }, [suggested, visible]); + + useEffect(() => { + if (!visible) return; + if (hasEditedRef.current) return; + if (!hostname) return; + const trimmed = label.trim(); + if (trimmed.length === 0 || trimmed === serverId) { + setLabel(hostname.trim()); + } + }, [hostname, label, serverId, visible]); + + const handleChange = useCallback((value: string) => { + hasEditedRef.current = true; + setLabel(value); + }, []); + + const handleSave = useCallback(() => { + const trimmed = label.trim(); + if (!trimmed) { + onSkip(); + return; + } + onSave(trimmed); + }, [label, onSave, onSkip]); + + return ( + + + Optional. You can rename this later in Settings. + + + + Label + + + + + + Skip + + + Save + + + + ); +} + diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 46cb35d37..5bd15f2c8 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -4,6 +4,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { Link } from "lucide-react-native"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; +import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints"; +import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; const styles = StyleSheet.create((theme) => ({ @@ -66,12 +68,13 @@ const styles = StyleSheet.create((theme) => ({ export interface PairLinkModalProps { visible: boolean; onClose: () => void; - onSaved?: (profile: HostProfile) => void; + targetServerId?: string; + onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; } -export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) { +export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: PairLinkModalProps) { const { theme } = useUnistyles(); - const { upsertDaemonFromOfferUrl } = useDaemonRegistry(); + const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]); @@ -99,11 +102,44 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) return; } + const parsedOffer = (() => { + try { + const idx = raw.indexOf("#offer="); + const encoded = raw.slice(idx + "#offer=".length).trim(); + if (!encoded) { + throw new Error("Offer payload is empty"); + } + const payload = decodeOfferFragmentPayload(encoded); + return ConnectionOfferSchema.parse(payload); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid pairing link"; + setErrorMessage(message); + if (!isMobile) { + Alert.alert("Pairing failed", message); + } + return null; + } + })(); + + if (!parsedOffer) { + return; + } + + if (targetServerId && parsedOffer.serverId !== targetServerId) { + const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`; + setErrorMessage(message); + if (!isMobile) { + Alert.alert("Wrong daemon", message); + } + return; + } + try { setIsSaving(true); setErrorMessage(""); + const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); const profile = await upsertDaemonFromOfferUrl(raw); - onSaved?.(profile); + onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: null, isNewHost }); handleClose(); } catch (error) { const message = error instanceof Error ? error.message : "Unable to pair host"; @@ -114,7 +150,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) } finally { setIsSaving(false); } - }, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]); + }, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]); return ( diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 6e771e1c7..d1c57f4e8 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,11 +1,14 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import type { HostProfile } from "@/contexts/daemon-registry-context"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; +import { NameHostModal } from "./name-host-modal"; const styles = StyleSheet.create((theme) => ({ container: { @@ -70,8 +73,27 @@ export interface WelcomeScreenProps { export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); const router = useRouter(); + const { updateHost } = useDaemonRegistry(); const [isDirectOpen, setIsDirectOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); + const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const [pendingRedirectServerId, setPendingRedirectServerId] = useState(null); + const pendingNameHostname = useSessionStore( + useCallback( + (state) => { + if (!pendingNameHost) return null; + return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + }, + [pendingNameHost] + ) + ); + + const finishOnboarding = useCallback( + (serverId: string) => { + router.replace({ pathname: "/", params: { serverId } }); + }, + [router] + ); return ( router.push("/pair-scan")} + onPress={() => router.push("/pair-scan?source=onboarding")} testID="welcome-scan-qr" > @@ -122,20 +144,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { setIsDirectOpen(false)} - onSaved={(profile) => { + onSaved={({ profile, serverId, hostname, isNewHost }) => { onHostAdded?.(profile); - router.replace({ pathname: "/", params: { serverId: profile.serverId } }); + setPendingRedirectServerId(serverId); + if (isNewHost) { + setPendingNameHost({ serverId, hostname }); + return; + } + finishOnboarding(serverId); }} /> setIsPasteLinkOpen(false)} - onSaved={(profile) => { + onSaved={({ profile, serverId, hostname, isNewHost }) => { onHostAdded?.(profile); - router.replace({ pathname: "/", params: { serverId: profile.serverId } }); + setPendingRedirectServerId(serverId); + if (isNewHost) { + setPendingNameHost({ serverId, hostname }); + return; + } + finishOnboarding(serverId); }} /> + + {pendingNameHost && pendingRedirectServerId ? ( + { + const serverId = pendingRedirectServerId; + setPendingNameHost(null); + setPendingRedirectServerId(null); + finishOnboarding(serverId); + }} + onSave={(label) => { + const serverId = pendingRedirectServerId; + void updateHost(pendingNameHost.serverId, { label }).finally(() => { + setPendingNameHost(null); + setPendingRedirectServerId(null); + finishOnboarding(serverId); + }); + }} + /> + ) : null} ); } diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index 232073b2b..127e0b9c2 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -4,7 +4,6 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { decodeOfferFragmentPayload, - deriveLabelFromEndpoint, normalizeHostPort, } from "@/utils/daemon-endpoints"; import { @@ -163,11 +162,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { } const labelTrimmed = input.label?.trim() ?? ""; - const derivedLabel = - labelTrimmed || - (input.connection.type === "direct" - ? deriveLabelFromEndpoint(input.connection.endpoint) - : serverId); + const derivedLabel = labelTrimmed || serverId; const idx = existing.findIndex((d) => d.serverId === serverId); if (idx === -1) { @@ -252,7 +247,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { serverId: offer.serverId, relayEndpoint: offer.relay.endpoint, daemonPublicKeyB64: offer.daemonPublicKeyB64, - label: offer.serverId, }); }, [upsertRelayConnection] diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f91a5b796..595917587 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -315,6 +315,7 @@ export function SessionProvider({ const updateSessionConnection = useSessionStore( (state) => state.updateSessionConnection ); + const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -972,6 +973,17 @@ export function SessionProvider({ const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const status = message.payload.status; + if (status === "server_info") { + const payload = message.payload as any; + const rawServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : ""; + if (!rawServerId) return; + const rawHostname = typeof payload.hostname === "string" ? payload.hostname.trim() : ""; + updateSessionServerInfo(serverId, { + serverId: rawServerId, + hostname: rawHostname.length > 0 ? rawHostname : null, + }); + return; + } if (status === "agent_initialized" && "agentId" in message.payload) { console.log("[Session] status agent_initialized", { agentId: (message.payload as any).agentId, diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 38c222e92..83fa5deee 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -4,8 +4,10 @@ import type { SessionState } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { randomUUID } from "expo-crypto"; +import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; const VOICE_CONVERSATION_ID_STORAGE_KEY = "@paseo:voice-conversation-id"; +const KEEP_AWAKE_TAG = "paseo:voice"; interface VoiceContextValue { isVoiceMode: boolean; @@ -155,6 +157,9 @@ export function VoiceProvider({ children }: VoiceProviderProps) { try { realtimeSessionRef.current = session; setActiveServerId(serverId); + await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) => { + console.warn("[Voice] Failed to activate keep-awake:", error); + }); await session.audioPlayer?.warmup?.(); await realtimeAudio.start(); setIsVoiceMode(true); @@ -177,6 +182,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { } catch (error: any) { console.error("[Voice] Failed to start:", error); setActiveServerId((current) => (current === serverId ? null : current)); + await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); throw error; } }, @@ -190,6 +196,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { await realtimeAudio.stop(); setIsVoiceMode(false); setActiveServerId(null); + await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); console.log("[Voice] Mode disabled"); if (session?.client) { @@ -199,6 +206,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { } } catch (error: any) { console.error("[Voice] Failed to stop:", error); + await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); throw error; } }, [realtimeAudio]); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 79e21cbeb..bbe28fe4d 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -7,14 +7,10 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; export interface AppSettings { - useSpeaker: boolean; - keepScreenOn: boolean; theme: "dark" | "light" | "auto"; } const DEFAULT_APP_SETTINGS: AppSettings = { - useSpeaker: true, - keepScreenOn: true, theme: "dark", }; @@ -99,12 +95,6 @@ async function loadSettingsFromStorage(): Promise { function pickAppSettingsFromLegacy(legacy: Record): Partial { const result: Partial = {}; - if (typeof legacy.useSpeaker === "boolean") { - result.useSpeaker = legacy.useSpeaker; - } - if (typeof legacy.keepScreenOn === "boolean") { - result.keepScreenOn = legacy.keepScreenOn; - } if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { result.theme = legacy.theme; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index cfc5386fb..4e07859e6 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -146,6 +146,11 @@ export interface DaemonConnectionSnapshot { lastError: string | null; } +export type DaemonServerInfo = { + serverId: string; + hostname: string | null; +}; + // Per-session state export interface SessionState { serverId: string; @@ -156,6 +161,9 @@ export interface SessionState { // Connection snapshot (mutable) connection: DaemonConnectionSnapshot; + // Server metadata (from server_info handshake) + serverInfo: DaemonServerInfo | null; + // Audio player (immutable reference) audioPlayer: ReturnType | null; @@ -211,6 +219,7 @@ interface SessionStoreActions { getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient) => void; updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void; + updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state setIsPlayingAudio: (serverId: string, playing: boolean) => void; @@ -299,6 +308,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio serverId, client, connection: createDefaultConnectionSnapshot(client), + serverInfo: null, audioPlayer, hasHydratedAgents: false, isPlayingAudio: false, @@ -408,6 +418,38 @@ export const useSessionStore = create()( }); }, + updateSessionServerInfo: (serverId, info) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + + const nextHostname = info.hostname?.trim() || null; + const prevHostname = session.serverInfo?.hostname?.trim() || null; + + if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) { + return prev; + } + + logSessionStoreUpdate("updateSessionServerInfo", serverId, { + serverId: info.serverId, + hostname: nextHostname, + }); + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + serverInfo: { serverId: info.serverId, hostname: nextHostname }, + }, + }, + }; + }); + }, + getSession: (serverId) => { return get().sessions[serverId]; }, diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 1de94984c..e4ecb25df 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -41,7 +41,7 @@ export class DaemonConnectionTestError extends Error { export async function probeDaemonEndpoint( endpoint: string, options?: { timeoutMs?: number } -): Promise<{ serverId: string }> { +): Promise<{ serverId: string; hostname: string | null }> { const timeoutMs = options?.timeoutMs ?? 6000; const url = buildDaemonWebSocketUrl(endpoint); @@ -51,12 +51,12 @@ export async function probeDaemonEndpoint( }); try { - return await new Promise<{ serverId: string }>((resolve, reject) => { + return await new Promise<{ serverId: string; hostname: string | null }>((resolve, reject) => { let cleanedUp = false; let unsubscribe: (() => void) | null = null; let unsubscribeStatus: (() => void) | null = null; - let isConnected = false; let serverId: string | null = null; + let hostname: string | null = null; const cleanup = () => { if (cleanedUp) return; @@ -67,13 +67,13 @@ export async function probeDaemonEndpoint( }; const maybeFinishOk = () => { - if (!isConnected) return; if (!serverId) return; cleanup(); - resolve({ serverId }); + resolve({ serverId, hostname }); }; const finishErr = (error: Error) => { + if (cleanedUp) return; cleanup(); reject(error); }; @@ -88,11 +88,6 @@ export async function probeDaemonEndpoint( }, timeoutMs); unsubscribe = client.subscribeConnectionStatus((state) => { - if (state.status === "connected") { - isConnected = true; - maybeFinishOk(); - return; - } if (state.status === "disconnected") { const reason = normalizeNonEmptyString(state.reason); const lastError = normalizeNonEmptyString(client.lastError); @@ -103,11 +98,15 @@ export async function probeDaemonEndpoint( unsubscribeStatus = client.on("status", (message) => { if (message.type !== "status") return; - const payload = message.payload as { status?: unknown; serverId?: unknown }; + const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown }; if (payload?.status !== "server_info") return; const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : ""; if (!raw) return; serverId = raw; + hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null; + if (hostname && hostname.length === 0) { + hostname = null; + } maybeFinishOk(); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3857dfb95..f3691c1fd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2,6 +2,7 @@ import { WebSocketServer } from "ws"; import type { Server as HTTPServer } from "http"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { join } from "path"; +import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; @@ -238,6 +239,7 @@ export class VoiceAssistantWebSocketServer { payload: { status: "server_info", serverId: this.serverId, + hostname: getHostname(), }, }) );