Complete client UI i18n migration

Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
chyendongnhanh338
2026-06-11 14:15:35 +08:00
committed by GitHub
parent 41cf070fd8
commit 08ebe5e7e1
199 changed files with 16426 additions and 1821 deletions
@@ -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" />;
}
@@ -1,3 +1,5 @@
import { i18n } from "@/i18n/i18next";
export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) {
super(message);
@@ -34,19 +36,18 @@ export function getDaemonManagementErrorPresentation(
if (presentationError instanceof DaemonConnectionRegistrationError) {
return {
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
message: i18n.t("desktop.daemon.management.registrationFailed"),
refreshStatus: true,
};
}
if (wasManagingDaemon) {
return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
message: i18n.t("desktop.daemon.management.pausedStopFailed"),
refreshStatus: false,
};
}
return {
message: "Unable to update built-in daemon management.",
message: i18n.t("desktop.daemon.management.updateFailed"),
refreshStatus: false,
};
}
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
type DesktopDaemonStatus,
startDesktopDaemon,
@@ -38,6 +39,7 @@ interface UseBuiltInDaemonManagementResult {
export function useBuiltInDaemonManagement(
input: UseBuiltInDaemonManagementInput,
): UseBuiltInDaemonManagementResult {
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
@@ -50,11 +52,10 @@ export function useBuiltInDaemonManagement(
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
title: t("desktop.daemon.management.pauseTitle"),
message: t("desktop.daemon.management.pauseMessage"),
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getDesktopDaemonLogs,
getDesktopDaemonStatus,
@@ -17,6 +18,7 @@ interface DaemonStatusData {
}
export function useDaemonStatus() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -33,7 +35,7 @@ export function useDaemonStatus() {
});
useDesktopIpcQueryErrorToast({
error: query.error,
message: "Unable to load desktop daemon status.",
message: t("desktop.daemon.loadFailed"),
logLabel: "[DesktopDaemon] Failed to load daemon status",
});
@@ -5,6 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
@@ -54,6 +55,7 @@ describe("useCliInstall", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -89,6 +91,28 @@ describe("useCliInstall", () => {
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
});
it("uses the active language for CLI install errors", async () => {
await i18n.changeLanguage("zh-CN");
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
});
});
describe("useSkillsStatus", () => {
@@ -97,6 +121,7 @@ describe("useSkillsStatus", () => {
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getCliInstallStatus,
getSkillsStatus,
@@ -29,6 +30,7 @@ interface DesktopInstallHookResult {
}
export function useCliInstall(): DesktopInstallHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -42,7 +44,7 @@ export function useCliInstall(): DesktopInstallHookResult {
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check CLI install status.",
message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status",
});
@@ -51,7 +53,7 @@ export function useCliInstall(): DesktopInstallHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install the Paseo CLI.",
message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI",
});
},
@@ -88,6 +90,7 @@ export interface SkillsStatusHookResult {
}
export function useSkillsStatus(): SkillsStatusHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
@@ -101,7 +104,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills status.",
message: t("desktop.integrations.skills.statusFailed"),
logLabel: "[Integrations] Failed to load skills status",
});
@@ -117,7 +120,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to install orchestration skills.",
message: t("desktop.integrations.skills.installFailed"),
logLabel: "[Integrations] Failed to install skills",
});
},
@@ -129,7 +132,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
message: t("desktop.integrations.skills.updateFailed"),
logLabel: "[Integrations] Failed to update skills",
});
},
@@ -141,7 +144,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
message: t("desktop.integrations.skills.uninstallFailed"),
logLabel: "[Integrations] Failed to uninstall skills",
});
},
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { DesktopHostBridge } from "@/desktop/host";
import { i18n } from "@/i18n/i18next";
import {
createDesktopPermissions,
type DesktopPermissionEnvironment,
@@ -180,4 +181,28 @@ describe("desktop-permissions", () => {
expect(result.state).toBe("denied");
});
it("uses the active app language for local status details", async () => {
await i18n.changeLanguage("zh-CN");
try {
const permissions = createDesktopPermissions(
fakeEnvironment({
notification: { permission: "granted" },
navigator: {
permissions: {
query: vi.fn(async () => ({ state: "prompt" })),
},
mediaDevices: { getUserMedia: vi.fn() },
},
}),
);
const snapshot = await permissions.getDesktopPermissionSnapshot();
expect(snapshot.notifications.detail).toBe("系统已允许通知。");
expect(snapshot.microphone.detail).toBe("麦克风权限尚未授予。");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,5 +1,6 @@
import { type DesktopHostBridge, getDesktopHost } from "@/desktop/host";
import { isNative, isWeb } from "@/constants/platform";
import { i18n } from "@/i18n/i18next";
export type DesktopPermissionKind = "notifications" | "microphone";
@@ -97,24 +98,26 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
if (permission === "granted") {
return status({
state: "granted",
detail: "Notifications are allowed by the OS.",
detail: i18n.t("desktop.permissions.notifications.allowed"),
});
}
if (permission === "denied") {
return status({
state: "denied",
detail: "Notifications are denied in system settings.",
detail: i18n.t("desktop.permissions.notifications.denied"),
});
}
if (permission === "default") {
return status({
state: "prompt",
detail: "Notifications have not been granted yet.",
detail: i18n.t("desktop.permissions.notifications.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected notification permission state: ${permission}`,
detail: i18n.t("desktop.permissions.notifications.unexpectedState", {
state: permission,
}),
});
}
@@ -127,7 +130,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification status is only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.webOnly"),
});
}
@@ -138,8 +141,8 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: supported ? "granted" : "unavailable",
detail: supported
? "Desktop notifications are supported."
: "Desktop notifications are not supported on this platform.",
? i18n.t("desktop.permissions.notifications.supported")
: i18n.t("desktop.permissions.notifications.unsupported"),
});
} catch {
// Fall through to web API check
@@ -153,7 +156,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
return status({
state: "unavailable",
detail: "Web Notification API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.notifications.apiUnavailable"),
});
}
@@ -161,7 +164,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.webOnly"),
});
}
@@ -169,7 +172,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator) {
return status({
state: "unavailable",
detail: "Navigator is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.navigatorUnavailable"),
});
}
@@ -180,36 +183,39 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (result?.state === "granted") {
return status({
state: "granted",
detail: "Microphone access is granted.",
detail: i18n.t("desktop.permissions.microphone.granted"),
});
}
if (result?.state === "denied") {
return status({
state: "denied",
detail: "Microphone access is denied in system settings.",
detail: i18n.t("desktop.permissions.microphone.denied"),
});
}
if (result?.state === "prompt") {
return status({
state: "prompt",
detail: "Microphone permission has not been granted yet.",
detail: i18n.t("desktop.permissions.microphone.notGranted"),
});
}
return status({
state: "unknown",
detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`,
detail: i18n.t("desktop.permissions.microphone.unexpectedState", {
state: result?.state ?? "unknown",
}),
});
} catch (error) {
if (isPermissionsQueryRuntimeUnsupported(error)) {
return status({
state: "unknown",
detail:
"Microphone status API is unavailable in this runtime. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.statusApiUnavailable"),
});
}
return status({
state: "unknown",
detail: `Failed to query microphone status: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.queryFailed", {
message: getErrorMessage(error),
}),
});
}
}
@@ -217,13 +223,13 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureUnavailable"),
});
}
return status({
state: "unknown",
detail: "Permission status API is unavailable. Use Request to check access.",
detail: i18n.t("desktop.permissions.microphone.permissionApiUnavailable"),
});
}
@@ -231,7 +237,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.notifications.requestsWebOnly"),
});
}
@@ -246,14 +252,16 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
} catch (error) {
return status({
state: "unknown",
detail: `Failed to request notification permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.notifications.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}
return status({
state: "unavailable",
detail: "Web Notification API requestPermission() is unavailable.",
detail: i18n.t("desktop.permissions.notifications.requestUnavailable"),
});
}
@@ -261,7 +269,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!env.isWeb) {
return status({
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",
detail: i18n.t("desktop.permissions.microphone.requestsWebOnly"),
});
}
@@ -269,7 +277,7 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") {
return status({
state: "unavailable",
detail: "Microphone capture API is unavailable in this environment.",
detail: i18n.t("desktop.permissions.microphone.captureApiUnavailable"),
});
}
@@ -287,18 +295,20 @@ export function createDesktopPermissions(env: DesktopPermissionEnvironment): Des
if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") {
return status({
state: "denied",
detail: "Microphone permission was denied by the user or system.",
detail: i18n.t("desktop.permissions.microphone.requestDenied"),
});
}
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return status({
state: "unavailable",
detail: "No microphone device was found.",
detail: i18n.t("desktop.permissions.microphone.noDevice"),
});
}
return status({
state: "unknown",
detail: `Failed to request microphone permission: ${getErrorMessage(error)}`,
detail: i18n.t("desktop.permissions.microphone.requestFailed", {
message: getErrorMessage(error),
}),
});
}
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
@@ -20,17 +21,8 @@ export interface UseDesktopPermissionsReturn {
sendTestNotification: () => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
state: "unknown" as const,
detail: "Notification status has not been checked yet.",
};
const EMPTY_MICROPHONE_STATUS = {
state: "unknown" as const,
detail: "Microphone status has not been checked yet.",
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
@@ -83,8 +75,14 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
notifications: {
state: "unknown",
detail: t("desktop.permissions.empty.notifications"),
},
microphone: {
state: "unknown",
detail: t("desktop.permissions.empty.microphone"),
},
};
if (kind === "notifications") {
@@ -110,7 +108,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktopApp, refreshPermissions],
[isDesktopApp, refreshPermissions, t],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
@@ -124,22 +122,20 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setTestNotificationError(null);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
title: t("desktop.permissions.testNotification.title"),
body: t("desktop.permissions.testNotification.body"),
});
if (!sent) {
setTestNotificationError(
"Notification was not delivered. Check System Settings > Notifications.",
);
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
}
} catch {
setTestNotificationError("Failed to send notification.");
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
}
}
}, [isDesktopApp]);
}, [isDesktopApp, t]);
useEffect(() => {
if (!isDesktopApp) {
@@ -7,6 +7,7 @@ import {
useDesktopIpcQueryErrorToast,
} from "@/desktop/hooks/desktop-ipc-error";
import type { ReleaseChannel } from "@/hooks/use-settings";
import { i18n } from "@/i18n/i18next";
const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
@@ -53,7 +54,7 @@ export function useDesktopSettings(): {
});
useDesktopIpcQueryErrorToast({
error: loadError,
message: "Unable to load desktop settings.",
message: i18n.t("desktop.settings.loadFailed"),
logLabel: "[DesktopSettings] Failed to load settings",
});
@@ -83,7 +84,7 @@ export function useDesktopSettings(): {
}
reportError({
error: saveError,
message: "Unable to save desktop settings.",
message: i18n.t("desktop.settings.saveFailed"),
logLabel: "[DesktopSettings] Failed to save settings",
});
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
createDesktopAppUpdater,
formatStatusText,
@@ -292,4 +293,32 @@ describe("formatStatusText", () => {
}),
).toBe("Restart now");
});
it("uses the active app language for local status wrappers", async () => {
await i18n.changeLanguage("zh-CN");
try {
expect(
formatStatusText({
status: "checking",
availableUpdate: null,
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("正在检查 app 更新...");
expect(
formatStatusText({
status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: null,
formatVersion,
formatLastCheckedAt,
}),
).toBe("更新已就绪:v1.2.3");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -4,6 +4,7 @@ import type {
DesktopAppUpdateInstallResult,
DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates";
import { i18n } from "@/i18n/i18next";
export type DesktopAppUpdateStatus =
| "idle"
@@ -119,40 +120,44 @@ export function formatStatusText(input: {
} = input;
if (status === "checking") {
return "Checking for app updates...";
return i18n.t("desktop.updates.status.checking");
}
if (status === "installing") {
return "Installing app update...";
return i18n.t("desktop.updates.status.installing");
}
if (status === "up-to-date") {
if (lastCheckedAt != null) {
return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`;
return i18n.t("desktop.updates.status.upToDateWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
});
}
return "Up to date.";
return i18n.t("desktop.updates.status.upToDate");
}
if (status === "pending") {
return "We'll let you know when the update is ready.";
return i18n.t("desktop.updates.status.pending");
}
if (status === "available") {
if (availableUpdate?.latestVersion) {
return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`;
return i18n.t("desktop.updates.status.availableWithVersion", {
version: formatVersion(availableUpdate.latestVersion),
});
}
return "An app update is ready to install.";
return i18n.t("desktop.updates.status.available");
}
if (status === "installed") {
return installMessage ?? "App update installed. Restart required.";
return installMessage ?? i18n.t("desktop.updates.status.installed");
}
if (status === "error") {
return "Failed to update app.";
return i18n.t("desktop.updates.status.failed");
}
return "Update status has not been checked yet.";
return i18n.t("desktop.updates.status.idle");
}
export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater {
@@ -264,7 +269,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
const message = getErrorMessage(error);
deps.reportInstallError?.({
error,
message: "Unable to install the desktop app update.",
message: i18n.t("desktop.updates.installError"),
logLabel: "[DesktopUpdater] Failed to install app update",
});
commit({
@@ -1,6 +1,7 @@
import { isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { isWeb } from "@/constants/platform";
import { i18n } from "@/i18n/i18next";
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean;
@@ -136,7 +137,7 @@ export async function installDesktopAppUpdate({
return {
installed: result.installed === true,
version: toStringOrNull(result.version),
message: toStringOrNull(result.message) ?? "Update completed.",
message: toStringOrNull(result.message) ?? i18n.t("desktop.updates.status.installed"),
};
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveUpdateCalloutDescriptor,
type ResolveUpdateCalloutInput,
@@ -104,4 +105,19 @@ describe("resolveUpdateCalloutDescriptor", () => {
?.dismissalKey,
).toBe("desktop-update:available:1.2.4");
});
it("uses the active app language for local callout chrome", async () => {
await i18n.changeLanguage("zh-CN");
try {
const descriptor = resolveUpdateCalloutDescriptor(input());
expect(descriptor?.title).toBe("有可用更新");
expect(descriptor?.actions).toEqual([
{ role: "changelog", label: "更新内容" },
{ role: "install", label: "安装并重启", variant: "primary", disabled: false },
]);
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,4 +1,5 @@
import type { DesktopAppUpdateStatus } from "@/desktop/updates/use-desktop-app-updater";
import { i18n } from "@/i18n/i18next";
export type UpdateCalloutBody =
| { kind: "available"; versionLabel: string | null }
@@ -57,23 +58,30 @@ export function resolveUpdateCalloutDescriptor(
let title: string;
let body: UpdateCalloutBody;
if (isInstalling) {
title = "Installing update";
title = i18n.t("desktop.updates.callout.installingTitle");
body = { kind: "installing" };
} else if (isError) {
title = "Update failed";
body = { kind: "error", message: input.errorMessage ?? "Something went wrong." };
title = i18n.t("desktop.updates.callout.failedTitle");
body = {
kind: "error",
message: input.errorMessage ?? i18n.t("desktop.updates.callout.genericError"),
};
} else {
title = "Update available";
title = i18n.t("desktop.updates.callout.availableTitle");
body = { kind: "available", versionLabel: formatVersionLabel(latestVersion) };
}
const actions: UpdateCalloutActionDescriptor[] = [{ role: "changelog", label: "What's new" }];
const actions: UpdateCalloutActionDescriptor[] = [
{ role: "changelog", label: i18n.t("desktop.updates.callout.whatsNew") },
];
if (isError) {
actions.push({ role: "retry", label: "Retry", variant: "primary" });
actions.push({ role: "retry", label: i18n.t("common.actions.retry"), variant: "primary" });
} else {
actions.push({
role: "install",
label: isInstalling ? "Installing..." : "Install & restart",
label: isInstalling
? i18n.t("desktop.updates.callout.installingAction")
: i18n.t("desktop.updates.callout.installAndRestart"),
variant: "primary",
disabled: isInstalling,
});
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { SidebarCalloutDescriptionText } from "@/components/sidebar-callout";
import { getIsElectronMac } from "@/constants/platform";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
@@ -12,20 +13,19 @@ import { openExternalUrl } from "@/utils/open-external-url";
const FALLBACK_DOWNLOAD_URL = "https://paseo.sh/download";
function RosettaCalloutDescription() {
function RosettaCalloutDescription({ t }: { t: ReturnType<typeof useTranslation>["t"] }) {
return (
<>
<SidebarCalloutDescriptionText>
You&apos;re running the Intel build of Paseo under Rosetta on Apple Silicon.
</SidebarCalloutDescriptionText>
<SidebarCalloutDescriptionText>
This causes high CPU usage. Download the Apple Silicon build to fix it.
{t("desktop.rosetta.runningIntel")}
</SidebarCalloutDescriptionText>
<SidebarCalloutDescriptionText>{t("desktop.rosetta.highCpu")}</SidebarCalloutDescriptionText>
</>
);
}
export function RosettaCalloutSource() {
const { t } = useTranslation();
const callouts = useSidebarCallouts();
const [runtimeInfo, setRuntimeInfo] = useState<DesktopRuntimeInfo | null>(null);
const isElectronMac = getIsElectronMac();
@@ -67,20 +67,20 @@ export function RosettaCalloutSource() {
return callouts.show({
id: "desktop-rosetta-warning",
priority: 300,
title: "Download the Apple Silicon build",
description: <RosettaCalloutDescription />,
title: t("desktop.rosetta.title"),
description: <RosettaCalloutDescription t={t} />,
variant: "error",
dismissible: false,
actions: [
{
label: "Download",
label: t("desktop.rosetta.download"),
onPress: openDownload,
variant: "primary",
},
],
testID: "rosetta-callout",
});
}, [callouts, isElectronMac, openDownload, runtimeInfo]);
}, [callouts, isElectronMac, openDownload, runtimeInfo, t]);
return null;
}
@@ -1,5 +1,6 @@
import { Gift } from "lucide-react-native";
import { type ReactNode, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useUnistyles } from "react-native-unistyles";
import {
type SidebarCalloutAction,
@@ -18,10 +19,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
function renderBody(body: UpdateCalloutBody): ReactNode {
if (body.kind === "installing") return "Installing and restarting...";
function renderBody(body: UpdateCalloutBody, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
if (body.kind === "installing") return t("desktop.updates.callout.installingDescription");
if (body.kind === "error") return body.message;
return <UpdateAvailableDescription versionLabel={body.versionLabel ?? undefined} />;
return <UpdateAvailableDescription versionLabel={body.versionLabel ?? undefined} t={t} />;
}
function materializeActions(
@@ -37,6 +38,7 @@ function materializeActions(
}
export function UpdateCalloutSource() {
const { t } = useTranslation();
const callouts = useSidebarCallouts();
const { theme } = useUnistyles();
const {
@@ -90,7 +92,7 @@ export function UpdateCalloutSource() {
dismissalKey: descriptor.dismissalKey,
priority: descriptor.priority,
title: descriptor.title,
description: renderBody(descriptor.body),
description: renderBody(descriptor.body, t),
icon: descriptor.showGiftIcon ? (
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : undefined,
@@ -114,21 +116,28 @@ export function UpdateCalloutSource() {
status,
theme.colors.foregroundMuted,
theme.iconSize.sm,
t,
]);
return null;
}
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
function UpdateAvailableDescription({
versionLabel,
t,
}: {
versionLabel?: string;
t: ReturnType<typeof useTranslation>["t"];
}) {
return (
<>
<SidebarCalloutDescriptionText>
{versionLabel
? `${versionLabel} is ready to install.`
: "A new version is ready to install."}
? t("desktop.updates.callout.versionReady", { version: versionLabel })
: t("desktop.updates.callout.newVersionReady")}
</SidebarCalloutDescriptionText>
<SidebarCalloutDescriptionText>
Upgrading the app will stop running agents and close terminal sessions.
{t("desktop.updates.callout.restartWarning")}
</SidebarCalloutDescriptionText>
</>
);