Merge branch 'settings-ux-rehaul'

This commit is contained in:
Mohamed Boudra
2026-02-05 12:02:30 +07:00
17 changed files with 580 additions and 815 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -425,7 +425,6 @@ export default function RootLayout() {
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>

View File

@@ -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<number | null>(null);
const [playbackStatus, setPlaybackStatus] = useState<string>('No audio playing');
const [lastRecordedBlob, setLastRecordedBlob] = useState<Blob | null>(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 (
<ScrollView style={[styles.scrollView, styles.scrollViewDark]}>
<View style={styles.container}>
{/* Header */}
<Text style={[styles.title, styles.titleDark]}>
Audio Test
</Text>
<Text style={[styles.subtitle, styles.subtitleDark]}>
Test audio recording and playback functionality
</Text>
{/* Permission Status */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Permission Status
</Text>
<View style={[
styles.permissionCard,
permissionGranted ? styles.permissionGrantedDark : styles.permissionDeniedDark
]}>
<Text style={[
styles.permissionText,
permissionGranted ? styles.permissionTextGrantedDark : styles.permissionTextDeniedDark
]}>
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
</Text>
</View>
</View>
{/* Recording Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Recording
</Text>
{/* Recording Status */}
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text style={[styles.statusValue, styles.statusValueDark]}>
{recordingStatus === 'idle' && 'Ready to record'}
{recordingStatus === 'recording' && '🔴 Recording...'}
{recordingStatus === 'processing' && 'Processing...'}
</Text>
</View>
{/* Recording Controls */}
<View style={styles.controlsRow}>
<Pressable
onPress={handleStartRecording}
disabled={recordingStatus !== 'idle' || !permissionGranted}
style={[
styles.button,
(recordingStatus !== 'idle' || !permissionGranted)
? styles.buttonDisabledDark
: styles.buttonBlueDark
]}
>
<Text style={[
styles.buttonText,
(recordingStatus !== 'idle' || !permissionGranted) && styles.buttonTextDisabledDark
]}>
Start Recording
</Text>
</Pressable>
<Pressable
onPress={handleStopRecording}
disabled={recordingStatus !== 'recording'}
style={[
styles.button,
recordingStatus !== 'recording'
? styles.buttonDisabledDark
: styles.buttonRedDark
]}
>
<Text style={[
styles.buttonText,
recordingStatus !== 'recording' && styles.buttonTextDisabledDark
]}>
Stop Recording
</Text>
</Pressable>
</View>
{/* Last Recording Info */}
{lastRecordingSize !== null && (
<View style={[styles.infoCard, styles.infoCardDark]}>
<Text style={[styles.infoCardLabel, styles.infoCardLabelDark]}>
Last Recording
</Text>
<Text style={[styles.infoCardValue, styles.infoCardValueDark]}>
Size: {lastRecordingSize} bytes
</Text>
<Text style={[styles.infoCardSubtext, styles.infoCardSubtextDark]}>
Type: audio/m4a
</Text>
</View>
)}
</View>
{/* Playback Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Playback
</Text>
{/* Playback Status */}
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text style={[styles.statusValue, styles.statusValueDark]}>
{playbackStatus}
</Text>
</View>
{/* Playback Controls */}
<View style={styles.gap3}>
<Pressable
onPress={handlePlayLastRecording}
disabled={!lastRecordedBlob}
style={[
styles.fullWidthButton,
!lastRecordedBlob
? styles.buttonDisabledDark
: styles.buttonGreenDark
]}
>
<Text style={[
styles.buttonText,
!lastRecordedBlob && styles.buttonTextDisabledDark
]}>
Play Last Recording
</Text>
</Pressable>
<Pressable
onPress={handlePlayTestAudio}
style={[styles.fullWidthButton, styles.buttonPurpleDark]}
>
<Text style={styles.buttonText}>
Play Test Audio
</Text>
</Pressable>
<Pressable
onPress={handleStopPlayback}
style={[styles.fullWidthButton, styles.buttonOrangeDark]}
>
<Text style={styles.buttonText}>
Stop Playback
</Text>
</Pressable>
</View>
</View>
{/* Audio Settings Info */}
<View style={[styles.configCard, styles.configCardDark]}>
<Text style={[styles.configTitle, styles.configTitleDark]}>
Audio Configuration
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
Sample Rate: 16000 Hz
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
Channels: 1 (Mono)
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
Format: M4A/AAC
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
Optimized for voice/speech
</Text>
</View>
</View>
</ScrollView>
);
}

View File

@@ -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<string | null>(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() {
<Text style={styles.permissionBody}>
QR scanning isn't supported in the web build. Use "Paste link" instead.
</Text>
<Pressable style={styles.permissionButton} onPress={() => router.replace("/settings")}>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
</Pressable>
</View>
@@ -197,9 +265,28 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingNameHost.serverId;
setPendingNameHost(null);
returnToSource(serverId);
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void updateHost(serverId, { label }).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
}}
/>
) : null}
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Pressable onPress={closeToSource}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>

