mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'settings-ux-rehaul'
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
135
packages/app/src/components/name-host-modal.tsx
Normal file
135
packages/app/src/components/name-host-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user