Files
paseo/packages/app/src/screens/settings-screen.tsx
2026-03-28 11:50:24 +08:00

1901 lines
58 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } from "react";
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Buffer } from "buffer";
import {
Sun,
Moon,
Monitor,
Globe,
Settings,
RotateCw,
Trash2,
Server,
Palette,
Keyboard,
Stethoscope,
Info,
Shield,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
// ---------------------------------------------------------------------------
// Section definitions
// ---------------------------------------------------------------------------
type SettingsSectionId =
| "hosts"
| "appearance"
| "shortcuts"
| "diagnostics"
| "about"
| "permissions"
| "daemon";
interface SettingsSectionDef {
id: SettingsSectionId;
label: string;
icon: ComponentType<{ size: number; color: string }>;
}
function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
];
if (context.isDesktop) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
return sections;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const delay = (ms: number) =>
new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
clearTimeout(timeout);
resolve();
}, ms);
});
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
}
if (connection.type === "directSocket") {
return `Local (${connection.path})`;
}
if (connection.type === "directPipe") {
return `Local (${connection.path})`;
}
return `TCP (${connection.endpoint})`;
}
function formatActiveConnectionBadge(input: {
activeConnection: { type: HostConnection["type"]; display: string } | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const { activeConnection, theme } = input;
if (!activeConnection) {
return null;
}
if (activeConnection.type === "relay") {
return {
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: "Relay",
};
}
if (activeConnection.type === "directSocket") {
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: "Local",
};
}
if (activeConnection.type === "directPipe") {
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: "Local",
};
}
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
function formatDaemonVersionBadge(version: string | null): string | null {
const daemonVersion = version?.trim();
if (!daemonVersion) {
return null;
}
if (daemonVersion.startsWith("v")) {
return daemonVersion;
}
return `v${daemonVersion}`;
}
// ---------------------------------------------------------------------------
// Section content components
// ---------------------------------------------------------------------------
interface HostsSectionProps {
daemons: HostProfile[];
settings: AppSettings;
routeServerId: string;
isDesktop: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
setPendingEditReopenServerId: (id: string | null) => void;
setIsAddHostMethodVisible: (visible: boolean) => void;
isAddHostMethodVisible: boolean;
isDirectHostVisible: boolean;
isPasteLinkVisible: boolean;
addConnectionTargetServerId: string | null;
closeAddConnectionFlow: () => void;
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
setIsPasteLinkVisible: (visible: boolean) => void;
pendingNameHost: { serverId: string; hostname: string | null } | null;
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
pendingNameHostname: string | null;
renameHost: (serverId: string, label: string) => Promise<void>;
pendingRemoveHost: HostProfile | null;
setPendingRemoveHost: (host: HostProfile | null) => void;
isRemovingHost: boolean;
setIsRemovingHost: (removing: boolean) => void;
removeHost: (serverId: string) => Promise<void>;
editingDaemonLive: HostProfile | null;
isSavingEdit: boolean;
handleCloseEditDaemon: () => void;
handleSaveEditDaemon: (label: string) => Promise<void>;
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
handleRemoveDaemon: (profile: HostProfile) => void;
handleAddConnectionFromModal: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
timeoutMs: number,
intervalMs?: number,
) => Promise<boolean>;
isMountedRef: MutableRefObject<boolean>;
}
function HostsSection(props: HostsSectionProps) {
const { theme } = useUnistyles();
return (
<>
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Hosts</Text>
{props.daemons.length === 0 ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>No hosts configured</Text>
</View>
) : (
props.daemons.map((daemon) => {
return (
<DaemonCard
key={daemon.serverId}
daemon={daemon}
onOpenSettings={props.handleEditDaemon}
/>
);
})
)}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => {
props.setAddConnectionTargetServerId(null);
props.setPendingEditReopenServerId(null);
props.setIsAddHostMethodVisible(true);
}}
>
+ Add connection
</Button>
</View>
<AddHostMethodModal
visible={props.isAddHostMethodVisible}
onClose={props.closeAddConnectionFlow}
onDirectConnection={() => {
props.setIsAddHostMethodVisible(false);
props.setIsDirectHostVisible(true);
}}
onPasteLink={() => {
props.setIsAddHostMethodVisible(false);
props.setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = props.addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = props.routeServerId || targetServerId || undefined;
props.closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
});
}}
/>
<AddHostModal
visible={props.isDirectHostVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
<PairLinkModal
visible={props.isPasteLinkVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
{props.pendingNameHost ? (
<NameHostModal
visible
serverId={props.pendingNameHost.serverId}
hostname={props.pendingNameHostname}
onSkip={() => props.setPendingNameHost(null)}
onSave={(label) => {
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
props.setPendingNameHost(null);
});
}}
/>
) : null}
{props.pendingRemoveHost ? (
<AdaptiveModalSheet
title="Remove host"
visible
onClose={() => {
if (props.isRemovingHost) return;
props.setPendingRemoveHost(null);
}}
testID="remove-host-confirm-modal"
>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>
Remove {props.pendingRemoveHost.label}? This will delete its saved connections.
</Text>
<View style={[styles.formActionsRow, { marginTop: theme.spacing[4] }]}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => props.setPendingRemoveHost(null)}
disabled={props.isRemovingHost}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
const serverId = props.pendingRemoveHost!.serverId;
props.setIsRemovingHost(true);
void props
.removeHost(serverId)
.then(() => props.setPendingRemoveHost(null))
.catch((error) => {
console.error("[Settings] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
})
.finally(() => props.setIsRemovingHost(false));
}}
disabled={props.isRemovingHost}
testID="remove-host-confirm"
>
Remove
</Button>
</View>
</AdaptiveModalSheet>
) : null}
<HostDetailModal
visible={Boolean(props.editingDaemonLive)}
host={props.editingDaemonLive}
isSaving={props.isSavingEdit}
onClose={props.handleCloseEditDaemon}
onSave={(label) => void props.handleSaveEditDaemon(label)}
onRemoveConnection={props.handleRemoveConnection}
onRemoveHost={props.handleRemoveDaemon}
onAddConnection={props.handleAddConnectionFromModal}
restartConfirmationMessage={props.restartConfirmationMessage}
waitForCondition={props.waitForCondition}
isScreenMountedRef={props.isMountedRef}
/>
</>
);
}
interface AppearanceSectionProps {
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
}
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Theme</Text>
</View>
<SegmentedControl
size="sm"
hideLabels={Platform.OS !== "web"}
value={settings.theme}
onValueChange={handleThemeChange}
options={[
{
value: "light",
label: "Light",
icon: ({ color, size }) => <Sun size={size} color={color} />,
},
{
value: "dark",
label: "Dark",
icon: ({ color, size }) => <Moon size={size} color={color} />,
},
{
value: "auto",
label: "System",
icon: ({ color, size }) => <Monitor size={size} color={color} />,
},
]}
/>
</View>
</View>
</View>
);
}
interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
playbackTestResult: string | null;
handlePlaybackTest: () => Promise<void>;
}
function DiagnosticsSection({
voiceAudioEngine,
isPlaybackTestRunning,
playbackTestResult,
handlePlaybackTest,
}: DiagnosticsSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Test audio</Text>
{playbackTestResult ? (
<Text style={styles.aboutHintText}>{playbackTestResult}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={() => void handlePlaybackTest()}
disabled={!voiceAudioEngine || isPlaybackTestRunning}
>
{isPlaybackTestRunning ? "Playing..." : "Play test"}
</Button>
</View>
</View>
</View>
);
}
interface AboutSectionProps {
appVersionText: string;
isDesktop: boolean;
}
function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>About</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Version</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
{isDesktop ? <DesktopAppUpdateRow /> : null}
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Section content switcher
// ---------------------------------------------------------------------------
interface SettingsSectionContentProps {
sectionId: SettingsSectionId;
hostsProps: HostsSectionProps;
appearanceProps: AppearanceSectionProps;
diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps;
appVersion: string | null;
isLocalDaemon: boolean;
isDesktop: boolean;
}
function SettingsSectionContent({
sectionId,
hostsProps,
appearanceProps,
diagnosticsProps,
aboutProps,
appVersion,
isLocalDaemon,
isDesktop,
}: SettingsSectionContentProps) {
switch (sectionId) {
case "hosts":
return <HostsSection {...hostsProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "shortcuts":
return <KeyboardShortcutsSection />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
case "about":
return <AboutSection {...aboutProps} />;
case "permissions":
return isDesktop ? <DesktopPermissionsSection /> : null;
case "daemon":
return isDesktop ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
) : null;
}
}
// ---------------------------------------------------------------------------
// Layouts
// ---------------------------------------------------------------------------
interface SettingsLayoutProps {
sections: SettingsSectionDef[];
sectionContentProps: Omit<SettingsSectionContentProps, "sectionId">;
}
function SettingsMobileLayout({ sections, sectionContentProps }: SettingsLayoutProps) {
const insets = useSafeAreaInsets();
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View style={styles.content}>
{sections.map((section) => (
<SettingsSectionContent
key={section.id}
sectionId={section.id}
{...sectionContentProps}
/>
))}
</View>
</ScrollView>
);
}
function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayoutProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [selectedSectionId, setSelectedSectionId] = useState<SettingsSectionId>("hosts");
return (
<View style={desktopStyles.row}>
<View style={desktopStyles.sidebar}>
{sections.map((section) => {
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
return (
<Pressable
key={section.id}
style={[
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
]}
numberOfLines={1}
>
{section.label}
</Text>
</Pressable>
);
})}
</View>
<ScrollView
style={desktopStyles.contentPane}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View style={styles.content}>
<SettingsSectionContent
sectionId={selectedSectionId}
{...sectionContentProps}
/>
</View>
</ScrollView>
</View>
);
}
// ---------------------------------------------------------------------------
// Desktop app update row (unchanged)
// ---------------------------------------------------------------------------
function DesktopAppUpdateRow() {
const {
isDesktop,
statusText,
availableUpdate,
errorMessage,
isChecking,
isInstalling,
checkForUpdates,
installUpdate,
} = useDesktopAppUpdater();
useFocusEffect(
useCallback(() => {
if (!isDesktop) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktop]),
);
const handleCheckForUpdates = useCallback(() => {
if (!isDesktop) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktop]);
const handleInstallUpdate = useCallback(() => {
if (!isDesktop) {
return;
}
void confirmDialog({
title: "Install desktop update",
message: "This updates Paseo on this computer.",
confirmLabel: "Install update",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
void installUpdate();
})
.catch((error) => {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktop]);
if (!isDesktop) {
return null;
}
return (
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>App updates</Text>
<Text style={styles.aboutHintText}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.aboutHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Main screen
// ---------------------------------------------------------------------------
export default function SettingsScreen() {
const { theme } = useUnistyles();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const { daemons, renameHost, removeHost, removeConnection } = {
daemons: useHosts(),
...useHostMutations(),
};
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 [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
: null;
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
// Keep the edit modal bound to live registry state.
useEffect(() => {
if (!editingServerId) return;
if (editingDaemonLive) return;
setEditingDaemon(null);
}, [editingDaemonLive, editingServerId]);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) {
return false;
}
if (predicate()) {
return true;
}
await delay(intervalMs);
}
return predicate();
},
[],
);
const handleEditDaemon = useCallback((profile: HostProfile) => {
setEditingDaemon(profile);
}, []);
const handleCloseEditDaemon = useCallback(() => {
if (isSavingEdit) return;
setEditingDaemon(null);
}, [isSavingEdit]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
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 (nextLabelRaw: string) => {
if (!editingServerId) return;
if (isSavingEdit) return;
const nextLabel = nextLabelRaw.trim();
if (!nextLabel) {
Alert.alert("Label required", "Enter a label for this host.");
return;
}
try {
setIsSavingEdit(true);
await renameHost(editingServerId, nextLabel);
handleCloseEditDaemon();
} catch (error) {
console.error("[Settings] Failed to rename host", error);
Alert.alert("Error", "Unable to save host");
} finally {
setIsSavingEdit(false);
}
},
[editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost],
);
const handleRemoveConnection = useCallback(
async (serverId: string, connectionId: string) => {
await removeConnection(serverId, connectionId);
},
[removeConnection],
);
const handleRemoveDaemon = useCallback((profile: HostProfile) => {
setEditingDaemon(null);
setPendingRemoveHost(profile);
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
}
setIsPlaybackTestRunning(true);
setPlaybackTestResult(null);
try {
const bytes = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64");
await voiceAudioEngine.initialize();
voiceAudioEngine.stop();
await voiceAudioEngine.play({
type: "audio/pcm;rate=16000;bits=16",
size: bytes.byteLength,
async arrayBuffer() {
return Uint8Array.from(bytes).buffer;
},
});
setPlaybackTestResult(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[Settings] Playback test failed", error);
setPlaybackTestResult(`Playback failed: ${message}`);
} finally {
setIsPlaybackTestRunning(false);
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sections = getSettingsSections({ isDesktop });
const hostsProps: HostsSectionProps = {
daemons,
settings,
routeServerId,
isDesktop,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
setPendingEditReopenServerId,
setIsAddHostMethodVisible,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
addConnectionTargetServerId,
closeAddConnectionFlow,
goBackToAddConnectionMethods,
setIsDirectHostVisible,
setIsPasteLinkVisible,
pendingNameHost,
setPendingNameHost,
pendingNameHostname,
renameHost,
pendingRemoveHost,
setPendingRemoveHost,
isRemovingHost,
setIsRemovingHost,
removeHost,
editingDaemonLive,
isSavingEdit,
handleCloseEditDaemon,
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
handleAddConnectionFromModal,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
};
const appearanceProps: AppearanceSectionProps = {
settings,
handleThemeChange,
};
const diagnosticsProps: DiagnosticsSectionProps = {
voiceAudioEngine,
isPlaybackTestRunning,
playbackTestResult,
handlePlaybackTest,
};
const aboutProps: AboutSectionProps = {
appVersionText,
isDesktop,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps,
appearanceProps,
diagnosticsProps,
aboutProps,
appVersion,
isLocalDaemon,
isDesktop,
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading settings...</Text>
</View>
);
}
return (
<View style={styles.container}>
<MenuHeader title="Settings" />
{isMobile ? (
<SettingsMobileLayout sections={sections} sectionContentProps={sectionContentProps} />
) : (
<SettingsDesktopLayout sections={sections} sectionContentProps={sectionContentProps} />
)}
</View>
);
}
// ---------------------------------------------------------------------------
// HostDetailModal (unchanged)
// ---------------------------------------------------------------------------
interface HostDetailModalProps {
visible: boolean;
host: HostProfile | null;
isSaving: boolean;
onClose: () => void;
onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
timeoutMs: number,
intervalMs?: number,
) => Promise<boolean>;
isScreenMountedRef: MutableRefObject<boolean>;
}
function HostDetailModal({
visible,
host,
isSaving,
onClose,
onSave,
onRemoveConnection,
onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState("");
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{
serverId: string;
connectionId: string;
title: string;
} | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
// Read per-connection probes from host runtime snapshots.
const connections = host?.connections ?? [];
// Restart logic (moved from DaemonCard)
const runtimeSnapshot = useHostRuntimeSnapshot(host?.serverId ?? "");
const runtimeClient = useHostRuntimeClient(host?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(host?.serverId ?? "");
const runtime = getHostRuntimeStore();
const daemonClient = runtimeClient;
const daemonVersion = useSessionStore((state) =>
host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null,
);
const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
const lastError = runtimeSnapshot?.lastError ?? null;
const [isRestarting, setIsRestarting] = useState(false);
const isHostConnected = useCallback(() => {
if (!host) {
return false;
}
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
}, [host, runtime]);
const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000;
if (isHostConnected()) {
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
}
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isScreenMountedRef.current) {
setIsRestarting(false);
if (!reconnected && host) {
Alert.alert(
"Unable to reconnect",
`${host.label} did not come back online. Please verify it restarted.`,
);
}
}
}, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!daemonClient || !host) return;
if (!isHostConnected()) {
Alert.alert(
"Host offline",
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.",
);
return;
}
setIsRestarting(true);
void daemonClient.restartServer(`settings_daemon_restart_${host.serverId}`).catch((error) => {
console.error(`[Settings] Failed to restart daemon ${host.label}`, error);
if (!isScreenMountedRef.current) return;
setIsRestarting(false);
Alert.alert(
"Error",
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.",
);
});
void waitForDaemonRestart();
}, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!daemonClient || !host) {
Alert.alert(
"Host unavailable",
"This host is not connected. Wait for it to come online before restarting.",
);
return;
}
void confirmDialog({
title: `Restart ${host.label}`,
message: restartConfirmationMessage,
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return;
}
beginServerRestart();
})
.catch((error) => {
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
});
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
// Status display
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor =
statusTone === "success"
? theme.colors.palette.green[400]
: statusTone === "warning"
? theme.colors.palette.amber[500]
: statusTone === "error"
? theme.colors.destructive
: theme.colors.foregroundMuted;
const statusPillBg =
statusTone === "success"
? "rgba(74, 222, 128, 0.1)"
: statusTone === "warning"
? "rgba(245, 158, 11, 0.1)"
: statusTone === "error"
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const handleDraftLabelChange = useCallback((nextValue: string) => {
setDraftLabel(nextValue);
}, []);
useEffect(() => {
if (!visible || !host) return;
// Initialize once per modal open / host switch; keep user edits fully local while typing.
setDraftLabel(host.label ?? "");
}, [visible, host?.serverId]);
useEffect(() => {
if (!visible) {
setIsRestarting(false);
setDraftLabel("");
}
}, [visible]);
return (
<>
<AdaptiveModalSheet
title={host?.label ?? "Host"}
visible={visible}
onClose={onClose}
testID="host-detail-modal"
>
{/* Status row */}
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}>
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.connectionPill}>
{connectionBadge.icon}
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? (
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
{connectionError}
</Text>
) : null}
{/* Label */}
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<AdaptiveTextInput
style={styles.input}
value={draftLabel}
onChangeText={handleDraftLabelChange}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
/>
</View>
{/* Connections */}
{host ? (
<View style={styles.formField}>
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.connections.map((conn) => {
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title = formatHostConnectionLabel(conn);
setPendingRemoveConnection({
serverId: host.serverId,
connectionId: conn.id,
title,
});
}}
/>
);
})}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection}
>
+ Add connection
</Button>
</View>
</View>
) : null}
{/* Save/Cancel + Advanced */}
<View
style={{
borderTopWidth: 1,
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
paddingTop: theme.spacing[4],
}}
>
<View
style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}
>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [styles.advancedTrigger, pressed && { opacity: 0.85 }]}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedTriggerText}>Advanced</Text>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" width={220}>
<DropdownMenuItem
onSelect={handleRestartPress}
leading={
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnected}
>
Restart daemon
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (host) onRemoveHost(host);
}}
leading={<Trash2 size={theme.iconSize.md} color={theme.colors.destructive} />}
>
Remove host
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<View style={styles.formActionsRow}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="default"
size="sm"
onPress={() => onSave(draftLabel)}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</View>
</View>
</AdaptiveModalSheet>
{/* Remove connection confirmation */}
{pendingRemoveConnection ? (
<AdaptiveModalSheet
title="Remove connection"
visible
onClose={() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}}
testID="remove-connection-confirm-modal"
>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>
Remove {pendingRemoveConnection.title}? This cannot be undone.
</Text>
<View style={[styles.formActionsRow, { marginTop: theme.spacing[4] }]}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setPendingRemoveConnection(null)}
disabled={isRemovingConnection}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
const { serverId, connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void onRemoveConnection(serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[Settings] Failed to remove connection", error);
Alert.alert("Error", "Unable to remove connection");
})
.finally(() => setIsRemovingConnection(false));
}}
disabled={isRemovingConnection}
testID="remove-connection-confirm"
>
Remove
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</>
);
}
// ---------------------------------------------------------------------------
// ConnectionRow (unchanged)
// ---------------------------------------------------------------------------
function ConnectionRow({
connection,
latencyMs,
latencyLoading,
latencyError,
onRemove,
}: {
connection: HostConnection;
latencyMs: number | null | undefined;
latencyLoading: boolean;
latencyError: boolean;
onRemove: () => void;
}) {
const { theme } = useUnistyles();
const title = formatHostConnectionLabel(connection);
const latencyText = (() => {
if (latencyLoading) return "...";
if (latencyError) return "Timeout";
if (latencyMs != null) return `${latencyMs}ms`;
return "\u2014";
})();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
}}
>
<Text style={{ color: theme.colors.foreground, fontSize: 12, flex: 1 }}>{title}</Text>
<Text style={{ color: latencyColor, fontSize: 11 }}>{latencyText}</Text>
<Button
variant="ghost"
size="sm"
textStyle={{ color: theme.colors.destructive }}
onPress={onRemove}
>
Remove
</Button>
</View>
);
}
// ---------------------------------------------------------------------------
// DaemonCard (unchanged)
// ---------------------------------------------------------------------------
interface DaemonCardProps {
daemon: HostProfile;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(daemon.serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
[daemon.serverId],
),
);
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor =
statusTone === "success"
? theme.colors.palette.green[400]
: statusTone === "warning"
? theme.colors.palette.amber[500]
: statusTone === "error"
? theme.colors.destructive
: theme.colors.foregroundMuted;
const badgeText = statusLabel;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const statusPillBg =
statusTone === "success"
? "rgba(74, 222, 128, 0.1)"
: statusTone === "warning"
? "rgba(245, 158, 11, 0.1)"
: statusTone === "error"
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
return (
<View style={[settingsStyles.card, styles.hostCard]} testID={`daemon-card-${daemon.serverId}`}>
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
<Text style={styles.hostLabel} numberOfLines={1}>
{daemon.label}
</Text>
<View style={styles.hostHeaderRight}>
<View
style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg },
]}
>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null}
</View>
{connectionBadge ? (
<View
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
{connectionBadge.icon}
{Platform.OS === "web" ? (
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>
) : null}
</View>
) : null}
{versionBadgeText ? (
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
<Pressable
style={styles.hostSettingsButton}
onPress={() => onOpenSettings(daemon)}
testID={`daemon-card-settings-${daemon.serverId}`}
accessibilityLabel={`Open settings for ${daemon.label}`}
accessibilityRole="button"
>
{({ hovered = false, pressed = false }) => (
<Settings
size={theme.iconSize.sm}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</View>
</View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create((theme) => ({
loadingContainer: {
flex: 1,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
},
loadingText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
},
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
scrollView: {
flex: 1,
},
content: {
padding: theme.spacing[4],
paddingTop: theme.spacing[6],
width: "100%",
maxWidth: 720,
alignSelf: "center",
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: theme.spacing[2],
},
input: {
backgroundColor: theme.colors.surface0,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// Host card styles
hostCard: {
marginBottom: theme.spacing[3],
overflow: "hidden",
},
hostCardContent: {
padding: theme.spacing[4],
gap: theme.spacing[2],
},
hostHeaderRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
hostHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
hostLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
flex: 1,
flexShrink: 1,
},
hostError: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
// Status pill
statusPill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
},
statusPillMobile: {
alignItems: "center",
justifyContent: "center",
width: 14,
height: 14,
borderRadius: 7,
},
statusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
connectionPill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 170,
},
connectionPillMobile: {
flexDirection: "row",
alignItems: "center",
borderRadius: theme.borderRadius.full,
},
connectionText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
versionPill: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 200,
},
hostSettingsButton: {
width: 28,
height: 28,
paddingVertical: 0,
paddingHorizontal: 0,
borderRadius: theme.borderRadius.md,
gap: 0,
marginLeft: theme.spacing[2],
alignItems: "center",
justifyContent: "center",
},
advancedTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
advancedTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
disabled: {
opacity: theme.opacity[50],
},
testResultText: {
fontSize: theme.fontSize.xs,
},
// Add host button
addButton: {
borderStyle: "dashed",
},
addButtonText: {
color: theme.colors.foregroundMuted,
},
// Add/Edit form
formCard: {
padding: theme.spacing[4],
marginBottom: theme.spacing[3],
gap: theme.spacing[4],
},
formTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
formField: {
gap: theme.spacing[2],
},
formActionsRow: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
formButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
},
formButtonPrimary: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500],
},
formButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
formButtonPrimaryText: {
color: theme.colors.palette.white,
},
// Audio settings card
audioCard: {
overflow: "hidden",
},
audioRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
audioRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
audioRowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
audioRowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
aboutHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
aboutErrorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
aboutUpdateActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
// Empty state
emptyCard: {
padding: theme.spacing[4],
marginBottom: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
// Dev section
devCard: {
overflow: "hidden",
},
devButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
devButtonBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
devButtonContent: {
flex: 1,
},
devButtonTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
devButtonDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: 2,
},
}));
const desktopStyles = StyleSheet.create((theme) => ({
row: {
flex: 1,
flexDirection: "row",
},
sidebar: {
width: 200,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[2],
gap: theme.spacing[1],
},
sidebarItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
sidebarLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
contentPane: {
flex: 1,
},
}));