View File

@@ -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<void>((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<string | null>(null);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(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<string | null>(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() {
<Pressable
style={styles.addButton}
onPress={() => setIsAddHostMethodVisible(true)}
onPress={() => {
setAddConnectionTargetServerId(null);
setPendingEditReopenServerId(null);
setIsAddHostMethodVisible(true);
}}
>
<Text style={styles.addButtonText}>+ Add connection</Text>
</Pressable>
@@ -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}`);
}}
/>
<AddHostModal
visible={isDirectHostVisible}
targetServerId={addConnectionTargetServerId ?? undefined}
onClose={() => setIsDirectHostVisible(false)}
onSaved={(profile) => {
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
}
}}
/>
<PairLinkModal
visible={isPasteLinkVisible}
targetServerId={addConnectionTargetServerId ?? undefined}
onClose={() => setIsPasteLinkVisible(false)}
onSaved={(profile) => {
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
}
}}
/>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => setPendingNameHost(null)}
onSave={(label) => {
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
setPendingNameHost(null);
});
}}
/>
) : null}
<AdaptiveModalSheet
title="Edit host"
visible={Boolean(editingDaemon)}
@@ -756,28 +749,19 @@ export default function SettingsScreen() {
{editingDaemon ? (
<View style={styles.formField}>
<Text style={styles.label}>Add direct connection</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={newEndpointRaw}
onChangeText={setNewEndpointRaw}
placeholder="192.168.1.10:6767"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
style={[
styles.formButton,
styles.formButtonPrimary,
(isAddingConnection || !newEndpointRaw.trim()) && styles.hostActionDisabled,
{ alignSelf: "flex-end" },
]}
onPress={() => 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"
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
{isAddingConnection ? "Adding..." : "Add"}
Add connection
</Text>
</Pressable>
</View>
@@ -785,14 +769,14 @@ export default function SettingsScreen() {
<View style={styles.formActionsRow}>
<Pressable
style={[styles.formButton, isSavingEdit && styles.hostActionDisabled]}
style={[styles.formButton, isSavingEdit && styles.disabled]}
onPress={handleCloseEditDaemon}
disabled={isSavingEdit}
>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.formButton, styles.formButtonPrimary, isSavingEdit && styles.hostActionDisabled]}
style={[styles.formButton, styles.formButtonPrimary, isSavingEdit && styles.disabled]}
onPress={() => void handleSaveEditDaemon()}
disabled={isSavingEdit}
>
@@ -846,66 +830,11 @@ export default function SettingsScreen() {
</View>
</View>
{/* Audio Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Use speaker</Text>
<Text style={styles.audioRowDescription}>
Play audio through speaker instead of earpiece
</Text>
</View>
<Switch
value={settings.useSpeaker}
onValueChange={handleToggleUseSpeaker}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={settings.useSpeaker ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
/>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Keep screen on</Text>
<Text style={styles.audioRowDescription}>
Prevent screen from sleeping during voice sessions
</Text>
</View>
<Switch
value={settings.keepScreenOn}
onValueChange={handleToggleKeepScreenOn}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={settings.keepScreenOn ? defaultTheme.colors.palette.white : defaultTheme.colors.palette.gray[300]}
/>
</View>
</View>
</View>
{/* Developer */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Developer</Text>
<View style={styles.devCard}>
<Pressable
style={styles.devButton}
onPress={() => router.push("/audio-test")}
>
<View style={styles.devButtonContent}>
<Text style={styles.devButtonTitle}>Audio test</Text>
<Text style={styles.devButtonDescription}>
Test audio recording and playback
</Text>
</View>
</Pressable>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.footerAppInfo}>
<Text style={styles.footerText}>Voice Dev Mobile</Text>
<Text style={styles.footerVersion}>Version 1.0.0</Text>
<Text style={styles.footerText}>Paseo</Text>
<Text style={styles.footerVersion}>Version {appVersion}</Text>
</View>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to defaults</Text>
@@ -1080,9 +1009,45 @@ function DaemonCard({
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
<Text style={styles.hostLabel}>{daemon.label}</Text>
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
<View style={styles.hostHeaderRight}>
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
testID={`daemon-menu-trigger-${daemon.serverId}`}
style={({ pressed }) => [
styles.menuButton,
pressed ? styles.menuButtonPressed : null,
]}
accessibilityRole="button"
accessibilityLabel={`Host actions for ${daemon.label}`}
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID={`daemon-menu-content-${daemon.serverId}`}>
<DropdownMenuItem onSelect={() => onEdit(daemon)} testID={`daemon-menu-edit-${daemon.serverId}`}>
Edit
</DropdownMenuItem>
<DropdownMenuItem destructive onSelect={() => onRemove(daemon)} testID={`daemon-menu-remove-${daemon.serverId}`}>
Remove
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Advanced</DropdownMenuLabel>
<DropdownMenuItem
onSelect={handleRestartPress}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
testID={`daemon-menu-restart-${daemon.serverId}`}
>
Restart daemon
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
<Text style={styles.hostUrl}>
@@ -1113,26 +1078,6 @@ function DaemonCard({
{connectionsSummary ? <Text style={styles.hostConnections}>{connectionsSummary}</Text> : null}
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View>
<View style={styles.hostActionsRow}>
<Pressable
style={[styles.hostActionButton, isRestarting && styles.hostActionDisabled]}
onPress={handleRestartPress}
disabled={isRestarting}
>
{isRestarting ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Text style={styles.hostActionText}>Restart</Text>
)}
</Pressable>
<Pressable style={styles.hostActionButton} onPress={() => onEdit(daemon)}>
<Text style={styles.hostActionText}>Edit</Text>
</Pressable>
<View style={styles.hostActionSeparator} />
<Pressable style={styles.hostActionButton} onPress={() => onRemove(daemon)}>
<Text style={[styles.hostActionText, styles.hostActionDestructiveText]}>Remove</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -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<TextInput>(null);
const hostInputRef = useRef<TextInput>(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 (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label (optional)</Text>
<InputComponent
ref={labelInputRef as any}
value={label}
onChangeText={setLabel}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => hostInputRef.current?.focus()}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<InputComponent

View File

@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
const styles = StyleSheet.create((theme) => ({
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 (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
<Text style={styles.helper}>
Optional. You can rename this later in Settings.
</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
<InputComponent
value={label}
onChangeText={handleChange}
placeholder={suggested}
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</View>
<View style={styles.actions}>
<Pressable style={styles.button} onPress={onSkip} testID="name-host-skip">
<Text style={styles.buttonText}>Skip</Text>
</Pressable>
<Pressable style={[styles.button, styles.primaryButton]} onPress={handleSave} testID="name-host-save">
<Text style={[styles.buttonText, styles.primaryButtonText]}>Save</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
);
}

View File

@@ -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 (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">

View File

@@ -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<string | null>(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 (
<ScrollView
@@ -110,7 +132,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{Platform.OS !== "web" ? (
<Pressable
style={styles.actionButton}
onPress={() => router.push("/pair-scan")}
onPress={() => router.push("/pair-scan?source=onboarding")}
testID="welcome-scan-qr"
>
<QrCode size={18} color={theme.colors.foreground} />
@@ -122,20 +144,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal
visible={isDirectOpen}
onClose={() => 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);
}}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => 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 ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
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}
</ScrollView>
);
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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<AppSettings> {
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
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;
}

View File

@@ -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<typeof useAudioPlayer> | 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<SessionStore>()(
});
},
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];
},

View File

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

View File

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