Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -10,6 +10,12 @@ export interface DesktopPermissionRowProps {
|
||||
title: string;
|
||||
status: DesktopPermissionStatus | null;
|
||||
isRequesting: boolean;
|
||||
labels: {
|
||||
granted: string;
|
||||
request: string;
|
||||
requesting: string;
|
||||
busyExtraAction: (label: string) => string;
|
||||
};
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
@@ -22,6 +28,7 @@ export function DesktopPermissionRow({
|
||||
title,
|
||||
status,
|
||||
isRequesting,
|
||||
labels,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
@@ -54,7 +61,7 @@ export function DesktopPermissionRow({
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
@@ -63,13 +70,13 @@ export function DesktopPermissionRow({
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
|
||||
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
{isRequesting ? labels.requesting : labels.request}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowDetail ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
@@ -9,6 +10,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
|
||||
export function DesktopPermissionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktopApp,
|
||||
@@ -59,12 +61,22 @@ export function DesktopPermissionsSection() {
|
||||
leftIcon={refreshIcon}
|
||||
onPress={handleRefreshPress}
|
||||
disabled={isBusy}
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
accessibilityLabel={t("settings.permissions.refreshAccessibility")}
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
|
||||
</Button>
|
||||
),
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing],
|
||||
[refreshIcon, handleRefreshPress, isBusy, isRefreshing, t],
|
||||
);
|
||||
|
||||
const permissionLabels = useMemo(
|
||||
() => ({
|
||||
granted: t("settings.permissions.actions.granted"),
|
||||
request: t("settings.permissions.actions.request"),
|
||||
requesting: t("settings.permissions.actions.requesting"),
|
||||
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!isDesktopApp) {
|
||||
@@ -72,25 +84,27 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="Permissions" trailing={refreshButton}>
|
||||
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
title={t("settings.permissions.notifications")}
|
||||
status={snapshot?.notifications ?? null}
|
||||
isRequesting={requestingPermission === "notifications"}
|
||||
onRequest={handleRequestNotifications}
|
||||
extraActionLabel="Test"
|
||||
labels={permissionLabels}
|
||||
extraActionLabel={t("settings.permissions.test")}
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={handleSendTestNotification}
|
||||
/>
|
||||
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
title={t("settings.permissions.microphone")}
|
||||
showBorder
|
||||
status={snapshot?.microphone ?? null}
|
||||
isRequesting={requestingPermission === "microphone"}
|
||||
onRequest={handleRequestMicrophone}
|
||||
labels={permissionLabels}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
@@ -41,6 +42,7 @@ function useKeepRunningAfterQuitToggle(args: {
|
||||
}
|
||||
|
||||
function useDaemonCliStatusModal() {
|
||||
const { t } = useTranslation();
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
@@ -52,12 +54,12 @@ function useDaemonCliStatusModal() {
|
||||
setIsCliStatusModalOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
||||
setCliStatusOutput(t("desktop.daemon.fullStatus.fetchFailed", { message }));
|
||||
setIsCliStatusModalOpen(true);
|
||||
} finally {
|
||||
setIsLoadingCliStatus(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleCopyCliStatus = useCallback(() => {
|
||||
if (!cliStatusOutput) {
|
||||
@@ -65,13 +67,13 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
void Clipboard.setStringAsync(cliStatusOutput)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Status copied to clipboard.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.fullStatus.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon status", error);
|
||||
});
|
||||
}, [cliStatusOutput]);
|
||||
}, [cliStatusOutput, t]);
|
||||
|
||||
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
|
||||
|
||||
@@ -86,6 +88,7 @@ function useDaemonCliStatusModal() {
|
||||
}
|
||||
|
||||
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
const { t } = useTranslation();
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
@@ -96,14 +99,14 @@ function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
||||
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Log path copied.");
|
||||
Alert.alert(t("common.states.copied"), t("desktop.daemon.logs.copied"));
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
Alert.alert(t("common.errors.error"), t("desktop.daemon.logs.copyFailed"));
|
||||
});
|
||||
}, [daemonLogs?.logPath]);
|
||||
}, [daemonLogs?.logPath, t]);
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!daemonLogs) {
|
||||
@@ -124,18 +127,23 @@ interface DaemonLogsModalProps {
|
||||
}
|
||||
|
||||
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_LOGS_HEADER}
|
||||
header={header}
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -155,11 +163,17 @@ function DaemonCliStatusModal({
|
||||
cliStatusOutput,
|
||||
onCopy,
|
||||
}: DaemonCliStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("desktop.daemon.fullStatus.modalTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={DAEMON_STATUS_HEADER}
|
||||
header={header}
|
||||
testID="daemon-cli-status-dialog"
|
||||
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
@@ -169,10 +183,10 @@ function DaemonCliStatusModal({
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>
|
||||
Close
|
||||
{t("common.actions.close")}
|
||||
</Button>
|
||||
<Button size="sm" onPress={onCopy}>
|
||||
Copy
|
||||
{t("common.actions.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -200,6 +214,7 @@ interface DaemonInfoCardProps {
|
||||
}
|
||||
|
||||
function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
daemonStatusStateText,
|
||||
daemonStatusDetailText,
|
||||
@@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.status.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.status.builtInOnly")}</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.management.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.management.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!isDaemonManagementPaused}
|
||||
onValueChange={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
accessibilityLabel="Manage built-in daemon"
|
||||
accessibilityLabel={t("desktop.daemon.management.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
|
||||
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.keepRunning.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.keepRunning.hint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={keepRunningAfterQuit}
|
||||
onValueChange={handleToggleKeepRunningAfterQuit}
|
||||
disabled={isUpdatingKeepRunningAfterQuit}
|
||||
accessibilityLabel="Keep daemon running after quit"
|
||||
accessibilityLabel={t("desktop.daemon.keepRunning.title")}
|
||||
/>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.logs.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? "Log path unavailable"}
|
||||
{daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
{t("desktop.daemon.logs.copyPath")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -275,16 +290,14 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
{t("desktop.daemon.logs.open")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("desktop.daemon.fullStatus.title")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("desktop.daemon.fullStatus.hint")}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -293,7 +306,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
onPress={handleRunCliStatus}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
{isLoadingCliStatus ? t("common.states.loading") : t("desktop.daemon.fullStatus.view")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -301,6 +314,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
}
|
||||
|
||||
export function LocalDaemonSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const appVersion = resolveAppVersion();
|
||||
@@ -318,8 +332,13 @@ export function LocalDaemonSection() {
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
|
||||
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
|
||||
statusError ??
|
||||
(daemonStatus?.status === "running"
|
||||
? t("desktop.daemon.status.running")
|
||||
: t("desktop.daemon.status.notRunning"));
|
||||
const daemonStatusDetailText = t("desktop.daemon.status.pid", {
|
||||
pid: daemonStatus?.pid ? daemonStatus.pid : "—",
|
||||
});
|
||||
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
|
||||
|
||||
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
|
||||
@@ -382,12 +401,12 @@ export function LocalDaemonSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenAdvancedSettings}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
|
||||
>
|
||||
Advanced settings
|
||||
{t("desktop.daemon.advancedSettings")}
|
||||
</Button>
|
||||
),
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings],
|
||||
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -396,7 +415,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Daemon"
|
||||
title={t("desktop.daemon.title")}
|
||||
trailing={advancedSettingsButton}
|
||||
testID="host-page-daemon-lifecycle-card"
|
||||
>
|
||||
@@ -427,11 +446,7 @@ export function LocalDaemonSection() {
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
{
|
||||
"App and daemon versions don't match. Update both to the same version for the best experience."
|
||||
}
|
||||
</Text>
|
||||
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
@@ -513,5 +528,3 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
|
||||
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
|
||||
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
|
||||
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -8,16 +10,34 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
|
||||
add: "settings.integrations.operations.add",
|
||||
update: "settings.integrations.operations.update",
|
||||
delete: "settings.integrations.operations.delete",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const {
|
||||
@@ -56,20 +76,30 @@ export function IntegrationsSection() {
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.integrations.skills.updateTitle"),
|
||||
message:
|
||||
ops.length > 0
|
||||
? formatUpdateMessage(ops, t)
|
||||
: t("settings.integrations.skills.updateFallback"),
|
||||
confirmLabel: t("settings.integrations.actions.update"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, updateSkills]);
|
||||
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
title: t("settings.integrations.skills.uninstallTitle"),
|
||||
message: t("settings.integrations.skills.uninstallMessage"),
|
||||
confirmLabel: t("settings.integrations.actions.uninstall"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
}, [isSkillsWorking, t, uninstallSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -94,9 +124,9 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenCliDocs}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openCli")}
|
||||
>
|
||||
CLI docs
|
||||
{t("settings.integrations.docs.cli")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -105,13 +135,13 @@ export function IntegrationsSection() {
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenSkillsDocs}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
accessibilityLabel={t("settings.integrations.docs.openSkills")}
|
||||
>
|
||||
Skills docs
|
||||
{t("settings.integrations.docs.skills")}
|
||||
</Button>
|
||||
</View>
|
||||
),
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
|
||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
|
||||
);
|
||||
|
||||
if (!showSection) {
|
||||
@@ -121,20 +151,24 @@ export function IntegrationsSection() {
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.integrations.commandLine.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.integrations.commandLine.description")}
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
@@ -143,7 +177,9 @@ export function IntegrationsSection() {
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
{isInstallingCli
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
@@ -151,12 +187,12 @@ export function IntegrationsSection() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
? t("settings.integrations.skills.updateAvailable")
|
||||
: t("settings.integrations.skills.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<SkillsActions
|
||||
@@ -181,6 +217,7 @@ interface SkillsActionsProps {
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
@@ -188,10 +225,10 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.working")
|
||||
: t("settings.integrations.actions.update")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
{t("settings.integrations.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
{isWorking
|
||||
? t("settings.integrations.actions.installing")
|
||||
: t("settings.integrations.actions.install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
|
||||
@@ -8,12 +10,17 @@ export interface PairDeviceModalProps {
|
||||
}
|
||||
|
||||
const SNAP_POINTS: string[] = ["82%", "94%"];
|
||||
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
|
||||
|
||||
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.integrations.pairDevices.rowTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={PAIR_DEVICE_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
@@ -21,18 +22,21 @@ function resolvePairingViewState(args: {
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
data: { url?: string | null; relayEnabled?: boolean } | undefined;
|
||||
labels: {
|
||||
failedToLoadOffer: string;
|
||||
relayDisabled: string;
|
||||
unavailable: string;
|
||||
};
|
||||
}): PairingViewState {
|
||||
if (args.isPending) return { tag: "loading" };
|
||||
if (args.isError) {
|
||||
const message =
|
||||
args.error instanceof Error ? args.error.message : "Failed to load pairing offer.";
|
||||
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
|
||||
return { tag: "error", message };
|
||||
}
|
||||
if (!args.data?.url) {
|
||||
const message =
|
||||
args.data?.relayEnabled === false
|
||||
? "Relay is not enabled. Enable relay to pair a device."
|
||||
: "Pairing offer unavailable.";
|
||||
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
|
||||
return { tag: "unavailable", message };
|
||||
}
|
||||
return { tag: "ready", url: args.data.url };
|
||||
@@ -40,6 +44,7 @@ function resolvePairingViewState(args: {
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -96,6 +101,17 @@ export function PairDeviceSection() {
|
||||
),
|
||||
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
|
||||
);
|
||||
const bodyLabels = useMemo(
|
||||
() => ({
|
||||
loadingOffer: t("pairing.device.loadingOffer"),
|
||||
hint: t("pairing.device.hint"),
|
||||
qrUnavailable: t("pairing.device.qrUnavailable"),
|
||||
retry: t("pairing.device.retry"),
|
||||
copy: t("pairing.device.copy"),
|
||||
copied: t("pairing.device.copied"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
@@ -104,6 +120,11 @@ export function PairDeviceSection() {
|
||||
isError: pairingQuery.isError,
|
||||
error: pairingQuery.error,
|
||||
data: pairingQuery.data,
|
||||
labels: {
|
||||
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
|
||||
relayDisabled: t("pairing.device.relayDisabled"),
|
||||
unavailable: t("pairing.device.unavailable"),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -119,6 +140,7 @@ export function PairDeviceSection() {
|
||||
copied={copied}
|
||||
handleRefetch={handleRefetch}
|
||||
handleCopyPress={handleCopyPress}
|
||||
labels={bodyLabels}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -135,6 +157,14 @@ interface PairDeviceBodyProps {
|
||||
copied: boolean;
|
||||
handleRefetch: () => void;
|
||||
handleCopyPress: () => void;
|
||||
labels: {
|
||||
loadingOffer: string;
|
||||
hint: string;
|
||||
qrUnavailable: string;
|
||||
retry: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
};
|
||||
}
|
||||
|
||||
function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
@@ -148,13 +178,14 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
copied,
|
||||
handleRefetch,
|
||||
handleCopyPress,
|
||||
labels,
|
||||
} = props;
|
||||
|
||||
if (viewState.tag === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hint}>Loading pairing offer…</Text>
|
||||
<Text style={styles.hint}>{labels.loadingOffer}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{viewState.message}</Text>
|
||||
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
|
||||
Retry
|
||||
{labels.retry}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>
|
||||
Scan this QR code with Paseo on your phone, or copy the link below.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{labels.hint}</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
<PairDeviceQrContent qrImageSource={qrImageSource} qrQuery={qrQuery} />
|
||||
<PairDeviceQrContent
|
||||
qrImageSource={qrImageSource}
|
||||
qrQuery={qrQuery}
|
||||
unavailableLabel={labels.qrUnavailable}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
@@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
/>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
function PairDeviceQrContent(props: {
|
||||
qrImageSource: { uri: string } | null;
|
||||
qrQuery: { isError: boolean };
|
||||
unavailableLabel: string;
|
||||
}) {
|
||||
if (props.qrImageSource) {
|
||||
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
|
||||
}
|
||||
if (props.qrQuery.isError) {
|
||||
return <Text style={styles.hint}>QR code unavailable.</Text>;
|
||||
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
|
||||
}
|
||||
return <ActivityIndicator size="small" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user