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" />;
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user