* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
1740 lines
56 KiB
TypeScript
1740 lines
56 KiB
TypeScript
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ComponentType, ReactNode } from "react";
|
|
import {
|
|
Alert,
|
|
Pressable,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { useRouter } from "expo-router";
|
|
import { useFocusEffect } from "@react-navigation/native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { Buffer } from "buffer";
|
|
import {
|
|
ArrowLeft,
|
|
Settings,
|
|
Palette,
|
|
Server,
|
|
Network,
|
|
Bot,
|
|
Boxes,
|
|
Gauge,
|
|
Keyboard,
|
|
Stethoscope,
|
|
Info,
|
|
Shield,
|
|
Puzzle,
|
|
Plus,
|
|
FolderGit2,
|
|
SquareTerminal,
|
|
Code2,
|
|
} from "lucide-react-native";
|
|
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
|
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
|
import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker";
|
|
import { HostStatusDot } from "@/components/host-status-dot";
|
|
import { ScreenTitle } from "@/components/headers/screen-title";
|
|
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
|
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
|
|
import {
|
|
useAppSettings,
|
|
useSettings,
|
|
parseTerminalScrollbackLines,
|
|
type AppSettings,
|
|
type SendBehavior,
|
|
type ServiceUrlBehavior,
|
|
type Settings as EffectiveSettings,
|
|
} from "@/hooks/use-settings";
|
|
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
import { BackHeader } from "@/components/headers/back-header";
|
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
|
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 { EditorSection } from "@/screens/settings/editor-section";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CommunityLinks } from "@/components/community-links";
|
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
|
import { BrowserDataSection } from "@/desktop/components/browser-data-section";
|
|
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
|
import { isElectronRuntime } 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 { useAppDiagnosticStore } from "@/diagnostics/store";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
|
import {
|
|
LANGUAGE_OPTIONS,
|
|
formatLanguageOptionLabel,
|
|
parseAppLanguage,
|
|
type AppLanguage,
|
|
type SupportedLocale,
|
|
} from "@/i18n/locales";
|
|
import {
|
|
HostConnectionsPage,
|
|
HostAgentsPage,
|
|
HostSettingsPage,
|
|
HostProvidersPage,
|
|
HostUsagePage,
|
|
HostWorkspacesPage,
|
|
HostTerminalsPage,
|
|
} from "@/screens/settings/host-page";
|
|
import ProjectsScreen from "@/screens/projects-screen";
|
|
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
|
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|
import {
|
|
type EnableBuiltInDaemonOption,
|
|
useEnableBuiltInDaemonOption,
|
|
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
|
|
import {
|
|
buildOpenProjectRoute,
|
|
buildProjectsSettingsRoute,
|
|
buildSettingsHostSectionRoute,
|
|
buildSettingsSectionRoute,
|
|
type HostSectionSlug,
|
|
type SettingsSectionSlug,
|
|
} from "@/utils/host-routes";
|
|
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// View model
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type SettingsView =
|
|
| { kind: "root" }
|
|
| { kind: "section"; section: SettingsSectionSlug }
|
|
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
|
| { kind: "projects" }
|
|
| { kind: "project"; projectKey: string };
|
|
|
|
interface SidebarSectionItem {
|
|
id: SettingsSectionSlug;
|
|
labelKey: string;
|
|
icon: ComponentType<{ size: number; color: string }>;
|
|
desktopOnly?: boolean;
|
|
}
|
|
|
|
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
|
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
|
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
|
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2 },
|
|
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
|
{
|
|
id: "integrations",
|
|
labelKey: "settings.sections.integrations",
|
|
icon: Puzzle,
|
|
desktopOnly: true,
|
|
},
|
|
{
|
|
id: "permissions",
|
|
labelKey: "settings.sections.permissions",
|
|
icon: Shield,
|
|
desktopOnly: true,
|
|
},
|
|
{ id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope },
|
|
{ id: "about", labelKey: "settings.sections.about", icon: Info },
|
|
];
|
|
|
|
interface HostSectionItem {
|
|
id: HostSectionSlug;
|
|
labelKey: string;
|
|
icon: ComponentType<{ size: number; color: string }>;
|
|
}
|
|
|
|
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
|
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
|
|
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
|
|
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
|
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
|
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
|
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
|
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
|
|
];
|
|
|
|
function renderHostSettingsContent(
|
|
view: Extract<SettingsView, { kind: "host" }>,
|
|
onHostRemoved: () => void,
|
|
): ReactNode {
|
|
switch (view.section) {
|
|
case "connections":
|
|
return <HostConnectionsPage serverId={view.serverId} />;
|
|
case "agents":
|
|
return <HostAgentsPage serverId={view.serverId} />;
|
|
case "workspaces":
|
|
return <HostWorkspacesPage serverId={view.serverId} />;
|
|
case "providers":
|
|
return <HostProvidersPage serverId={view.serverId} />;
|
|
case "usage":
|
|
return <HostUsagePage serverId={view.serverId} />;
|
|
case "terminals":
|
|
return <HostTerminalsPage serverId={view.serverId} />;
|
|
case "host":
|
|
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trigger + sidebar style helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
|
|
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
|
|
}
|
|
|
|
function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered];
|
|
}
|
|
|
|
function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [
|
|
sidebarStyles.item,
|
|
Boolean(hovered) && sidebarStyles.itemHovered,
|
|
sidebarStyles.itemSelected,
|
|
];
|
|
}
|
|
|
|
function getSendBehaviorOptions(t: TFunction) {
|
|
return [
|
|
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
|
|
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
|
|
];
|
|
}
|
|
|
|
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
|
|
const labels: Record<ServiceUrlBehavior, string> = {
|
|
ask: t("settings.general.serviceUrls.options.ask"),
|
|
"in-app": t("settings.general.serviceUrls.options.inApp"),
|
|
external: t("settings.general.serviceUrls.options.external"),
|
|
};
|
|
return labels[value];
|
|
}
|
|
|
|
function getActiveLocale(language: string | undefined): SupportedLocale {
|
|
const parsed = parseAppLanguage(language);
|
|
return parsed && parsed !== "system" ? parsed : "en";
|
|
}
|
|
|
|
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GeneralSectionProps {
|
|
settings: AppSettings;
|
|
isDesktopApp: boolean;
|
|
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
|
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
|
handleLanguageChange: (language: AppLanguage) => void;
|
|
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
|
}
|
|
|
|
interface ServiceUrlBehaviorMenuItemProps {
|
|
value: ServiceUrlBehavior;
|
|
label: string;
|
|
selected: boolean;
|
|
onChange: (value: ServiceUrlBehavior) => void;
|
|
}
|
|
|
|
function ServiceUrlBehaviorMenuItem({
|
|
value,
|
|
label,
|
|
selected,
|
|
onChange,
|
|
}: ServiceUrlBehaviorMenuItemProps) {
|
|
const handleSelect = useCallback(() => {
|
|
onChange(value);
|
|
}, [onChange, value]);
|
|
return (
|
|
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
|
{label}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
interface LanguageMenuItemProps {
|
|
value: AppLanguage;
|
|
activeLocale: SupportedLocale;
|
|
selected: boolean;
|
|
onChange: (value: AppLanguage) => void;
|
|
}
|
|
|
|
function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageMenuItemProps) {
|
|
const { t } = useTranslation();
|
|
const handleSelect = useCallback(() => {
|
|
onChange(value);
|
|
}, [onChange, value]);
|
|
const option = LANGUAGE_OPTIONS.find((entry) => entry.value === value);
|
|
const label = option
|
|
? formatLanguageOptionLabel(option, activeLocale, t(option.labelKey))
|
|
: value;
|
|
|
|
return (
|
|
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
|
{label}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
function GeneralSection({
|
|
settings,
|
|
isDesktopApp,
|
|
handleSendBehaviorChange,
|
|
handleServiceUrlBehaviorChange,
|
|
handleLanguageChange,
|
|
handleTerminalScrollbackLinesChange,
|
|
}: GeneralSectionProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const activeLocale = getActiveLocale(i18n.language);
|
|
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
|
|
const sendBehaviorDescriptionKey =
|
|
settings.sendBehavior === "interrupt"
|
|
? "settings.general.defaultSend.descriptions.interrupt"
|
|
: "settings.general.defaultSend.descriptions.queue";
|
|
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
|
|
(option) => option.value === settings.language,
|
|
);
|
|
const selectedLanguageLabel = selectedLanguageOption
|
|
? formatLanguageOptionLabel(
|
|
selectedLanguageOption,
|
|
activeLocale,
|
|
t(selectedLanguageOption.labelKey),
|
|
)
|
|
: settings.language;
|
|
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
|
String(settings.terminalScrollbackLines),
|
|
);
|
|
|
|
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
|
|
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
|
|
}, []);
|
|
|
|
const commitTerminalScrollback = useCallback(() => {
|
|
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
|
|
const nextValue = parsed ?? settings.terminalScrollbackLines;
|
|
setTerminalScrollbackValue(String(nextValue));
|
|
if (nextValue !== settings.terminalScrollbackLines) {
|
|
handleTerminalScrollbackLinesChange(nextValue);
|
|
}
|
|
}, [
|
|
handleTerminalScrollbackLinesChange,
|
|
settings.terminalScrollbackLines,
|
|
terminalScrollbackValue,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
|
|
}, [settings.terminalScrollbackLines]);
|
|
|
|
return (
|
|
<SettingsSection title={t("settings.general.title")}>
|
|
<View style={settingsStyles.card}>
|
|
<View style={settingsStyles.row}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
|
|
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
|
|
</View>
|
|
<SegmentedControl
|
|
size="sm"
|
|
value={settings.sendBehavior}
|
|
onValueChange={handleSendBehaviorChange}
|
|
options={sendBehaviorOptions}
|
|
/>
|
|
</View>
|
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
|
|
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
|
|
</View>
|
|
<DropdownMenu>
|
|
<DropdownTrigger
|
|
accessibilityRole="button"
|
|
accessibilityLabel={selectedLanguageLabel}
|
|
style={themeTriggerStyle}
|
|
>
|
|
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
|
|
</DropdownTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" width={300}>
|
|
{LANGUAGE_OPTIONS.map((option) => (
|
|
<LanguageMenuItem
|
|
key={option.value}
|
|
value={option.value}
|
|
activeLocale={activeLocale}
|
|
selected={settings.language === option.value}
|
|
onChange={handleLanguageChange}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</View>
|
|
{isDesktopApp ? (
|
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
|
|
<Text style={settingsStyles.rowHint}>
|
|
{t("settings.general.serviceUrls.description")}
|
|
</Text>
|
|
</View>
|
|
<DropdownMenu>
|
|
<DropdownTrigger style={themeTriggerStyle}>
|
|
<Text style={styles.themeTriggerText}>
|
|
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
|
|
</Text>
|
|
</DropdownTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" width={200}>
|
|
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
|
|
<ServiceUrlBehaviorMenuItem
|
|
key={value}
|
|
value={value}
|
|
label={getServiceUrlBehaviorLabel(t, value)}
|
|
selected={settings.serviceUrlBehavior === value}
|
|
onChange={handleServiceUrlBehaviorChange}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</View>
|
|
) : null}
|
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>
|
|
{t("settings.general.terminalScrollback.label")}
|
|
</Text>
|
|
<Text style={settingsStyles.rowHint}>
|
|
{t("settings.general.terminalScrollback.description")}
|
|
</Text>
|
|
</View>
|
|
<TextInput
|
|
value={terminalScrollbackValue}
|
|
onChangeText={handleTerminalScrollbackChangeText}
|
|
onBlur={commitTerminalScrollback}
|
|
onSubmitEditing={commitTerminalScrollback}
|
|
keyboardType="number-pad"
|
|
inputMode="numeric"
|
|
selectTextOnFocus
|
|
style={styles.terminalScrollbackInput}
|
|
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
interface DiagnosticsSectionProps {
|
|
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
|
isPlaybackTestRunning: boolean;
|
|
playbackTestResult: string | null;
|
|
handlePlaybackTest: () => Promise<void>;
|
|
}
|
|
|
|
function DiagnosticsSection({
|
|
voiceAudioEngine,
|
|
isPlaybackTestRunning,
|
|
playbackTestResult,
|
|
handlePlaybackTest,
|
|
}: DiagnosticsSectionProps) {
|
|
const { t } = useTranslation();
|
|
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
|
const handlePlayPress = useCallback(() => {
|
|
void handlePlaybackTest();
|
|
}, [handlePlaybackTest]);
|
|
return (
|
|
<SettingsSection title={t("settings.diagnostics.title")}>
|
|
<View style={settingsStyles.card}>
|
|
<View style={settingsStyles.row} testID="app-diagnostic-row">
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
|
|
<Text style={settingsStyles.rowHint}>{t("settings.diagnostics.app.rowHint")}</Text>
|
|
</View>
|
|
<Button variant="secondary" size="sm" onPress={openAppDiagnostic}>
|
|
{t("settings.diagnostics.app.run")}
|
|
</Button>
|
|
</View>
|
|
<View style={settingsStyles.row}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text>
|
|
{playbackTestResult ? (
|
|
<Text style={settingsStyles.rowHint}>{playbackTestResult}</Text>
|
|
) : null}
|
|
</View>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onPress={handlePlayPress}
|
|
disabled={!voiceAudioEngine || isPlaybackTestRunning}
|
|
>
|
|
{isPlaybackTestRunning
|
|
? t("settings.diagnostics.playing")
|
|
: t("settings.diagnostics.playTest")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
interface AboutSectionProps {
|
|
appVersion: string | null;
|
|
appVersionText: string;
|
|
isDesktopApp: boolean;
|
|
}
|
|
|
|
function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<SettingsSection title={t("settings.about.title")}>
|
|
<View style={settingsStyles.card}>
|
|
<View style={settingsStyles.row}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.about.appVersion")}</Text>
|
|
<Text style={settingsStyles.rowHint}>{t("settings.about.thisDevice")}</Text>
|
|
</View>
|
|
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
|
</View>
|
|
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
|
|
</View>
|
|
</SettingsSection>
|
|
<ConnectedHostsSection clientVersion={appVersion} />
|
|
<View style={styles.aboutCommunity}>
|
|
<CommunityLinks />
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function normalizeVersion(version: string | null | undefined): string | null {
|
|
const trimmed = version?.trim();
|
|
if (!trimmed) return null;
|
|
return trimmed.replace(/^v/i, "");
|
|
}
|
|
|
|
function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) {
|
|
const { t } = useTranslation();
|
|
const hosts = useHosts();
|
|
if (hosts.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<SettingsSection title={t("settings.about.connectedHosts")}>
|
|
<View style={settingsStyles.card}>
|
|
{hosts.map((host, index) => (
|
|
<HostVersionRow
|
|
key={host.serverId}
|
|
host={host}
|
|
showBorder={index > 0}
|
|
clientVersion={clientVersion}
|
|
/>
|
|
))}
|
|
</View>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
function HostVersionRow({
|
|
host,
|
|
showBorder,
|
|
clientVersion,
|
|
}: {
|
|
host: HostProfile;
|
|
showBorder: boolean;
|
|
clientVersion: string | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
|
const daemonVersion = useSessionStore(
|
|
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
|
|
);
|
|
|
|
const rowStyle = useMemo(
|
|
() => [settingsStyles.row, showBorder && settingsStyles.rowBorder],
|
|
[showBorder],
|
|
);
|
|
|
|
const normalizedHost = normalizeVersion(daemonVersion);
|
|
const normalizedClient = normalizeVersion(clientVersion);
|
|
const isMismatch =
|
|
normalizedHost !== null && normalizedClient !== null && normalizedHost !== normalizedClient;
|
|
|
|
let valueText: string;
|
|
if (!isConnected) {
|
|
valueText = t("settings.about.offline");
|
|
} else if (normalizedHost) {
|
|
valueText = formatVersionWithPrefix(normalizedHost);
|
|
} else {
|
|
valueText = "—";
|
|
}
|
|
|
|
const valueStyle = useMemo(
|
|
() => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch],
|
|
[isMismatch],
|
|
);
|
|
|
|
return (
|
|
<View style={rowStyle}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
|
{host.label}
|
|
</Text>
|
|
{isMismatch ? (
|
|
<Text style={settingsStyles.rowHint}>{t("settings.about.versionDiffers")}</Text>
|
|
) : null}
|
|
</View>
|
|
<Text style={valueStyle}>{valueText}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function getUpdateButtonLabel(
|
|
t: TFunction,
|
|
isInstalling: boolean,
|
|
latestVersion: string | null | undefined,
|
|
): string {
|
|
if (isInstalling) return t("settings.about.updates.installing");
|
|
if (latestVersion) {
|
|
return t("settings.about.updates.updateTo", {
|
|
version: formatVersionWithPrefix(latestVersion),
|
|
});
|
|
}
|
|
return t("settings.about.updates.update");
|
|
}
|
|
|
|
function DesktopAppUpdateRow() {
|
|
const { t } = useTranslation();
|
|
const { settings, updateSettings } = useSettings();
|
|
const {
|
|
isDesktopApp,
|
|
statusText,
|
|
availableUpdate,
|
|
errorMessage,
|
|
isChecking,
|
|
isInstalling,
|
|
checkForUpdates,
|
|
installUpdate,
|
|
} = useDesktopAppUpdater();
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (!isDesktopApp) {
|
|
return undefined;
|
|
}
|
|
void checkForUpdates({ intent: "automatic", silent: true });
|
|
return undefined;
|
|
}, [checkForUpdates, isDesktopApp]),
|
|
);
|
|
|
|
const handleCheckForUpdates = useCallback(() => {
|
|
if (!isDesktopApp) {
|
|
return;
|
|
}
|
|
void checkForUpdates();
|
|
}, [checkForUpdates, isDesktopApp]);
|
|
|
|
const handleReleaseChannelChange = useCallback(
|
|
(releaseChannel: EffectiveSettings["releaseChannel"]) => {
|
|
void updateSettings({ releaseChannel });
|
|
},
|
|
[updateSettings],
|
|
);
|
|
const releaseChannelOptions = useMemo(
|
|
() => [
|
|
{ value: "stable" as const, label: t("settings.about.releaseChannel.stable") },
|
|
{ value: "beta" as const, label: t("settings.about.releaseChannel.beta") },
|
|
],
|
|
[t],
|
|
);
|
|
|
|
const handleInstallUpdate = useCallback(() => {
|
|
if (!isDesktopApp) {
|
|
return;
|
|
}
|
|
|
|
void confirmDialog({
|
|
title: t("settings.about.updates.installTitle"),
|
|
message: t("settings.about.updates.installMessage"),
|
|
confirmLabel: t("settings.about.updates.installConfirm"),
|
|
cancelLabel: t("common.actions.cancel"),
|
|
})
|
|
.then((confirmed) => {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
void installUpdate();
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
console.error("[Settings] Failed to open app update confirmation", error);
|
|
Alert.alert(
|
|
t("settings.about.updates.alertTitle"),
|
|
t("settings.about.updates.alertMessage"),
|
|
);
|
|
});
|
|
}, [installUpdate, isDesktopApp, t]);
|
|
|
|
const isUpdateReady = availableUpdate?.readyToInstall === true;
|
|
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
|
|
|
|
if (!isDesktopApp) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.about.releaseChannel.label")}</Text>
|
|
<Text style={settingsStyles.rowHint}>
|
|
{t("settings.about.releaseChannel.description")}
|
|
</Text>
|
|
</View>
|
|
<SegmentedControl
|
|
size="sm"
|
|
value={settings.releaseChannel}
|
|
onValueChange={handleReleaseChannelChange}
|
|
options={releaseChannelOptions}
|
|
/>
|
|
</View>
|
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
|
|
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
|
{readyUpdateVersion ? (
|
|
<Text style={settingsStyles.rowHint}>
|
|
{t("settings.about.updates.readyToInstall", {
|
|
version: formatVersionWithPrefix(readyUpdateVersion),
|
|
})}
|
|
</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 ? t("settings.about.updates.checking") : t("settings.about.updates.check")}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onPress={handleInstallUpdate}
|
|
disabled={isChecking || isInstalling || !isUpdateReady}
|
|
>
|
|
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidebar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Local daemon first, then remaining hosts in their existing order.
|
|
*/
|
|
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
|
|
return useMemo(() => orderHostsLocalFirst(hosts, localServerId), [hosts, localServerId]);
|
|
}
|
|
|
|
interface SidebarSectionButtonProps {
|
|
itemId: SettingsSectionSlug;
|
|
label: string;
|
|
icon: ComponentType<{ size: number; color: string }>;
|
|
isSelected: boolean;
|
|
onSelect: (section: SettingsSectionSlug) => void;
|
|
}
|
|
|
|
function SidebarSectionButton({
|
|
itemId,
|
|
label,
|
|
icon: IconComponent,
|
|
isSelected,
|
|
onSelect,
|
|
}: SidebarSectionButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const handlePress = useCallback(() => {
|
|
onSelect(itemId);
|
|
}, [onSelect, itemId]);
|
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
|
const labelStyle = useMemo(
|
|
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
|
[isSelected, theme.colors.foreground],
|
|
);
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
onPress={handlePress}
|
|
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
|
>
|
|
<IconComponent
|
|
size={theme.iconSize.md}
|
|
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
<Text style={labelStyle} numberOfLines={1}>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
interface SidebarHostSectionButtonProps {
|
|
itemId: HostSectionSlug;
|
|
label: string;
|
|
icon: ComponentType<{ size: number; color: string }>;
|
|
isSelected: boolean;
|
|
onSelect: (section: HostSectionSlug) => void;
|
|
}
|
|
|
|
function SidebarHostSectionButton({
|
|
itemId,
|
|
label,
|
|
icon: IconComponent,
|
|
isSelected,
|
|
onSelect,
|
|
}: SidebarHostSectionButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const handlePress = useCallback(() => {
|
|
onSelect(itemId);
|
|
}, [onSelect, itemId]);
|
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
|
const labelStyle = useMemo(
|
|
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
|
[isSelected, theme.colors.foreground],
|
|
);
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
onPress={handlePress}
|
|
testID={`settings-host-section-${itemId}`}
|
|
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
|
>
|
|
<IconComponent
|
|
size={theme.iconSize.md}
|
|
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
<Text style={labelStyle} numberOfLines={1}>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
interface SidebarProjectsButtonProps {
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
|
const labelStyle = useMemo(
|
|
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
|
[isSelected, theme.colors.foreground],
|
|
);
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={accessibilityState}
|
|
onPress={onSelect}
|
|
testID="settings-projects"
|
|
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
|
>
|
|
<FolderGit2
|
|
size={theme.iconSize.md}
|
|
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
<Text style={labelStyle} numberOfLines={1}>
|
|
{t("settings.projects")}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
interface HostPickerProps {
|
|
activeServerId: string | null;
|
|
sortedHosts: HostProfile[];
|
|
onSelectHost: (serverId: string) => void;
|
|
onAddHost: () => void;
|
|
enableBuiltInDaemonOption: EnableBuiltInDaemonOption;
|
|
}
|
|
|
|
/**
|
|
* Scopes the four host sections to a host. Reuses the canonical sidebar host
|
|
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
|
|
* <Combobox>. The local host is listed first, each row shows the connection it
|
|
* is using right now; an "Add host" row is always reachable from the list —
|
|
* even with a single host.
|
|
*/
|
|
function HostPicker({
|
|
activeServerId,
|
|
sortedHosts,
|
|
onSelectHost,
|
|
onAddHost,
|
|
enableBuiltInDaemonOption,
|
|
}: HostPickerProps) {
|
|
const { t } = useTranslation();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const triggerRef = useRef<View | null>(null);
|
|
const activeHost =
|
|
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
|
|
|
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
|
const hostOptionTestID = useCallback(
|
|
(serverId: string) => `settings-host-picker-item-${serverId}`,
|
|
[],
|
|
);
|
|
const triggerStyle = useCallback(
|
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
sidebarStyles.pickerTrigger,
|
|
hovered && sidebarStyles.pickerTriggerHovered,
|
|
],
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<SharedHostPicker
|
|
hosts={sortedHosts}
|
|
value={activeServerId ?? ""}
|
|
onSelect={onSelectHost}
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
anchorRef={triggerRef}
|
|
includeAddHost
|
|
onAddHost={onAddHost}
|
|
includeEnableBuiltInDaemon={enableBuiltInDaemonOption.visible}
|
|
onEnableBuiltInDaemon={enableBuiltInDaemonOption.onPress}
|
|
showActiveConnection
|
|
searchable={false}
|
|
title={t("settings.hostPicker.switchHost")}
|
|
desktopMinWidth={240}
|
|
addHostTestID="settings-add-host"
|
|
hostOptionTestID={hostOptionTestID}
|
|
>
|
|
<ComboboxTrigger
|
|
ref={triggerRef}
|
|
block
|
|
style={triggerStyle}
|
|
onPress={handleOpen}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.hostPicker.switchHost")}
|
|
testID="settings-host-picker"
|
|
>
|
|
{activeHost ? (
|
|
<View style={sidebarStyles.pickerTriggerDot}>
|
|
<HostStatusDot serverId={activeHost.serverId} />
|
|
</View>
|
|
) : null}
|
|
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
|
|
{activeHost?.label ?? t("settings.groups.host")}
|
|
</Text>
|
|
</ComboboxTrigger>
|
|
</SharedHostPicker>
|
|
);
|
|
}
|
|
|
|
interface SettingsSidebarProps {
|
|
view: SettingsView;
|
|
onSelectSection: (section: SettingsSectionSlug) => void;
|
|
onSelectHostSection: (section: HostSectionSlug) => void;
|
|
onSelectHost: (serverId: string) => void;
|
|
onSelectProjects: () => void;
|
|
onAddHost: () => void;
|
|
onBackToWorkspace: () => void;
|
|
activeHostServerId: string | null;
|
|
layout: "desktop" | "mobile";
|
|
}
|
|
|
|
function SettingsSidebar({
|
|
view,
|
|
onSelectSection,
|
|
onSelectHostSection,
|
|
onSelectHost,
|
|
onSelectProjects,
|
|
onAddHost,
|
|
onBackToWorkspace,
|
|
activeHostServerId,
|
|
layout,
|
|
}: SettingsSidebarProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const hosts = useHosts();
|
|
const localServerId = useLocalDaemonServerId();
|
|
const sortedHosts = useSortedHosts(hosts, localServerId);
|
|
const hasHosts = sortedHosts.length > 0;
|
|
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
|
|
const isDesktopApp = isElectronRuntime();
|
|
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
|
const insets = useSafeAreaInsets();
|
|
const isDesktop = layout === "desktop";
|
|
const outerContainerStyle = useMemo(
|
|
() => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer],
|
|
[isDesktop],
|
|
);
|
|
const innerContainerStyle = useMemo(
|
|
() => [{ flex: 1 }, isDesktop ? { paddingTop: insets.top } : null],
|
|
[insets.top, isDesktop],
|
|
);
|
|
const selectedSectionId = view.kind === "section" ? view.section : null;
|
|
const selectedHostSection = view.kind === "host" ? view.section : null;
|
|
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
|
|
|
const sidebarBody = (
|
|
<>
|
|
<View style={sidebarStyles.list}>
|
|
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
|
|
{items.map((item) => (
|
|
<Fragment key={item.id}>
|
|
<SidebarSectionButton
|
|
itemId={item.id}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
isSelected={selectedSectionId === item.id}
|
|
onSelect={onSelectSection}
|
|
/>
|
|
{item.id === "general" ? (
|
|
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
|
) : null}
|
|
</Fragment>
|
|
))}
|
|
</View>
|
|
<SidebarSeparator />
|
|
{hasHosts ? (
|
|
<View style={sidebarStyles.list}>
|
|
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
|
|
<HostPicker
|
|
activeServerId={activeHostServerId}
|
|
sortedHosts={sortedHosts}
|
|
onSelectHost={onSelectHost}
|
|
onAddHost={onAddHost}
|
|
enableBuiltInDaemonOption={enableBuiltInDaemonOption}
|
|
/>
|
|
{HOST_SECTION_ITEMS.map((item) => (
|
|
<SidebarHostSectionButton
|
|
key={item.id}
|
|
itemId={item.id}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
isSelected={selectedHostSection === item.id}
|
|
onSelect={onSelectHostSection}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View style={sidebarStyles.list}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.addHost")}
|
|
onPress={onAddHost}
|
|
testID="settings-add-host"
|
|
style={sidebarItemStyle}
|
|
>
|
|
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
<Text style={sidebarStyles.label} numberOfLines={1}>
|
|
{t("settings.addHost")}
|
|
</Text>
|
|
</Pressable>
|
|
{enableBuiltInDaemonOption.visible ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.enableBuiltInDaemon")}
|
|
onPress={enableBuiltInDaemonOption.onPress}
|
|
testID="settings-enable-built-in-daemon"
|
|
style={sidebarItemStyle}
|
|
>
|
|
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
<Text style={sidebarStyles.label} numberOfLines={1}>
|
|
{t("settings.enableBuiltInDaemon")}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<View
|
|
accessibilityLabel={t("settings.title")}
|
|
role="navigation"
|
|
style={outerContainerStyle}
|
|
testID="settings-sidebar"
|
|
>
|
|
{isDesktop ? (
|
|
<View style={innerContainerStyle}>
|
|
<View style={sidebarStyles.sidebarDragArea}>
|
|
<TitlebarDragRegion />
|
|
<WindowChromeSafeArea placement="below" />
|
|
<SidebarHeaderRow
|
|
icon={ArrowLeft}
|
|
label={t("settings.backToWorkspace")}
|
|
onPress={onBackToWorkspace}
|
|
testID="settings-back-to-workspace"
|
|
/>
|
|
</View>
|
|
<ScrollView style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false}>
|
|
{sidebarBody}
|
|
</ScrollView>
|
|
</View>
|
|
) : (
|
|
sidebarBody
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SettingsScreenProps {
|
|
view: SettingsView;
|
|
openAddHostIntent?: string | null;
|
|
}
|
|
|
|
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
|
const router = useRouter();
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
|
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
|
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
|
|
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
|
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
|
|
const isDesktopApp = isElectronRuntime();
|
|
const appVersion = resolveAppVersion();
|
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
|
const isCompactLayout = useIsCompactFormFactor();
|
|
const insets = useSafeAreaInsets();
|
|
const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
|
const hosts = useHosts();
|
|
const localServerId = useLocalDaemonServerId();
|
|
const sortedHosts = useSortedHosts(hosts, localServerId);
|
|
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
|
|
view.kind === "host" ? view.serverId : null,
|
|
);
|
|
const knownSelectedSettingsHostServerId = useMemo(() => {
|
|
if (!selectedSettingsHostServerId) {
|
|
return null;
|
|
}
|
|
return hosts.some((host) => host.serverId === selectedSettingsHostServerId)
|
|
? selectedSettingsHostServerId
|
|
: null;
|
|
}, [hosts, selectedSettingsHostServerId]);
|
|
|
|
useEffect(() => {
|
|
if (view.kind === "host") {
|
|
setSelectedSettingsHostServerId(view.serverId);
|
|
}
|
|
}, [view]);
|
|
|
|
// The host the four sections scope to: the host on the active view, otherwise
|
|
// the picker choice, otherwise the local daemon, otherwise the first host.
|
|
const activeHostServerId = useMemo(() => {
|
|
if (view.kind === "host") return view.serverId;
|
|
return knownSelectedSettingsHostServerId ?? localServerId ?? sortedHosts[0]?.serverId ?? null;
|
|
}, [view, knownSelectedSettingsHostServerId, localServerId, sortedHosts]);
|
|
|
|
const handleSendBehaviorChange = useCallback(
|
|
(behavior: SendBehavior) => {
|
|
void updateSettings({ sendBehavior: behavior });
|
|
},
|
|
[updateSettings],
|
|
);
|
|
|
|
const handleServiceUrlBehaviorChange = useCallback(
|
|
(behavior: ServiceUrlBehavior) => {
|
|
void updateSettings({ serviceUrlBehavior: behavior });
|
|
},
|
|
[updateSettings],
|
|
);
|
|
|
|
const handleLanguageChange = useCallback(
|
|
(language: AppLanguage) => {
|
|
void updateSettings({ language });
|
|
},
|
|
[updateSettings],
|
|
);
|
|
|
|
const handleTerminalScrollbackLinesChange = useCallback(
|
|
(terminalScrollbackLines: number) => {
|
|
void updateSettings({ terminalScrollbackLines });
|
|
},
|
|
[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(t("settings.diagnostics.playbackFailed", { message }));
|
|
} finally {
|
|
setIsPlaybackTestRunning(false);
|
|
}
|
|
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
|
|
|
|
const closeAddConnectionFlow = useCallback(() => {
|
|
setIsAddHostMethodVisible(false);
|
|
setIsDirectHostVisible(false);
|
|
setIsPasteLinkVisible(false);
|
|
}, []);
|
|
|
|
const goBackToAddConnectionMethods = useCallback(() => {
|
|
setIsDirectHostVisible(false);
|
|
setIsPasteLinkVisible(false);
|
|
setIsAddHostMethodVisible(true);
|
|
}, []);
|
|
|
|
const handleAddHost = useCallback(() => {
|
|
setIsAddHostMethodVisible(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
|
|
return;
|
|
}
|
|
lastOpenedAddHostIntentRef.current = openAddHostIntent;
|
|
handleAddHost();
|
|
}, [handleAddHost, openAddHostIntent]);
|
|
|
|
const handleSelectDirectConnection = useCallback(() => {
|
|
setIsAddHostMethodVisible(false);
|
|
setIsDirectHostVisible(true);
|
|
}, []);
|
|
|
|
const handleSelectPasteLink = useCallback(() => {
|
|
setIsAddHostMethodVisible(false);
|
|
setIsPasteLinkVisible(true);
|
|
}, []);
|
|
|
|
const handleHostAdded = useCallback(
|
|
({ serverId }: { serverId: string }) => {
|
|
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
|
if (isCompactLayout) {
|
|
router.push(target);
|
|
} else {
|
|
router.replace(target);
|
|
}
|
|
},
|
|
[isCompactLayout, router],
|
|
);
|
|
|
|
const handleSelectSection = useCallback(
|
|
(section: SettingsSectionSlug) => {
|
|
const target = buildSettingsSectionRoute(section);
|
|
if (isCompactLayout) {
|
|
router.push(target);
|
|
} else {
|
|
router.replace(target);
|
|
}
|
|
},
|
|
[isCompactLayout, router],
|
|
);
|
|
|
|
// Picker: choose the host for host-section rows. If the user is already on a
|
|
// host detail route, keep that detail section and swap only the host segment.
|
|
const handleSelectHost = useCallback(
|
|
(serverId: string) => {
|
|
setSelectedSettingsHostServerId(serverId);
|
|
if (view.kind !== "host") {
|
|
return;
|
|
}
|
|
const section: HostSectionSlug = view.section;
|
|
const target = buildSettingsHostSectionRoute(serverId, section);
|
|
if (isCompactLayout) {
|
|
router.push(target);
|
|
} else {
|
|
router.replace(target);
|
|
}
|
|
},
|
|
[isCompactLayout, router, view],
|
|
);
|
|
|
|
const handleSelectHostSection = useCallback(
|
|
(section: HostSectionSlug) => {
|
|
if (!activeHostServerId) {
|
|
handleAddHost();
|
|
return;
|
|
}
|
|
const target = buildSettingsHostSectionRoute(activeHostServerId, section);
|
|
if (isCompactLayout) {
|
|
router.push(target);
|
|
} else {
|
|
router.replace(target);
|
|
}
|
|
},
|
|
[activeHostServerId, handleAddHost, isCompactLayout, router],
|
|
);
|
|
|
|
const handleSelectProjects = useCallback(() => {
|
|
const target = buildProjectsSettingsRoute();
|
|
if (isCompactLayout) {
|
|
router.push(target);
|
|
} else {
|
|
router.replace(target);
|
|
}
|
|
}, [isCompactLayout, router]);
|
|
|
|
const handleScanQr = useCallback(() => {
|
|
closeAddConnectionFlow();
|
|
router.push({
|
|
pathname: "/pair-scan",
|
|
params: { source: "settings" },
|
|
});
|
|
}, [closeAddConnectionFlow, router]);
|
|
|
|
const handleHostRemoved = useCallback(() => {
|
|
const fallback = buildSettingsSectionRoute("general");
|
|
if (isCompactLayout) {
|
|
router.replace("/settings");
|
|
} else {
|
|
router.replace(fallback);
|
|
}
|
|
}, [isCompactLayout, router]);
|
|
|
|
const handleBackToRoot = useCallback(() => {
|
|
if (router.canGoBack()) {
|
|
router.back();
|
|
} else {
|
|
router.replace("/settings");
|
|
}
|
|
}, [router]);
|
|
|
|
const handleBackToWorkspace = useCallback(() => {
|
|
if (navigateToLastWorkspace()) {
|
|
return;
|
|
}
|
|
router.replace(buildOpenProjectRoute());
|
|
}, [router]);
|
|
|
|
const detailHeader = ((): {
|
|
title: string;
|
|
Icon: ComponentType<{ size: number; color: string }>;
|
|
titleAccessory?: ReactNode;
|
|
} | null => {
|
|
if (view.kind === "host") {
|
|
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
|
if (!item) return null;
|
|
return { title: t(item.labelKey), Icon: item.icon };
|
|
}
|
|
if (view.kind === "section") {
|
|
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
|
if (!item) return null;
|
|
return { title: t(item.labelKey), Icon: item.icon };
|
|
}
|
|
if (view.kind === "project" || view.kind === "projects") {
|
|
return { title: t("settings.projects"), Icon: FolderGit2 };
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
const content = (() => {
|
|
if (view.kind === "host") {
|
|
return renderHostSettingsContent(view, handleHostRemoved);
|
|
}
|
|
if (view.kind === "projects") {
|
|
return <ProjectsScreen view={view} />;
|
|
}
|
|
if (view.kind === "project") {
|
|
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
|
}
|
|
if (view.kind === "section") {
|
|
switch (view.section) {
|
|
case "general":
|
|
return (
|
|
<>
|
|
<GeneralSection
|
|
settings={settings}
|
|
isDesktopApp={isDesktopApp}
|
|
handleSendBehaviorChange={handleSendBehaviorChange}
|
|
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
|
handleLanguageChange={handleLanguageChange}
|
|
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
|
/>
|
|
{isDesktopApp ? <BrowserDataSection /> : null}
|
|
</>
|
|
);
|
|
case "appearance":
|
|
return <AppearanceSection />;
|
|
case "editor":
|
|
return <EditorSection />;
|
|
case "shortcuts":
|
|
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
|
case "integrations":
|
|
return isDesktopApp ? <IntegrationsSection /> : null;
|
|
case "permissions":
|
|
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
|
case "diagnostics":
|
|
return (
|
|
<DiagnosticsSection
|
|
voiceAudioEngine={voiceAudioEngine}
|
|
isPlaybackTestRunning={isPlaybackTestRunning}
|
|
playbackTestResult={playbackTestResult}
|
|
handlePlaybackTest={handlePlaybackTest}
|
|
/>
|
|
);
|
|
case "about":
|
|
return (
|
|
<AboutSection
|
|
appVersion={appVersion}
|
|
appVersionText={appVersionText}
|
|
isDesktopApp={isDesktopApp}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
if (settingsLoading) {
|
|
return (
|
|
<View style={styles.loadingContainer}>
|
|
<Text style={styles.loadingText}>{t("settings.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const desktopDetailHeaderLeft = detailHeader ? (
|
|
<>
|
|
<HeaderIconBadge>
|
|
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
</HeaderIconBadge>
|
|
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
|
|
{detailHeader.titleAccessory}
|
|
</>
|
|
) : null;
|
|
|
|
const addHostModals = (
|
|
<>
|
|
<AddHostMethodModal
|
|
visible={isAddHostMethodVisible}
|
|
onClose={closeAddConnectionFlow}
|
|
onDirectConnection={handleSelectDirectConnection}
|
|
onPasteLink={handleSelectPasteLink}
|
|
onScanQr={handleScanQr}
|
|
/>
|
|
<AddHostModal
|
|
visible={isDirectHostVisible}
|
|
onClose={closeAddConnectionFlow}
|
|
onCancel={goBackToAddConnectionMethods}
|
|
onSaved={handleHostAdded}
|
|
/>
|
|
<PairLinkModal
|
|
visible={isPasteLinkVisible}
|
|
onClose={closeAddConnectionFlow}
|
|
onCancel={goBackToAddConnectionMethods}
|
|
onSaved={handleHostAdded}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
// Mobile root: full-screen sidebar-as-list.
|
|
if (isCompactLayout && view.kind === "root") {
|
|
return (
|
|
<View style={styles.container}>
|
|
<BackHeader title={t("settings.title")} onBack={handleBackToWorkspace} />
|
|
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
|
<SettingsSidebar
|
|
view={view}
|
|
onSelectSection={handleSelectSection}
|
|
onSelectHostSection={handleSelectHostSection}
|
|
onSelectHost={handleSelectHost}
|
|
onSelectProjects={handleSelectProjects}
|
|
onAddHost={handleAddHost}
|
|
onBackToWorkspace={handleBackToWorkspace}
|
|
activeHostServerId={activeHostServerId}
|
|
layout="mobile"
|
|
/>
|
|
</ScrollView>
|
|
{addHostModals}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Mobile detail: full-screen content with a back header. Project detail uses
|
|
// an app-level back (out of settings, to the workspace) since the in-body
|
|
// "Back to projects" ghost button handles list-level back; other detail views
|
|
// step back to the settings root.
|
|
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
|
|
if (isCompactLayout) {
|
|
return (
|
|
<View style={styles.container}>
|
|
<BackHeader
|
|
title={detailHeader?.title}
|
|
titleAccessory={detailHeader?.titleAccessory}
|
|
onBack={detailBackHandler}
|
|
/>
|
|
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
|
<View style={styles.content}>{content}</View>
|
|
</ScrollView>
|
|
{addHostModals}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Desktop split view — mirrors AppContainer: sidebar owns the titlebar drag
|
|
// region + traffic-light padding; detail pane renders whatever header the
|
|
// selected section provides.
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={desktopStyles.row}>
|
|
<WindowChromeRegion corners="top-left">
|
|
<SettingsSidebar
|
|
view={view}
|
|
onSelectSection={handleSelectSection}
|
|
onSelectHostSection={handleSelectHostSection}
|
|
onSelectHost={handleSelectHost}
|
|
onSelectProjects={handleSelectProjects}
|
|
onAddHost={handleAddHost}
|
|
onBackToWorkspace={handleBackToWorkspace}
|
|
activeHostServerId={activeHostServerId}
|
|
layout="desktop"
|
|
/>
|
|
</WindowChromeRegion>
|
|
<WindowChromeRegion corners="top-right">
|
|
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
|
<ScreenHeader
|
|
borderless={!detailHeader}
|
|
left={desktopDetailHeaderLeft}
|
|
leftStyle={desktopStyles.detailLeft}
|
|
/>
|
|
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
|
<View style={styles.content}>{content}</View>
|
|
</ScrollView>
|
|
</View>
|
|
</WindowChromeRegion>
|
|
</View>
|
|
{addHostModals}
|
|
</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",
|
|
},
|
|
aboutValue: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
aboutVersionMismatch: {
|
|
color: theme.colors.palette.amber[500],
|
|
},
|
|
aboutErrorText: {
|
|
color: theme.colors.palette.red[300],
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
aboutCommunity: {
|
|
marginTop: theme.spacing[4],
|
|
},
|
|
aboutUpdateActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
themeTrigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
},
|
|
themeTriggerText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
terminalScrollbackInput: {
|
|
width: 112,
|
|
minHeight: 36,
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surface2,
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "right",
|
|
},
|
|
placeholder: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingVertical: theme.spacing[8],
|
|
},
|
|
placeholderText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
}));
|
|
|
|
const desktopStyles = StyleSheet.create((theme) => ({
|
|
row: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
},
|
|
contentPane: {
|
|
flex: 1,
|
|
},
|
|
detailLeft: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
const sidebarStyles = StyleSheet.create((theme) => ({
|
|
desktopContainer: {
|
|
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
|
borderRightWidth: 1,
|
|
borderRightColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
scrollBody: {
|
|
flex: 1,
|
|
},
|
|
sidebarDragArea: {
|
|
position: "relative",
|
|
},
|
|
mobileContainer: {
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
},
|
|
list: {
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
gap: theme.spacing[1],
|
|
},
|
|
groupLabel: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
color: theme.colors.foregroundMuted,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
item: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
minHeight: 36,
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
},
|
|
itemHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
itemSelected: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
label: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foregroundMuted,
|
|
fontWeight: theme.fontWeight.normal,
|
|
flex: 1,
|
|
},
|
|
pickerTrigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
minHeight: 36,
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
},
|
|
pickerTriggerHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
pickerTriggerLabel: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
// Match the setting items' icon footprint so the host label aligns with them.
|
|
pickerTriggerDot: {
|
|
width: theme.iconSize.md,
|
|
height: theme.iconSize.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
}));
|