Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type TextStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
@@ -134,6 +135,7 @@ function markerStyle(type: RowType) {
|
||||
// UnistylesRuntime.updateTheme commits a setting; the optional `overrides` layer
|
||||
// inline styles for live-while-typing feedback on the code font.
|
||||
export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => buildUnifiedRows(), []);
|
||||
const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]);
|
||||
const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]);
|
||||
@@ -149,7 +151,7 @@ export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel="Live preview of the syntax theme and code font"
|
||||
accessibilityLabel={t("settings.appearance.syntax.previewAccessibility")}
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
style={styles.card}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
@@ -49,16 +51,18 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// Stored value -> displayed label. `auto` reads as "System" for the app theme.
|
||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
zinc: "Zinc",
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
auto: "System",
|
||||
};
|
||||
function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
|
||||
const labelKeys: Record<AppSettings["theme"], string> = {
|
||||
light: "settings.appearance.theme.options.light",
|
||||
dark: "settings.appearance.theme.options.dark",
|
||||
zinc: "settings.appearance.theme.options.zinc",
|
||||
midnight: "settings.appearance.theme.options.midnight",
|
||||
claude: "settings.appearance.theme.options.claude",
|
||||
ghostty: "settings.appearance.theme.options.ghostty",
|
||||
auto: "settings.appearance.theme.options.auto",
|
||||
};
|
||||
return t(labelKeys[value]);
|
||||
}
|
||||
|
||||
const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"];
|
||||
const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
@@ -72,13 +76,10 @@ const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
// those read as a bug, so show a human label in the placeholder instead.
|
||||
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
|
||||
|
||||
function resolveDefaultStackPlaceholder(stack: string): string {
|
||||
return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack;
|
||||
function resolveDefaultStackPlaceholder(t: TFunction, stack: string): string {
|
||||
return BARE_DEFAULT_STACKS.has(stack) ? t("settings.appearance.fonts.systemDefault") : stack;
|
||||
}
|
||||
|
||||
const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK);
|
||||
const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK);
|
||||
|
||||
// Local size string (digits only) -> preview override number. Empty/invalid
|
||||
// yields undefined so the preview falls back to the committed theme value.
|
||||
function sizeDraftToOverride(value: string): number | undefined {
|
||||
@@ -128,13 +129,14 @@ interface ThemeMenuItemProps {
|
||||
}
|
||||
|
||||
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(themeValue);
|
||||
}, [onChange, themeValue]);
|
||||
const leading = useMemo(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
|
||||
{THEME_LABELS[themeValue]}
|
||||
{getThemeLabel(t, themeValue)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -145,18 +147,22 @@ interface ThemeRowProps {
|
||||
}
|
||||
|
||||
function ThemeRow({ value, onChange }: ThemeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = getThemeLabel(t, value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Theme</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Theme: ${THEME_LABELS[value]}`}
|
||||
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<ThemeLeading themeValue={value} />
|
||||
<Text style={styles.triggerText}>{THEME_LABELS[value]}</Text>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
@@ -316,18 +322,26 @@ interface SyntaxRowProps {
|
||||
}
|
||||
|
||||
function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = syntaxLabelForId(value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Highlight theme</Text>
|
||||
<Text style={settingsStyles.rowHint}>Colors for code, independent of the app theme</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.appearance.syntax.highlightTheme")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.appearance.syntax.highlightThemeHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Highlight theme: ${syntaxLabelForId(value)}`}
|
||||
accessibilityLabel={t("settings.appearance.syntax.highlightThemeAccessibility", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{syntaxLabelForId(value)}</Text>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
@@ -350,8 +364,11 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const showFontFamilyRows = !isNative;
|
||||
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
||||
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
||||
|
||||
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
|
||||
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
|
||||
@@ -455,19 +472,19 @@ export function AppearanceSection() {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title="Theme">
|
||||
<SettingsSection title={t("settings.appearance.theme.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Fonts">
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{showFontFamilyRows ? (
|
||||
<FontFamilyRow
|
||||
title="Interface font"
|
||||
hint="Used across the app. Leave empty for the system default"
|
||||
accessibilityLabel="Interface font family"
|
||||
placeholder={UI_FONT_PLACEHOLDER}
|
||||
title={t("settings.appearance.fonts.interfaceFont")}
|
||||
hint={t("settings.appearance.fonts.interfaceFontHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.interfaceFontAccessibility")}
|
||||
placeholder={uiFontPlaceholder}
|
||||
value={settings.uiFontFamily}
|
||||
draft={uiFontDraft}
|
||||
withBorder={false}
|
||||
@@ -476,8 +493,8 @@ export function AppearanceSection() {
|
||||
/>
|
||||
) : null}
|
||||
<FontSizeRow
|
||||
title="Interface size"
|
||||
accessibilityLabel="Interface font size"
|
||||
title={t("settings.appearance.fonts.interfaceSize")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
|
||||
draft={uiSizeDraft}
|
||||
withBorder={showFontFamilyRows}
|
||||
onChangeDraft={handleUiSizeChange}
|
||||
@@ -485,10 +502,10 @@ export function AppearanceSection() {
|
||||
/>
|
||||
{showFontFamilyRows ? (
|
||||
<FontFamilyRow
|
||||
title="Code font"
|
||||
hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
|
||||
accessibilityLabel="Code font family"
|
||||
placeholder={MONO_FONT_PLACEHOLDER}
|
||||
title={t("settings.appearance.fonts.codeFont")}
|
||||
hint={t("settings.appearance.fonts.codeFontHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
|
||||
placeholder={monoFontPlaceholder}
|
||||
value={settings.monoFontFamily}
|
||||
draft={monoFontDraft}
|
||||
withBorder
|
||||
@@ -497,15 +514,15 @@ export function AppearanceSection() {
|
||||
/>
|
||||
) : null}
|
||||
<FontSizeRow
|
||||
title="Code size"
|
||||
accessibilityLabel="Code font size"
|
||||
title={t("settings.appearance.fonts.codeSize")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
|
||||
draft={codeSizeDraft}
|
||||
onChangeDraft={handleCodeSizeChange}
|
||||
onCommit={commitCodeSize}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Syntax">
|
||||
<SettingsSection title={t("settings.appearance.syntax.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
@@ -32,15 +34,12 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { formatLatency } from "@/utils/latency";
|
||||
|
||||
const RESTART_CONFIRMATION_MESSAGE =
|
||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||
|
||||
function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
|
||||
if (connection.type === "relay") {
|
||||
return `Relay (${connection.relayEndpoint})`;
|
||||
return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`;
|
||||
}
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return `Local (${connection.path})`;
|
||||
return `${t("settings.host.badges.local")} (${connection.path})`;
|
||||
}
|
||||
return `TCP (${connection.endpoint})`;
|
||||
}
|
||||
@@ -48,18 +47,19 @@ function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
function formatActiveConnectionBadge(
|
||||
activeConnection: { type: HostConnection["type"]; display: string } | null,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
t: TFunction,
|
||||
): { icon: React.ReactNode; text: string } | null {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return {
|
||||
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
text: "Relay",
|
||||
text: t("settings.host.badges.relay"),
|
||||
};
|
||||
}
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
text: "Local",
|
||||
text: t("settings.host.badges.local"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -74,24 +74,24 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||
|
||||
function useHostProfile(serverId: string): HostProfile | null {
|
||||
const daemons = useHosts();
|
||||
return daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||
}
|
||||
|
||||
function HostNotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.notFound")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const daemonVersion = useSessionStore(
|
||||
@@ -122,7 +122,7 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
} else {
|
||||
statusPillBg = "rgba(161, 161, 170, 0.1)";
|
||||
}
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
const statusPillStyle = useMemo(
|
||||
@@ -170,6 +170,7 @@ function HostConnectionError({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
@@ -182,7 +183,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
<HostConnectionError serverId={serverId} />
|
||||
<ConnectionsSection host={host} />
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
<SettingsSection title={t("settings.host.pairDevices.title")}>
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
@@ -191,6 +192,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -201,13 +203,13 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Agents">
|
||||
<SettingsSection title={t("settings.hostSections.agents")}>
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage agents</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -215,6 +217,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -225,12 +228,12 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingsSection title={t("settings.hostSections.workspaces")}>
|
||||
<AutoArchiveMergedWorkspacesCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage workspaces</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.host.workspaces.unavailable")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -284,6 +287,7 @@ export function HostSettingsPage({
|
||||
}
|
||||
|
||||
export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { renameHost } = useHostMutations();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -307,7 +311,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
hitSlop={8}
|
||||
style={styles.identityEditButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Edit label"
|
||||
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
|
||||
testID="host-page-label-edit-button"
|
||||
>
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -315,10 +319,10 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
|
||||
<AdaptiveRenameModal
|
||||
visible={isEditing}
|
||||
title="Rename host"
|
||||
title={t("settings.host.daemon.rename.title")}
|
||||
initialValue={host.label}
|
||||
placeholder="My Host"
|
||||
submitLabel="Save"
|
||||
placeholder={t("settings.host.daemon.rename.placeholder")}
|
||||
submitLabel={t("settings.host.daemon.rename.submit")}
|
||||
onClose={closeEditor}
|
||||
onSubmit={handleSubmit}
|
||||
testID="host-page-rename-modal"
|
||||
@@ -328,6 +332,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { removeConnection } = useHostMutations();
|
||||
const snapshot = useHostRuntimeSnapshot(host.serverId);
|
||||
const probeByConnectionId = snapshot?.probeByConnectionId ?? new Map();
|
||||
@@ -336,13 +341,20 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
const removeConnectionHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.connections.removeTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRequestRemove = useCallback((connection: HostConnection) => {
|
||||
setPendingRemoveConnection({
|
||||
connectionId: connection.id,
|
||||
title: formatHostConnectionLabel(connection),
|
||||
});
|
||||
}, []);
|
||||
const handleRequestRemove = useCallback(
|
||||
(connection: HostConnection) => {
|
||||
setPendingRemoveConnection({
|
||||
connectionId: connection.id,
|
||||
title: formatHostConnectionLabel(connection, t),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCloseConfirm = useCallback(() => {
|
||||
if (isRemovingConnection) return;
|
||||
@@ -361,13 +373,16 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
.then(() => setPendingRemoveConnection(null))
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to remove connection", error);
|
||||
Alert.alert("Error", "Unable to remove connection");
|
||||
Alert.alert(
|
||||
t("settings.host.connections.removeErrorTitle"),
|
||||
t("settings.host.connections.removeErrorMessage"),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsRemovingConnection(false));
|
||||
}, [pendingRemoveConnection, removeConnection, host.serverId]);
|
||||
}, [pendingRemoveConnection, removeConnection, host.serverId, t]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Connections">
|
||||
<SettingsSection title={t("settings.host.connections.title")}>
|
||||
<View style={settingsStyles.card} testID="host-page-connections-card">
|
||||
{host.connections.map((conn, index) => {
|
||||
const probe = probeByConnectionId.get(conn.id);
|
||||
@@ -387,13 +402,15 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
|
||||
{pendingRemoveConnection ? (
|
||||
<AdaptiveModalSheet
|
||||
header={REMOVE_CONNECTION_HEADER}
|
||||
header={removeConnectionHeader}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-connection-confirm-modal"
|
||||
>
|
||||
<Text style={styles.confirmText}>
|
||||
Remove {pendingRemoveConnection.title}? This cannot be undone.
|
||||
{t("settings.host.connections.removeMessage", {
|
||||
name: pendingRemoveConnection.title,
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
@@ -403,7 +420,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
onPress={handleCancelConfirm}
|
||||
disabled={isRemovingConnection}
|
||||
>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -413,7 +430,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
disabled={isRemovingConnection}
|
||||
testID="remove-connection-confirm"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -437,12 +454,13 @@ function ConnectionRow({
|
||||
latencyError: boolean;
|
||||
onRemove: (connection: HostConnection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const title = formatHostConnectionLabel(connection);
|
||||
const title = formatHostConnectionLabel(connection, t);
|
||||
|
||||
const latencyText = (() => {
|
||||
if (latencyLoading) return "...";
|
||||
if (latencyError) return "Timeout";
|
||||
if (latencyError) return t("settings.host.connections.timeout");
|
||||
if (latencyMs != null) return formatLatency(latencyMs);
|
||||
return "—";
|
||||
})();
|
||||
@@ -479,7 +497,7 @@ function ConnectionRow({
|
||||
textStyle={destructiveTextStyle}
|
||||
onPress={handlePressRemove}
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -491,6 +509,7 @@ const delay = (ms: number) =>
|
||||
});
|
||||
|
||||
function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
@@ -533,34 +552,34 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
"Unable to reconnect",
|
||||
`${host.label} did not come back online. Please verify it restarted.`,
|
||||
t("settings.host.daemon.restart.unableToReconnectTitle"),
|
||||
t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [host.label, isHostConnected, waitForCondition]);
|
||||
}, [host.label, isHostConnected, t, waitForCondition]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
"Host unavailable",
|
||||
"This host is not connected. Wait for it to come online before restarting.",
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
"Host offline",
|
||||
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.",
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: `Restart ${host.label}`,
|
||||
message: RESTART_CONFIRMATION_MESSAGE,
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
@@ -573,8 +592,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
if (!isMountedRef.current) return;
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.",
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.requestFailedMessage"),
|
||||
);
|
||||
});
|
||||
void waitForDaemonRestart();
|
||||
@@ -582,9 +601,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.dialogFailedMessage"),
|
||||
);
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, waitForDaemonRestart]);
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
|
||||
|
||||
const restartIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
@@ -595,10 +617,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
<View style={settingsStyles.card} testID="host-page-restart-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Restarts the daemon process. The app will reconnect automatically
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.restart.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.restart.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -608,7 +628,9 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
disabled={isRestarting || !daemonClient || !isConnected}
|
||||
testID="host-page-restart-button"
|
||||
>
|
||||
{isRestarting ? "Restarting..." : "Restart"}
|
||||
{isRestarting
|
||||
? t("settings.host.daemon.restart.restarting")
|
||||
: t("settings.host.daemon.restart.confirm")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -616,6 +638,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
|
||||
@@ -636,15 +659,17 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.orchestration.enableTools.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
{t("settings.host.orchestration.enableTools.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.mcp.injectIntoAgents !== false}
|
||||
onValueChange={handleValueChange}
|
||||
accessibilityLabel="Inject Paseo tools"
|
||||
accessibilityLabel={t("settings.host.orchestration.enableTools.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -691,13 +716,17 @@ function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.orchestration.systemPrompt.sheetTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(persistedPrompt);
|
||||
@@ -740,8 +769,12 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.host.orchestration.systemPrompt.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.orchestration.systemPrompt.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -749,7 +782,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
{t("settings.host.orchestration.systemPrompt.edit")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -764,10 +797,10 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
accessibilityLabel={t("settings.host.orchestration.systemPrompt.accessibilityLabel")}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
placeholder={t("settings.host.orchestration.systemPrompt.placeholder")}
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
@@ -777,7 +810,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
{t("settings.host.orchestration.systemPrompt.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -786,7 +819,9 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
{isSaving
|
||||
? t("settings.host.orchestration.systemPrompt.saving")
|
||||
: t("settings.host.orchestration.systemPrompt.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -796,6 +831,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
@@ -811,10 +847,8 @@ function PairDeviceRow() {
|
||||
testID="host-page-pair-device-row"
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan a QR code or copy a link to connect your phone to this host
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.pairDevices.rowTitle")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.pairDevices.rowHint")}</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
@@ -837,6 +871,7 @@ function RemoveHostSection({
|
||||
isLocalDaemon: boolean;
|
||||
onRemoved?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { removeHost } = useHostMutations();
|
||||
const { updateSettings } = useDesktopSettings();
|
||||
@@ -844,6 +879,14 @@ function RemoveHostSection({
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const daemonStatus = daemonStatusData?.status ?? null;
|
||||
const removeHostHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localConfirmTitle")
|
||||
: t("settings.host.daemon.remove.title"),
|
||||
}),
|
||||
[isLocalDaemon, t],
|
||||
);
|
||||
|
||||
const destructiveTextStyle = useMemo(
|
||||
() => ({ color: theme.colors.destructive }),
|
||||
@@ -903,8 +946,10 @@ function RemoveHostSection({
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to remove host", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host",
|
||||
t("settings.host.daemon.remove.errorTitle"),
|
||||
isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localErrorMessage")
|
||||
: t("settings.host.daemon.remove.errorMessage"),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsRemoving(false));
|
||||
@@ -916,35 +961,34 @@ function RemoveHostSection({
|
||||
removeHost,
|
||||
rollbackLocalhostRemoval,
|
||||
setStatus,
|
||||
t,
|
||||
updateSettings,
|
||||
]);
|
||||
|
||||
const confirmationHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host",
|
||||
}),
|
||||
[isLocalDaemon],
|
||||
);
|
||||
|
||||
const removeIcon = useMemo(
|
||||
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
|
||||
[theme.iconSize.sm, theme.colors.destructive],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<SettingsSection
|
||||
title={t("settings.host.daemon.dangerZone")}
|
||||
testID="host-page-remove-host-card"
|
||||
>
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{isLocalDaemon ? "Remove localhost connection" : "Remove host"}
|
||||
{isLocalDaemon
|
||||
? t("settings.host.daemon.remove.localTitle")
|
||||
: t("settings.host.daemon.remove.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isLocalDaemon
|
||||
? "Removes localhost from this device and stops the built-in daemon"
|
||||
: "Removes this host and its saved connections from this device"}
|
||||
? t("settings.host.daemon.remove.localHint")
|
||||
: t("settings.host.daemon.remove.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
@@ -955,22 +999,22 @@ function RemoveHostSection({
|
||||
onPress={handleOpenConfirm}
|
||||
testID="host-page-remove-host-button"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isConfirming ? (
|
||||
<AdaptiveModalSheet
|
||||
header={confirmationHeader}
|
||||
header={removeHostHeader}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-host-confirm-modal"
|
||||
>
|
||||
<Text style={styles.confirmText}>
|
||||
{isLocalDaemon
|
||||
? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected."
|
||||
: `Remove ${host.label}? This will delete its saved connections.`}
|
||||
? t("settings.host.daemon.remove.localConfirmMessage")
|
||||
: t("settings.host.daemon.remove.confirmMessage", { name: host.label })}
|
||||
</Text>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
@@ -980,7 +1024,7 @@ function RemoveHostSection({
|
||||
onPress={handleCancel}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -990,7 +1034,7 @@ function RemoveHostSection({
|
||||
disabled={isRemoving}
|
||||
testID="remove-host-confirm"
|
||||
>
|
||||
Remove
|
||||
{t("settings.host.connections.removeAction")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -32,6 +33,7 @@ function ShortcutSequence({
|
||||
chord: string[] | null;
|
||||
heldModifiers: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayChord = useMemo(() => {
|
||||
const combos = [...(chord ?? [])];
|
||||
if (heldModifiers) {
|
||||
@@ -41,7 +43,7 @@ function ShortcutSequence({
|
||||
}, [chord, heldModifiers]);
|
||||
|
||||
if ((!chord || chord.length === 0) && !heldModifiers) {
|
||||
return <Text style={styles.capturingText}>Press shortcut...</Text>;
|
||||
return <Text style={styles.capturingText}>{t("settings.shortcuts.capturePrompt")}</Text>;
|
||||
}
|
||||
|
||||
return <Shortcut chord={displayChord} />;
|
||||
@@ -119,6 +121,7 @@ function ShortcutRow({
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayChord = useMemo(
|
||||
() => (overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys]),
|
||||
[overrideCombo, row.keys],
|
||||
@@ -127,7 +130,7 @@ function ShortcutRow({
|
||||
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
|
||||
@@ -138,17 +141,19 @@ function ShortcutRow({
|
||||
<>
|
||||
{isCapturing && capturedCombos.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onPress={onDone}>
|
||||
Done
|
||||
{t("settings.shortcuts.actions.done")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onPress={isCapturing ? onCancel : onRebind}>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
{isCapturing
|
||||
? t("settings.shortcuts.actions.cancel")
|
||||
: t("settings.shortcuts.actions.rebind")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
<Text style={styles.resetText}>Reset</Text>
|
||||
<Text style={styles.resetText}>{t("settings.shortcuts.actions.reset")}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
@@ -157,6 +162,7 @@ function ShortcutRow({
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
|
||||
@@ -244,9 +250,9 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
if (isNative) {
|
||||
return (
|
||||
<SettingsSection title="Shortcuts">
|
||||
<SettingsSection title={t("settings.sections.shortcuts")}>
|
||||
<View style={mobileCardStyle}>
|
||||
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop</Text>
|
||||
<Text style={styles.mobileText}>{t("settings.shortcuts.unavailableOnMobile")}</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -254,7 +260,7 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
const resetAllButton = hasOverrides ? (
|
||||
<Button variant="ghost" size="sm" onPress={handleResetAll}>
|
||||
Reset all
|
||||
{t("settings.shortcuts.actions.resetAll")}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
@@ -264,7 +270,7 @@ export function KeyboardShortcutsSection() {
|
||||
return (
|
||||
<SettingsSection
|
||||
key={section.id}
|
||||
title={section.title}
|
||||
title={t(section.titleKey)}
|
||||
trailing={sectionIndex === 0 ? resetAllButton : undefined}
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
|
||||
@@ -100,6 +100,25 @@ vi.mock("lucide-react-native", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
|
||||
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
|
||||
if (key === "settings.providers.statuses.disabled") return "Disabled";
|
||||
if (key === "settings.providers.statuses.available") return "Available";
|
||||
if (key === "settings.providers.statuses.loading") return "Loading";
|
||||
if (key === "settings.providers.statuses.error") return "Error";
|
||||
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
|
||||
if (key === "settings.providers.models.one") return "1 model";
|
||||
if (key === "settings.providers.models.many") return `${values?.count} models`;
|
||||
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
|
||||
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
value,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -29,18 +31,32 @@ interface ProviderStatus {
|
||||
modelCount: number | null;
|
||||
}
|
||||
|
||||
function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
|
||||
if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
|
||||
if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
|
||||
if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
|
||||
function getProviderStatus(
|
||||
status: string,
|
||||
enabled: boolean,
|
||||
modelCount: number,
|
||||
t: TFunction,
|
||||
): ProviderStatus {
|
||||
if (!enabled)
|
||||
return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null };
|
||||
if (status === "loading") {
|
||||
return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null };
|
||||
}
|
||||
if (status === "error") {
|
||||
return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null };
|
||||
}
|
||||
if (status === "ready") {
|
||||
return {
|
||||
tone: "success",
|
||||
label: "Available",
|
||||
label: t("settings.providers.statuses.available"),
|
||||
modelCount: modelCount > 0 ? modelCount : null,
|
||||
};
|
||||
}
|
||||
return { tone: "warning", label: "Not installed", modelCount: null };
|
||||
return {
|
||||
tone: "warning",
|
||||
label: t("settings.providers.statuses.notInstalled"),
|
||||
modelCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
@@ -62,6 +78,7 @@ function ProviderRow({
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
@@ -72,7 +89,7 @@ function ProviderRow({
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry.models?.length ?? 0;
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(def.id);
|
||||
@@ -99,7 +116,7 @@ function ProviderRow({
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${def.label} provider details`}
|
||||
accessibilityLabel={t("settings.providers.providerDetails", { name: def.label })}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
@@ -128,7 +145,7 @@ function ProviderRow({
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={`Enable ${def.label}`}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -150,6 +167,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["t
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const dotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
|
||||
@@ -168,7 +186,9 @@ function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
|
||||
{status.modelCount === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: status.modelCount })}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
@@ -181,6 +201,7 @@ export interface ProvidersSectionProps {
|
||||
}
|
||||
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
@@ -205,14 +226,14 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
await patchConfig({ providers: { [providerId]: { enabled } } });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to update provider",
|
||||
t("settings.providers.updateErrorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setPendingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig],
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
@@ -224,31 +245,31 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
await refresh([entry.id]);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to add provider",
|
||||
t("settings.providers.addErrorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, patchConfig, refresh],
|
||||
[installingProviderId, patchConfig, refresh, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Providers"
|
||||
title={t("settings.providers.title")}
|
||||
testID="host-page-providers-card"
|
||||
style={styles.sectionSpacing}
|
||||
>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to see providers</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.providers.unavailable")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && isLoading ? (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
<Text style={styles.emptyText}>{t("settings.providers.loading")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
||||
@@ -275,7 +296,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
|
||||
{hasServer && isConnected ? (
|
||||
<SettingsSection
|
||||
title="Add provider"
|
||||
title={t("settings.providers.addProvider")}
|
||||
testID="host-page-add-provider-card"
|
||||
style={styles.addProviderSection}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -27,6 +28,7 @@ export function SettingsGroup({
|
||||
style,
|
||||
children,
|
||||
}: SettingsGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const groupStyle = useMemo(() => [styles.group, style], [style]);
|
||||
return (
|
||||
@@ -39,7 +41,7 @@ export function SettingsGroup({
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`About ${title}`}
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
testID={testID ? `${testID}-info` : undefined}
|
||||
hitSlop={8}
|
||||
style={styles.infoButton}
|
||||
|
||||
Reference in New Issue
Block a user