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(),
},
})
);