From 08ebe5e7e18792685855c6ccbf296c70ee021a74 Mon Sep 17 00:00:00 2001 From: chyendongnhanh338 Date: Thu, 11 Jun 2026 14:15:35 +0800 Subject: [PATCH] Complete client UI i18n migration Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage. --- docs/i18n.md | 79 + package-lock.json | 103 +- packages/app/e2e/helpers/project-settings.ts | 4 +- packages/app/e2e/settings-i18n.spec.ts | 23 + packages/app/package.json | 3 + packages/app/src/agent-stream/view.tsx | 35 +- packages/app/src/app/_layout.tsx | 17 +- packages/app/src/app/pair-scan.tsx | 30 +- .../app/src/assistant-file-links/resolver.ts | 3 +- .../src/assistant-file-links/use-file-link.ts | 13 +- .../app/src/client-slash-commands/index.ts | 3 + .../src/components/adaptive-modal-sheet.tsx | 26 +- .../src/components/add-host-method-modal.tsx | 33 +- .../app/src/components/add-host-modal.tsx | 156 +- packages/app/src/components/agent-list.tsx | 102 +- .../src/components/archived-agent-callout.tsx | 6 +- .../components/attachment-lightbox.test.tsx | 11 + .../src/components/attachment-lightbox.tsx | 8 +- .../app/src/components/branch-switcher.tsx | 12 +- .../src/components/browser-pane.electron.tsx | 132 +- packages/app/src/components/browser-pane.tsx | 6 +- .../app/src/components/browser-pane.web.tsx | 10 +- .../components/combined-model-selector.tsx | 50 +- .../app/src/components/command-center.tsx | 17 +- .../src/components/context-window-meter.tsx | 25 +- .../app/src/components/dictation-controls.tsx | 25 +- packages/app/src/components/diff-viewer.tsx | 7 +- .../app/src/components/download-toast.tsx | 13 +- .../app/src/components/explorer-sidebar.tsx | 6 +- .../app/src/components/file-drop-zone.tsx | 4 +- .../app/src/components/file-explorer-pane.tsx | 53 +- packages/app/src/components/file-pane.tsx | 18 +- .../src/components/headers/back-header.tsx | 4 +- .../src/components/headers/menu-header.tsx | 6 +- .../src/components/highlighted-code-block.tsx | 4 +- .../import-session-sheet-view-model.ts | 21 +- .../src/components/import-session-sheet.tsx | 52 +- .../components/keyboard-shortcuts-dialog.tsx | 14 +- packages/app/src/components/left-sidebar.tsx | 65 +- .../message-compaction-label.test.ts | 10 + .../components/message-compaction-label.ts | 16 +- packages/app/src/components/message.tsx | 63 +- .../app/src/components/pair-link-modal.tsx | 38 +- packages/app/src/components/plan-card.tsx | 7 +- .../src/components/project-picker-modal.tsx | 8 +- .../src/components/provider-catalog-list.tsx | 22 +- .../components/provider-diagnostic-sheet.tsx | 88 +- .../src/components/question-form-card-core.ts | 7 +- .../app/src/components/question-form-card.tsx | 26 +- .../app/src/components/quitting-overlay.tsx | 6 +- .../src/components/realtime-voice-overlay.tsx | 8 +- packages/app/src/components/rename-modal.tsx | 17 +- .../app/src/components/rewind/rewind-menu.tsx | 20 +- .../rewind/use-rewind-agent-mutation.ts | 6 +- .../rewind/use-rewind-capabilities.test.ts | 17 + .../rewind/use-rewind-capabilities.ts | 23 +- .../app/src/components/sidebar-callout.tsx | 4 +- .../src/components/sidebar-workspace-list.tsx | 222 +- .../sidebar/sidebar-status-list.tsx | 8 +- .../app/src/components/split-container.tsx | 5 +- packages/app/src/components/terminal-pane.tsx | 4 +- packages/app/src/components/toast-host.tsx | 6 +- .../app/src/components/tool-call-details.tsx | 29 +- .../app/src/components/ui/autocomplete.tsx | 12 +- packages/app/src/components/ui/combobox.tsx | 19 +- .../app/src/components/ui/context-menu.tsx | 4 +- .../app/src/components/ui/dropdown-menu.tsx | 4 +- .../app/src/components/welcome-screen.tsx | 18 +- .../src/components/workspace-hover-card.tsx | 7 +- .../src/components/workspace-setup-dialog.tsx | 31 +- .../worktree-setup-callout-policy.ts | 8 +- packages/app/src/composer/actions.ts | 7 +- .../app/src/composer/agent-controls/index.tsx | 43 +- .../composer/agent-controls/mode-control.tsx | 12 +- .../src/composer/agent-controls/utils.test.ts | 12 +- .../app/src/composer/agent-controls/utils.ts | 28 +- .../src/composer/attachments/workspace.tsx | 16 +- .../app/src/composer/draft/create-flow.ts | 15 +- .../app/src/composer/draft/import-pill.tsx | 6 +- .../app/src/composer/draft/workspace-tab.tsx | 14 +- packages/app/src/composer/index.tsx | 139 +- packages/app/src/composer/input/input.tsx | 112 +- .../app/src/composer/input/labels.test.ts | 137 ++ packages/app/src/composer/input/labels.ts | 54 + packages/app/src/composer/submit.ts | 9 +- packages/app/src/contexts/session-context.tsx | 12 +- .../components/desktop-permission-row.tsx | 13 +- .../desktop-permissions-section.tsx | 28 +- .../components/desktop-updates-section.tsx | 99 +- .../components/integrations-section.tsx | 93 +- .../desktop/components/pair-device-modal.tsx | 11 +- .../components/pair-device-section.tsx | 58 +- .../desktop/daemon/daemon-management-error.ts | 9 +- .../hooks/use-built-in-daemon-management.ts | 11 +- .../src/desktop/hooks/use-daemon-status.ts | 4 +- .../desktop/hooks/use-install-status.test.tsx | 25 + .../src/desktop/hooks/use-install-status.ts | 15 +- .../permissions/desktop-permissions.test.ts | 25 + .../permissions/desktop-permissions.ts | 64 +- .../permissions/use-desktop-permissions.ts | 36 +- .../src/desktop/settings/desktop-settings.ts | 5 +- .../updates/desktop-app-updater.test.ts | 29 + .../desktop/updates/desktop-app-updater.ts | 27 +- .../src/desktop/updates/desktop-updates.ts | 3 +- .../updates/resolve-update-callout.test.ts | 16 + .../desktop/updates/resolve-update-callout.ts | 22 +- .../updates/rosetta-callout-source.tsx | 18 +- .../desktop/updates/update-callout-source.tsx | 25 +- .../src/dictation/dictation-stream-sender.ts | 5 +- packages/app/src/git/actions-split-button.tsx | 11 +- packages/app/src/git/actions-store.ts | 3 +- packages/app/src/git/diff-pane.tsx | 101 +- packages/app/src/git/policy.test.ts | 30 +- packages/app/src/git/policy.ts | 161 +- packages/app/src/git/pr-pane.tsx | 27 +- packages/app/src/git/use-actions.ts | 386 +++- .../app/src/git/use-github-search-query.ts | 15 +- packages/app/src/git/use-pr-status-query.ts | 7 +- packages/app/src/git/use-status-query.ts | 7 +- .../app/src/git/worktree-archive-warning.ts | 67 +- .../app/src/hooks/image-attachment-picker.ts | 10 +- .../app/src/hooks/use-agent-autocomplete.ts | 29 +- .../app/src/hooks/use-agent-commands-query.ts | 4 +- packages/app/src/hooks/use-agent-history.ts | 4 +- .../app/src/hooks/use-agent-initialization.ts | 32 +- packages/app/src/hooks/use-archive-agent.ts | 4 +- packages/app/src/hooks/use-branch-switcher.ts | 32 +- .../hooks/use-clear-workspace-attention.ts | 3 +- packages/app/src/hooks/use-command-center.ts | 27 +- packages/app/src/hooks/use-daemon-config.ts | 4 +- .../app/src/hooks/use-dictation.shared.ts | 4 +- packages/app/src/hooks/use-dictation.ts | 12 +- .../app/src/hooks/use-draft-agent-features.ts | 4 +- .../src/hooks/use-file-explorer-actions.ts | 27 +- .../src/hooks/use-image-attachment-picker.ts | 12 +- .../src/hooks/use-load-older-agent-history.ts | 12 +- packages/app/src/hooks/use-pr-pane-data.ts | 21 +- .../app/src/hooks/use-project-icon-query.ts | 4 +- .../app/src/hooks/use-providers-snapshot.ts | 4 +- packages/app/src/hooks/use-settings/index.ts | 5 + .../src/hooks/use-settings/storage.test.ts | 33 + .../app/src/hooks/use-settings/storage.ts | 7 + packages/app/src/i18n/i18next.ts | 35 + packages/app/src/i18n/init.test.ts | 14 + packages/app/src/i18n/init.ts | 10 + packages/app/src/i18n/locales.test.ts | 68 + packages/app/src/i18n/locales.ts | 57 + packages/app/src/i18n/provider.tsx | 30 + packages/app/src/i18n/resources.test.ts | 590 ++++++ packages/app/src/i18n/resources/ar.ts | 1801 ++++++++++++++++ packages/app/src/i18n/resources/en.ts | 1816 ++++++++++++++++ packages/app/src/i18n/resources/es.ts | 1842 ++++++++++++++++ packages/app/src/i18n/resources/fr.ts | 1850 +++++++++++++++++ packages/app/src/i18n/resources/ru.ts | 1837 ++++++++++++++++ packages/app/src/i18n/resources/zh-CN.ts | 1779 ++++++++++++++++ packages/app/src/i18n/sync-language.test.ts | 32 + packages/app/src/i18n/sync-language.ts | 26 + .../src/keyboard/keyboard-shortcuts.test.ts | 14 + .../app/src/keyboard/keyboard-shortcuts.ts | 59 + .../panels/agent-panel-descriptor.test.tsx | 24 + packages/app/src/panels/agent-panel.tsx | 52 +- .../app/src/panels/draft-panel-descriptor.ts | 10 +- packages/app/src/panels/file-panel.tsx | 4 +- packages/app/src/panels/setup-panel.tsx | 55 +- packages/app/src/panels/terminal-panel.tsx | 8 +- .../provider-selection.test.ts | 51 + .../provider-selection/provider-selection.ts | 37 +- packages/app/src/review/surface.test.tsx | 3 + packages/app/src/review/surface.tsx | 22 +- .../app/src/screens/new-workspace-screen.tsx | 79 +- .../app/src/screens/open-project-screen.tsx | 18 +- .../src/screens/project-settings-screen.tsx | 239 ++- .../app/src/screens/projects-screen.test.tsx | 13 + packages/app/src/screens/projects-screen.tsx | 13 +- packages/app/src/screens/sessions-screen.tsx | 12 +- packages/app/src/screens/settings-screen.tsx | 293 ++- .../appearance/appearance-preview.tsx | 4 +- .../appearance/appearance-section.tsx | 93 +- .../app/src/screens/settings/host-page.tsx | 226 +- .../settings/keyboard-shortcuts-section.tsx | 24 +- .../settings/providers-section.test.tsx | 19 + .../screens/settings/providers-section.tsx | 57 +- .../src/screens/settings/settings-group.tsx | 4 +- .../app/src/screens/startup-splash-screen.tsx | 19 +- .../terminals/use-workspace-terminals.ts | 8 +- .../workspace/use-workspace-tab-rename.tsx | 14 +- .../screens/workspace/workspace-bulk-close.ts | 60 +- .../workspace/workspace-desktop-tabs-row.tsx | 86 +- .../workspace-open-in-editor-button.tsx | 21 +- .../workspace/workspace-route-state-views.tsx | 28 +- .../screens/workspace/workspace-screen.tsx | 307 ++- .../workspace-scripts-button.test.tsx | 3 + .../workspace/workspace-scripts-button.tsx | 44 +- .../screens/workspace/workspace-tab-menu.ts | 65 +- .../workspace/workspace-tab-presentation.tsx | 6 +- packages/app/src/stores/download-store.ts | 13 +- packages/app/src/subagents/track.tsx | 10 +- .../runtime/terminal-stream-controller.ts | 4 +- packages/app/src/utils/open-service-url.ts | 11 +- 199 files changed, 16426 insertions(+), 1821 deletions(-) create mode 100644 docs/i18n.md create mode 100644 packages/app/e2e/settings-i18n.spec.ts create mode 100644 packages/app/src/composer/input/labels.test.ts create mode 100644 packages/app/src/composer/input/labels.ts create mode 100644 packages/app/src/i18n/i18next.ts create mode 100644 packages/app/src/i18n/init.test.ts create mode 100644 packages/app/src/i18n/init.ts create mode 100644 packages/app/src/i18n/locales.test.ts create mode 100644 packages/app/src/i18n/locales.ts create mode 100644 packages/app/src/i18n/provider.tsx create mode 100644 packages/app/src/i18n/resources.test.ts create mode 100644 packages/app/src/i18n/resources/ar.ts create mode 100644 packages/app/src/i18n/resources/en.ts create mode 100644 packages/app/src/i18n/resources/es.ts create mode 100644 packages/app/src/i18n/resources/fr.ts create mode 100644 packages/app/src/i18n/resources/ru.ts create mode 100644 packages/app/src/i18n/resources/zh-CN.ts create mode 100644 packages/app/src/i18n/sync-language.test.ts create mode 100644 packages/app/src/i18n/sync-language.ts diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 000000000..9b3a69609 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,79 @@ +# I18n + +Paseo client UI translations live in `packages/app/src/i18n`. + +## Supported Locales + +- `en` +- `ar` +- `es` +- `fr` +- `ru` +- `zh-CN` + +The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. + +## Translation Scope + +Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers. + +Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text. + +## Adding Copy + +English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`. + +For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as ` diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 0fd56320b..f50e735b0 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -11,6 +11,8 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; @@ -33,8 +35,18 @@ interface AgentListProps { showAttentionIndicator?: boolean; } +type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older"; + +const DATE_SECTION_ORDER = [ + "today", + "yesterday", + "thisWeek", + "thisMonth", + "older", +] as const satisfies readonly DateSectionKey[]; + type FlatListItem = - | { type: "header"; key: string; title: string } + | { type: "header"; key: string; section: DateSectionKey } | { type: "agent"; key: string; agent: AggregatedAgent }; function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { @@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) { }); } -function deriveDateSectionLabel(lastActivityAt: Date): string { +function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); @@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string { ); if (activityStart.getTime() >= todayStart.getTime()) { - return "Today"; + return "today"; } if (activityStart.getTime() >= yesterdayStart.getTime()) { - return "Yesterday"; + return "yesterday"; } const diffTime = todayStart.getTime() - activityStart.getTime(); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays <= 7) { - return "This week"; + return "thisWeek"; } if (diffDays <= 30) { - return "This month"; + return "thisMonth"; } - return "Older"; + return "older"; } -function formatStatusLabel(status: AggregatedAgent["status"]): string { +function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string { + switch (section) { + case "today": + return t("agentList.dateSections.today"); + case "yesterday": + return t("agentList.dateSections.yesterday"); + case "thisWeek": + return t("agentList.dateSections.thisWeek"); + case "thisMonth": + return t("agentList.dateSections.thisMonth"); + case "older": + return t("agentList.dateSections.older"); + } +} + +function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string { switch (status) { case "initializing": - return "Starting"; + return t("agentList.status.initializing"); case "idle": - return "Idle"; + return t("agentList.status.idle"); case "running": - return "Running"; + return t("agentList.status.running"); case "error": - return "Error"; + return t("agentList.status.error"); case "closed": - return "Closed"; + return t("agentList.status.closed"); default: return status; } @@ -188,12 +215,14 @@ function SessionRow({ onLongPress: (agent: AggregatedAgent) => void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; - const statusLabel = formatStatusLabel(agent.status); + const statusLabel = formatStatusLabel(t, agent.status); const projectPath = shortenPath(agent.cwd); const ProviderIcon = getProviderIcon(agent.provider); + const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const pressableStyle = useCallback( ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -231,14 +260,19 @@ function SessionRow({ - {agent.title || "New session"} + {agent.title || t("agentList.fallbackTitle")} - {agent.archivedAt ? : null} - {(agent.pendingPermissionCount ?? 0) > 0 ? ( - + {agent.archivedAt ? ( + + ) : null} + {pendingPermissionCount > 0 ? ( + ) : null} {!isMobile && showAttentionIndicator && agent.requiresAttention ? ( - + ) : null} {isMobile && ( @@ -272,7 +306,7 @@ function SessionRow({ )} {isMobile && showAttentionIndicator && agent.requiresAttention ? ( - + ) : null} @@ -289,6 +323,7 @@ export function AgentList({ showAttentionIndicator = true, }: AgentListProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); const isMobile = useIsCompactFormFactor(); @@ -354,22 +389,21 @@ export function AgentList({ }, [actionAgent, actionClient, archiveAgent]); const flatItems = useMemo((): FlatListItem[] => { - const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; - const buckets = new Map(); + const buckets = new Map(); for (const agent of agents) { - const label = deriveDateSectionLabel(agent.lastActivityAt); - const existing = buckets.get(label) ?? []; + const section = deriveDateSectionKey(agent.lastActivityAt); + const existing = buckets.get(section) ?? []; existing.push(agent); - buckets.set(label, existing); + buckets.set(section, existing); } const result: FlatListItem[] = []; - for (const label of order) { - const data = buckets.get(label); + for (const section of DATE_SECTION_ORDER) { + const data = buckets.get(section); if (!data || data.length === 0) { continue; } - result.push({ type: "header", key: `header:${label}`, title: label }); + result.push({ type: "header", key: `header:${section}`, section }); for (const agent of data) { result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent }); } @@ -382,7 +416,7 @@ export function AgentList({ if (item.type === "header") { return ( - {item.title} + {formatDateSectionLabel(t, item.section)} ); } @@ -397,7 +431,7 @@ export function AgentList({ /> ); }, - [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator], + [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t], ); const keyExtractor = useCallback((item: FlatListItem) => item.key, []); @@ -454,8 +488,8 @@ export function AgentList({ {isActionDaemonUnavailable - ? "Host offline" - : "This agent is still running. Archiving it will stop the agent."} + ? t("agentList.archiveSheet.hostOffline") + : t("agentList.archiveSheet.runningAgent")} - Cancel + {t("common.actions.cancel")} - Archive + {t("agentList.archiveSheet.archive")} diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 752529e7f..4bfb5d30b 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { View, Text } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps { } export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) { + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - This agent is archived + {t("agentPanel.archived.callout")} diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 3fe044c72..8948cf9db 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({ isNative: false, })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + "message.attachments.closeImage": "Close image", + "message.attachments.dismissImage": "Dismiss image", + "message.attachments.imageLoadFailed": "Couldn't load image", + })[key] ?? key, + }), +})); + vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index fe1a37323..b44e76928 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Image as ExpoImage } from "expo-image"; import { X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { isWeb } from "@/constants/platform"; @@ -15,6 +16,7 @@ interface AttachmentLightboxProps { export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const url = useAttachmentPreviewUrl(metadata); const [errored, setErrored] = useState(false); @@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp {hasError ? ( - Couldn't load image + {t("message.attachments.imageLoadFailed")} ) : ( (null); const client = useHostRuntimeClient(serverId); @@ -91,7 +93,7 @@ export function BranchSwitcher({ onPress={handleOpen} style={triggerStyle} accessibilityRole="button" - accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`} + accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })} > {titleContent} {!isCompact ? : null} @@ -101,10 +103,10 @@ export function BranchSwitcher({ value={currentBranchName} onSelect={handleBranchSelect} searchable - placeholder="Switch branch..." - searchPlaceholder="Filter branches..." - emptyText="No branches found." - title="Switch branch" + placeholder={t("branchSwitcher.placeholder")} + searchPlaceholder={t("branchSwitcher.searchPlaceholder")} + emptyText={t("branchSwitcher.empty")} + title={t("branchSwitcher.title")} open={isOpen} onOpenChange={setIsOpen} anchorRef={anchorRef} diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index fe4a2bb2b..99ce5fd4a 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -10,6 +10,7 @@ import { import { Pressable, Text, TextInput, View } from "react-native"; import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { buildWorkspaceAttachmentScopeKey, useWorkspaceAttachments, @@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value; } -function getWebviewLoadErrorMessage(event: Event): string | null { +function getWebviewLoadErrorMessage(event: Event, failedToLoadLabel: string): string | null { const details = event as Event & { errorCode?: unknown; errorDescription?: unknown; @@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null { const description = typeof details.errorDescription === "string" && details.errorDescription.trim() ? details.errorDescription.trim() - : "Failed to load page"; + : failedToLoadLabel; const url = typeof details.validatedURL === "string" && details.validatedURL.trim() ? details.validatedURL.trim() @@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null { return url ? `${description}: ${url}` : description; } -function getLoadUrlRejectionMessage(error: unknown): string | null { +function getLoadUrlRejectionMessage(error: unknown, failedToLoadLabel: string): string | null { if (error instanceof Error && error.message.trim()) { if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) { return null; @@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null { } return error.trim(); } - return "Failed to load page"; + return failedToLoadLabel; } -function getUnsafeNavigationMessage(url: string): string | null { +function getUnsafeNavigationMessage( + url: string, + labels: { invalidUrl: string; unsupportedProtocol: (protocol: string) => string }, +): string | null { try { const parsed = new URL(url); if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") { return null; } - return `Blocked unsupported browser URL: ${parsed.protocol}`; + return labels.unsupportedProtocol(parsed.protocol); } catch { - return "Invalid browser URL"; + return labels.invalidUrl; } } @@ -283,6 +287,7 @@ export function BrowserPane({ onFocusPane?: () => void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null); const updateBrowser = useBrowserStore((state) => state.updateBrowser); const webviewRef = useRef(null); @@ -327,6 +332,17 @@ export function BrowserPane({ () => [styles.metaError, { color: theme.colors.palette.red[500] }], [theme.colors.palette.red], ); + const browserErrorLabels = useMemo( + () => ({ + failedToLoad: t("workspace.browser.errors.failedToLoad"), + invalidUrl: t("workspace.browser.errors.invalidUrl"), + unsupportedProtocol: (protocol: string) => + t("workspace.browser.errors.unsupportedProtocol", { protocol }), + }), + [t], + ); + const browserErrorLabelsRef = useRef(browserErrorLabels); + browserErrorLabelsRef.current = browserErrorLabels; useEffect(() => { const nextUrl = browser?.url ?? "https://example.com"; @@ -384,7 +400,10 @@ export function BrowserPane({ host.replaceChildren(); - const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current); + const initialUnsafeNavigationMessage = getUnsafeNavigationMessage( + initialUrlRef.current, + browserErrorLabelsRef.current, + ); const webview = document.createElement("webview") as ElectronWebview; webviewRef.current = webview; webview.setAttribute("partition", `persist:paseo-browser-${browserId}`); @@ -459,7 +478,7 @@ export function BrowserPane({ updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null }); }; const handleLoadFailed = (event: Event) => { - const message = getWebviewLoadErrorMessage(event); + const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad); if (!message) { return; } @@ -519,43 +538,46 @@ export function BrowserPane({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [browserId, onFocusPane]); - const navigate = useCallback((nextUrl: string) => { - const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); - const webview = webviewRef.current; - const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl); - const previousUrl = browserRef.current?.url ?? initialUrlRef.current; - pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl; - updateBrowserRef.current(browserIdRef.current, { - url: normalizedUrl, - isLoading: unsafeNavigationMessage === null, - ...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}), - lastError: null, - }); - setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl)); - if (unsafeNavigationMessage) { + const navigate = useCallback( + (nextUrl: string) => { + const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); + const webview = webviewRef.current; + const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels); + const previousUrl = browserRef.current?.url ?? initialUrlRef.current; + pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl; updateBrowserRef.current(browserIdRef.current, { - isLoading: false, - lastError: unsafeNavigationMessage, + url: normalizedUrl, + isLoading: unsafeNavigationMessage === null, + ...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}), + lastError: null, }); - return; - } - if (webview?.loadURL) { - void webview.loadURL(normalizedUrl).catch((error: unknown) => { - const message = getLoadUrlRejectionMessage(error); - if (!message) { - return; - } + setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl)); + if (unsafeNavigationMessage) { updateBrowserRef.current(browserIdRef.current, { isLoading: false, - lastError: message, + lastError: unsafeNavigationMessage, }); - }); - return; - } - if (webview) { - webview.setAttribute("src", normalizedUrl); - } - }, []); + return; + } + if (webview?.loadURL) { + void webview.loadURL(normalizedUrl).catch((error: unknown) => { + const message = getLoadUrlRejectionMessage(error, browserErrorLabels.failedToLoad); + if (!message) { + return; + } + updateBrowserRef.current(browserIdRef.current, { + isLoading: false, + lastError: message, + }); + }); + return; + } + if (webview) { + webview.setAttribute("src", normalizedUrl); + } + }, + [browserErrorLabels], + ); const handleBack = useCallback(() => { webviewRef.current?.goBack?.(); @@ -929,10 +951,8 @@ export function BrowserPane({ if (!isElectronRuntime()) { return ( - Browser is desktop-only - - Open this workspace in Electron to use the built-in browser. - + {t("workspace.browser.unavailable.title")} + {t("workspace.browser.unavailable.subtitle")} ); } @@ -943,7 +963,7 @@ export function BrowserPane({ @@ -970,13 +994,13 @@ export function BrowserPane({ @@ -996,7 +1020,11 @@ export function BrowserPane({ diff --git a/packages/app/src/components/browser-pane.tsx b/packages/app/src/components/browser-pane.tsx index 3afe064a9..67887b674 100644 --- a/packages/app/src/components/browser-pane.tsx +++ b/packages/app/src/components/browser-pane.tsx @@ -1,6 +1,7 @@ import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; interface BrowserPaneProps { browserId: string; @@ -13,6 +14,7 @@ interface BrowserPaneProps { export function BrowserPane({ browserId }: BrowserPaneProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const titleStyle = useMemo( () => [styles.title, { color: theme.colors.foreground }], [theme.colors.foreground], @@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) { return ( - Browser is desktop-only - Browser session {browserId} + {t("workspace.browser.unavailable.title")} + {t("workspace.browser.session", { browserId })} ); } diff --git a/packages/app/src/components/browser-pane.web.tsx b/packages/app/src/components/browser-pane.web.tsx index f87cdb453..27dd88ac3 100644 --- a/packages/app/src/components/browser-pane.web.tsx +++ b/packages/app/src/components/browser-pane.web.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; interface BrowserPaneProps { @@ -12,6 +13,7 @@ interface BrowserPaneProps { } export function BrowserPane({ browserId }: BrowserPaneProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const titleStyle = useMemo( () => [styles.title, { color: theme.colors.foreground }], @@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) { return ( - Browser is desktop-only - - Open this workspace in Electron to use the built-in browser. - - Browser session {browserId} + {t("workspace.browser.unavailable.title")} + {t("workspace.browser.unavailable.subtitle")} + {t("workspace.browser.session", { browserId })} ); } diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 423478106..2c9312528 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { View, Text, @@ -144,6 +145,7 @@ function ModelRow({ onToggleFavorite?: (provider: string, modelId: string) => void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const ProviderIcon = getProviderIcon(row.provider); const handleToggleFavorite = useCallback( @@ -166,7 +168,9 @@ function ModelRow({ hitSlop={8} style={favoriteButtonStyle} accessibilityRole="button" - accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} + accessibilityLabel={ + isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel") + } testID={`favorite-model-${row.provider}-${row.modelId}`} > {({ hovered }) => { @@ -193,6 +197,7 @@ function ModelRow({ theme.colors.palette.amber, theme.colors.foregroundMuted, theme.colors.border, + t, ], ); @@ -256,6 +261,7 @@ function FavoritesSection({ onSelect: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void; }) { + const { t } = useTranslation(); if (favoriteRows.length === 0) { return null; } @@ -263,7 +269,7 @@ function FavoritesSection({ return ( - Favorites + {t("modelSelector.favorites")} {favoriteRows.map((row) => ( {`${count} ${count === 1 ? "model" : "models"}`} + + {t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", { + count, + })} + ); } else if (selection.kind === "loading") { stateNode = ( @@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps color={theme.colors.foregroundMuted} style={styles.rowSpinner} /> - Loading + {t("modelSelector.loadingShort")} ); } else { stateNode = ( - Error + {t("modelSelector.error")} ); } @@ -431,6 +442,7 @@ function ProviderErrorEmptyState({ isRetryingProvider: boolean; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const handleRetry = useCallback(() => { onRetryProvider?.(providerId); }, [onRetryProvider, providerId]); @@ -440,7 +452,7 @@ function ProviderErrorEmptyState({ {message} {onRetryProvider ? ( ) : null} @@ -461,6 +473,7 @@ function SelectorContent({ isRetryingProvider, }: SelectorContentProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const selectedViewProvider = useMemo( () => @@ -484,7 +497,7 @@ function SelectorContent({ const emptyState = ( - No models match your search + {t("modelSelector.noMatches")} ); @@ -501,7 +514,7 @@ function SelectorContent({ color={theme.colors.foregroundMuted} style={styles.rowSpinner} /> - Loading + {t("modelSelector.loadingShort")} ); } @@ -569,6 +582,7 @@ export function CombinedModelSelector({ serverId = null, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [isContentReady, setIsContentReady] = useState(platformIsWeb); @@ -653,12 +667,15 @@ export function CombinedModelSelector({ }, [providers, view]); const triggerLabel = useMemo(() => { - if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { + if ( + selectedModelLabel === t("modelSelector.loading") || + selectedModelLabel === t("modelSelector.selectModel") + ) { return selectedModelLabel; } return buildSelectedTriggerLabel(selectedModelLabel); - }, [selectedModelLabel]); + }, [selectedModelLabel, t]); useEffect(() => { if (platformIsWeb) { @@ -713,7 +730,7 @@ export function CombinedModelSelector({ const sheetHeader = useMemo(() => { if (view.kind === "all") { - return { title: "Select provider" }; + return { title: t("modelSelector.title") }; } const ProviderIconForView = getProviderIcon(view.providerId); const headerActions = ( @@ -723,7 +740,9 @@ export function CombinedModelSelector({ hitSlop={8} style={iconButtonStyle} accessibilityRole="button" - accessibilityLabel={`Open ${view.providerLabel} settings`} + accessibilityLabel={t("modelSelector.openProviderSettings", { + provider: view.providerLabel, + })} testID={`selector-header-settings-${view.providerId}`} > {renderTrigger ? ( @@ -823,7 +843,7 @@ export function CombinedModelSelector({ ) : ( - Loading model selector… + {t("modelSelector.loadingSelector")} )} diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index fcbadd289..8de3ed43f 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -8,6 +8,7 @@ import { type PressableStateCallbackType, } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Home, Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useCommandCenter } from "@/hooks/use-command-center"; @@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps { function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const titleStyle = useMemo( () => [styles.title, { color: theme.colors.foreground }], [theme.colors.foreground], @@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro - {agent.title || "New agent"} + {agent.title || t("shell.commandCenter.newAgent")} {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} @@ -256,10 +258,12 @@ function AgentItemsSection({ sectionDividerStyle, sectionLabelStyle, }: AgentItemsSectionProps) { + const { t } = useTranslation(); + return ( <> {actionItemsLength > 0 ? : null} - Agents + {t("shell.commandCenter.agents")} {agentItems.map((item, index) => { const rowIndex = actionItemsLength + index; const agent = item.agent; @@ -283,6 +287,7 @@ function AgentItemsSection({ export function CommandCenter() { const { theme } = useUnistyles(); + const { t } = useTranslation(); const { open, inputRef, @@ -442,12 +447,12 @@ export function CommandCenter() { const resultList = items.length === 0 ? ( - No matches + {t("shell.commandCenter.noMatches")} ) : ( <> {actionItems.length > 0 ? ( <> - Actions + {t("shell.commandCenter.actions")} {actionItems.map((item, index) => ( - Context window - {`${roundedPercentage}% used`} - {`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`} + {t("contextWindow.title")} + + {t("contextWindow.used", { percentage: roundedPercentage })} + + + {t("contextWindow.tokens", { + used: formatTokenCount(usedTokens), + max: formatTokenCount(maxTokens), + })} + {formattedSessionCost ? ( - {`Session cost ${formattedSessionCost}`} + + {t("contextWindow.sessionCost", { cost: formattedSessionCost })} + ) : null} diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index cc12a26de..eb2b14a25 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { VolumeMeter } from "./volume-meter"; import { FOOTER_HEIGHT } from "@/constants/layout"; import type { DictationStatus } from "@/hooks/use-dictation"; @@ -43,6 +44,7 @@ export function DictationControls({ disabled = false, }: DictationControlsProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isFailed = status === "failed"; const showActiveState = isRecording || isProcessing || isFailed; const actionsDisabled = isProcessing; @@ -71,7 +73,7 @@ export function DictationControls({ onPress={onStart} disabled={disabled} accessibilityRole="button" - accessibilityLabel="Start voice dictation" + accessibilityLabel={t("message.dictation.start")} style={micButtonStyle} > @@ -89,7 +91,7 @@ export function DictationControls({ @@ -102,7 +104,7 @@ export function DictationControls({ {!actionsDisabled && isFailed ? ( @@ -112,14 +114,14 @@ export function DictationControls({ <> @@ -149,6 +151,7 @@ export function DictationOverlay({ onDiscard, }: Omit & { errorText?: string }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isFailed = status === "failed"; const showActiveState = isRecording || isProcessing || isFailed; const actionsDisabled = isProcessing; @@ -189,7 +192,7 @@ export function DictationOverlay({ onPress={handleCancel} disabled={actionsDisabled && !isFailed} accessibilityRole="button" - accessibilityLabel="Cancel dictation" + accessibilityLabel={t("message.dictation.cancel")} style={overlayCancelButtonStyle} > @@ -208,7 +211,9 @@ export function DictationOverlay({ {isFailed ? ( - {errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."} + {errorText + ? t("message.dictation.failed", { error: errorText }) + : t("message.dictation.failedRetry")} ) : null} @@ -223,7 +228,7 @@ export function DictationOverlay({ @@ -234,7 +239,7 @@ export function DictationOverlay({ diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 371f78e55..26f22c6b8 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; @@ -120,10 +121,12 @@ function DiffSegment({ export function DiffViewer({ diffLines, maxHeight, - emptyLabel = "No changes to display", + emptyLabel, fillAvailableHeight = false, }: DiffViewerProps) { + const { t } = useTranslation(); const [scrollViewWidth, setScrollViewWidth] = React.useState(0); + const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty"); const webScrollbarStyle = useWebScrollbarStyle(); const handleInnerLayout = React.useCallback( (e: { nativeEvent: { layout: { width: number } } }) => @@ -159,7 +162,7 @@ export function DiffViewer({ if (!diffLines.length) { return ( - {emptyLabel} + {resolvedEmptyLabel} ); } diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index 910578aab..b06417213 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store const AUTO_DISMISS_DELAY = 3000; -function getDownloadStatusText(download: Download): string { +function getDownloadStatusText(download: Download, t: TFunction): string { if (download.status === "downloading") { if (download.progress) { return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`; } - return "Starting..."; + return t("common.states.starting"); } - if (download.status === "complete") return "Download complete"; - return download.message ?? "Download failed"; + if (download.status === "complete") return t("common.states.downloadComplete"); + return download.message ?? t("common.states.downloadFailed"); } export function DownloadToast() { const { theme } = useUnistyles(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const downloads = useDownloadStore((state) => state.downloads); const activeDownloadId = useDownloadStore((state) => state.activeDownloadId); @@ -78,7 +81,7 @@ export function DownloadToast() { {activeDownload.fileName} - {getDownloadStatusText(activeDownload)} + {getDownloadStatusText(activeDownload, t)} {activeDownload.status === "downloading" && activeDownload.progress && ( diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 2a41f83e9..97ffe159d 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { GitHubIcon } from "@/components/icons/github-icon"; import { PrPane } from "@/git/pr-pane"; import { usePrPaneData } from "@/hooks/use-pr-pane-data"; @@ -418,6 +419,7 @@ function SidebarContent({ onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const padding = useWindowControlsPadding("explorerSidebar"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ @@ -448,7 +450,7 @@ function SidebarContent({ @@ -456,7 +458,7 @@ function SidebarContent({ diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index 4025aeab0..af4b272a0 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import { useEffect, useMemo } from "react"; import { Upload } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import type { ImageAttachment } from "@/composer/types"; import { isWeb } from "@/constants/platform"; @@ -16,6 +17,7 @@ interface FileDropZoneProps { const IS_WEB = isWeb; export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const { isDragging, containerRef } = useFileDropZone({ onFilesDropped, @@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil {/* Content */} - Drop images here + {t("composer.attachments.dropImagesHere")} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 46f6da236..c1295a541 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, FlatList, @@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { isWeb } from "@/constants/platform"; -const SORT_OPTIONS: { value: SortOption; label: string }[] = [ - { value: "name", label: "Name" }, - { value: "modified", label: "Modified" }, - { value: "size", label: "Size" }, +const SORT_OPTIONS: { value: SortOption }[] = [ + { value: "name" }, + { value: "modified" }, + { value: "size" }, ]; const INDENT_PER_LEVEL = 16; @@ -115,6 +116,7 @@ function TreeRowItem({ onDownloadEntry, }: TreeRowItemProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isDirectory = entry.kind === "directory"; const handlePress = useCallback(() => { @@ -181,7 +183,7 @@ function TreeRowItem({ - Size + {t("workspace.fileExplorer.context.size")} {formatFileSize({ size: entry.size })} @@ -189,7 +191,7 @@ function TreeRowItem({ - Modified + {t("workspace.fileExplorer.context.modified")} {formatTimeAgo(new Date(entry.modifiedAt))} @@ -198,11 +200,11 @@ function TreeRowItem({ - Copy path + {t("workspace.fileExplorer.context.copyPath")} {entry.kind === "file" ? ( - Download + {t("workspace.fileExplorer.context.download")} ) : null} @@ -229,6 +231,7 @@ export function FileExplorerPane({ workspaceRoot, onOpenFile, }: FileExplorerPaneProps) { + const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; @@ -395,7 +398,15 @@ export function FileExplorerPane({ void refetchExplorer(); }, [refetchExplorer]); - const currentSortLabel = resolveCurrentSortLabel(sortOption); + const sortLabels = useMemo( + () => ({ + name: t("workspace.fileExplorer.sort.name"), + modified: t("workspace.fileExplorer.sort.modified"), + size: t("workspace.fileExplorer.sort.size"), + }), + [t], + ); + const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels); const treeRows = useMemo( () => resolveTreeRows({ directories, expandedPaths, sortOption }), @@ -453,7 +464,7 @@ export function FileExplorerPane({ if (!hasWorkspaceScope) { return ( - Workspace is unavailable + {t("workspace.fileExplorer.states.unavailable")} ); } @@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps { function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const { error, showInitialLoading, @@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { {showBackFromError ? ( - Back + {t("workspace.fileExplorer.actions.back")} ) : null} - Retry + {t("workspace.fileExplorer.actions.retry")} @@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { return ( - Loading files… + {t("workspace.fileExplorer.states.loading")} ); } @@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { if (treeRows.length === 0) { return ( - No files + {t("workspace.fileExplorer.empty.noFiles")} ); } @@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { hitSlop={8} style={iconButtonStyleProp} accessibilityRole="button" - accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"} + accessibilityLabel={ + isRefreshFetching + ? t("workspace.fileExplorer.actions.refreshing") + : t("workspace.fileExplorer.actions.refresh") + } > {isRefreshFetching ? ( @@ -704,8 +720,11 @@ function resolveShowInitialLoading({ ); } -function resolveCurrentSortLabel(sortOption: SortOption): string { - return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; +function resolveCurrentSortLabel( + sortOption: SortOption, + labels: Record, +): string { + return labels[sortOption] ?? labels.name; } function resolveTreeRows({ diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 8e4bbc727..e4bbf28e0 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -17,6 +17,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; @@ -503,6 +504,7 @@ function FilePreviewBody({ imagePreviewUri, }: FilePreviewBodyProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const filePath = location.path; const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); @@ -562,7 +564,7 @@ function FilePreviewBody({ return ( - Loading file… + {t("panels.file.loading")} ); } @@ -570,7 +572,7 @@ function FilePreviewBody({ if (!preview) { return ( - No preview available + {t("panels.file.noPreview")} ); } @@ -659,7 +661,7 @@ function FilePreviewBody({ return ( - Loading file… + {t("panels.file.loading")} ); } @@ -689,7 +691,7 @@ function FilePreviewBody({ return ( - Binary preview unavailable + {t("panels.file.binaryPreviewUnavailable")} {formatFileSize({ size: preview.size })} ); @@ -704,6 +706,7 @@ export function FilePane({ workspaceRoot: string; location: WorkspaceFileLocation; }) { + const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; @@ -726,7 +729,10 @@ export function FilePane({ enabled: Boolean(client && readTarget), queryFn: async () => { if (!client || !readTarget) { - return { file: null as ExplorerFile | null, error: "Host is not connected" }; + return { + file: null as ExplorerFile | null, + error: t("workspace.terminal.hostDisconnected"), + }; } try { const file = await client.readFile(readTarget.cwd, readTarget.path); @@ -740,7 +746,7 @@ export function FilePane({ return { file: null, imageAttachment: null, - error: error instanceof Error ? error.message : "Failed to load file", + error: error instanceof Error ? error.message : t("panels.file.failedToLoad"), }; } }, diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index 30382f239..6532ee561 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -1,4 +1,5 @@ import { useCallback, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -19,6 +20,7 @@ function goBack(): void { export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const handleBack = useCallback(() => { if (onBack) { onBack(); @@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back onPress={handleBack} style={styles.backButton} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={t("common.actions.back")} > diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 25b50e019..aea0abf2e 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PanelLeft } from "lucide-react-native"; @@ -48,6 +49,7 @@ export function SidebarMenuToggle({ nativeID = "menu-button", }: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); @@ -68,7 +70,7 @@ export function SidebarMenuToggle({ return ( {isMobile ? ( diff --git a/packages/app/src/components/highlighted-code-block.tsx b/packages/app/src/components/highlighted-code-block.tsx index 2c1d17d0d..c61e81db6 100644 --- a/packages/app/src/components/highlighted-code-block.tsx +++ b/packages/app/src/components/highlighted-code-block.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import { MarkdownTextSpan } from "@/components/markdown-text"; import * as Clipboard from "expo-clipboard"; import { Check, Copy } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import type { HighlightToken } from "@getpaseo/highlight"; import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -153,6 +154,7 @@ interface CopyButtonProps { const COPIED_RESET_MS = 1500; const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const resetRef = useRef | null>(null); @@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt style={wrapperStyle} pointerEvents={visible ? "auto" : "none"} accessibilityRole="button" - accessibilityLabel={copied ? "Copied" : "Copy code"} + accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")} hitSlop={8} > {({ hovered }) => { diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts index 96d7080f6..9650c8071 100644 --- a/packages/app/src/components/import-session-sheet-view-model.ts +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -1,5 +1,6 @@ import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { i18n } from "@/i18n/i18next"; export const PER_PROVIDER_LIMIT = 15; export const ALL_FILTER_VALUE = "__all__"; @@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string if (firstPromptPreview) { return firstPromptPreview; } - return "Untitled session"; + return i18n.t("importSession.preview.untitledSession"); } export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { - return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; + return ( + entry.lastPromptPreview?.trim() || + entry.firstPromptPreview?.trim() || + i18n.t("importSession.preview.noPrompt") + ); } export interface EmptyStateInputs { @@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): { const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; if (isFilteredEmpty) { const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; - return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` }; + return { + showEmptyState, + emptyStateTitle: i18n.t("importSession.empty.noProviderSessions", { provider: label }), + }; } if (input.totalAlreadyImportedCount > 0) { - return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." }; + return { + showEmptyState, + emptyStateTitle: i18n.t("importSession.empty.alreadyImported"), + }; } - return { showEmptyState, emptyStateTitle: "No recent sessions to import." }; + return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noRecent") }; } diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 6a8b9138b..ce38cec43 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { DaemonClient, FetchRecentProviderSessionEntry, @@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { i18n } from "@/i18n/i18next"; import { aggregateSessionEntries, ALL_FILTER_VALUE, @@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: { visible: boolean; client: RecentProviderSessionsClient | null; cwd: string | null | undefined; + hostDisconnectedMessage?: string; }): SessionsQueryConfig[] { - const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args; + const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } = + args; if (providersToFetch === null) return []; const enabled = visible && Boolean(client); return providersToFetch.map((provider) => ({ @@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: { enabled, queryFn: async () => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } return await client.fetchRecentProviderSessions({ ...(cwd ? { cwd } : {}), @@ -105,33 +109,36 @@ function SheetStatusMessages({ importErrored, }: SheetStatusMessagesProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); if (!isClientReady) { - return Connect to a host to import sessions; + return {t("importSession.status.connectHost")}; } if (isSnapshotUnsupported) { - return Update the host to import sessions.; + return {t("importSession.status.updateHost")}; } return ( <> {hasNoImportableProviders ? ( - No importable providers are enabled. + {t("importSession.status.noProviders")} ) : null} {isLoadingSessions && !hasRows ? ( - Loading recent sessions... + {t("importSession.status.loading")} ) : null} {allQueriesErrored ? ( - Could not load recent sessions. + {t("importSession.status.failedAll")} ) : null} {!allQueriesErrored && erroredProviderLabels.length > 0 ? ( - Could not load sessions for {erroredProviderLabels.join(", ")}. + {t("importSession.status.failedProviders", { + providers: erroredProviderLabels.join(", "), + })} ) : null} {importErrored ? ( - Could not import selected session. + {t("importSession.status.failedImport")} ) : null} ); @@ -139,6 +146,7 @@ function SheetStatusMessages({ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const pressableStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.refreshButton, @@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); @@ -229,7 +238,9 @@ function ImportSessionSheetRow({ {title} - {importing ? "Importing..." : lastActivity} + + {importing ? t("importSession.row.importing") : lastActivity} + {promptPreview} @@ -253,6 +264,7 @@ export function ImportSessionSheet({ onImportedAgent, onImported, }: ImportSessionSheetProps) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const { theme } = useUnistyles(); @@ -284,8 +296,9 @@ export function ImportSessionSheet({ visible, client, cwd, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [providersToFetch, sessionsQueryRoot, visible, client, cwd], + [providersToFetch, sessionsQueryRoot, visible, client, cwd, t], ); const queries = useQueries({ queries: queriesConfig }); @@ -318,19 +331,20 @@ export function ImportSessionSheet({ const filterComboboxOptions = useMemo( () => [ - { id: ALL_FILTER_VALUE, label: "All providers" }, + { id: ALL_FILTER_VALUE, label: t("importSession.filters.all") }, ...filterProviders.map((provider) => ({ id: provider, label: providerLabelById.get(provider) ?? provider, })), ], - [filterProviders, providerLabelById], + [filterProviders, providerLabelById, t], ); const selectedProviderLabel = useMemo( () => - filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers", - [filterComboboxOptions, selectedProvider], + filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? + t("importSession.filters.all"), + [filterComboboxOptions, selectedProvider, t], ); const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); @@ -385,7 +399,7 @@ export function ImportSessionSheet({ const importMutation = useMutation({ mutationFn: async (entry: FetchRecentProviderSessionEntry) => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } if (!entry.cwd) { throw new Error("Session is missing a working directory"); @@ -430,10 +444,10 @@ export function ImportSessionSheet({ const header = useMemo( () => ({ - title: "Import session", + title: t("importSession.title"), actions: , }), - [isRefreshing, handleRefresh], + [isRefreshing, handleRefresh, t], ); const isSnapshotUnsupported = !supportsSnapshot; diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index 78f6e1250..ea90e6ed1 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { getIsElectronRuntime } from "@/constants/layout"; @@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; const SNAP_POINTS: string[] = ["70%", "92%"]; -const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" }; export function KeyboardShortcutsDialog() { + const { t } = useTranslation(); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); @@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() { ); const handleClose = useCallback(() => setOpen(false), [setOpen]); + const header = useMemo(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]); return ( {sections.map((section) => ( - {section.title} + {t(section.titleKey)} {section.rows.map((row) => ( - {row.label} - {row.note ? {row.note} : null} + {t(row.labelKey)} + {row.note ? ( + {row.noteKey ? t(row.noteKey) : row.note} + ) : null} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 69cbabf59..4b08a507a 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,6 @@ import { router, usePathname } from "expo-router"; import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { type Dispatch, memo, @@ -104,6 +105,7 @@ interface SidebarSharedProps { handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; + labels: SidebarLabels; renderHostOption: (input: { option: ComboboxOption; selected: boolean; @@ -112,6 +114,16 @@ interface SidebarSharedProps { }) => ReactElement; } +interface SidebarLabels { + addProject: string; + home: string; + settings: string; + switchHost: string; + searchHosts: string; + sessions: string; + closeSidebar: string; +} + interface MobileSidebarProps extends SidebarSharedProps { insetsTop: number; insetsBottom: number; @@ -132,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({ void _selectedAgentId; const { theme } = useUnistyles(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const isCompactLayout = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => @@ -146,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({ ); const activeServerId = activeDaemon?.serverId ?? null; const activeHostLabel = useMemo(() => { - if (!activeDaemon) return "No host"; + if (!activeDaemon) return t("sidebar.host.noHost"); const trimmed = activeDaemon.label?.trim(); return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; - }, [activeDaemon]); + }, [activeDaemon, t]); const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? ""); const activeHostStatus = activeServerId ? (activeHostSnapshot?.connectionStatus ?? "connecting") @@ -271,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({ [pathname], ); + const labels = useMemo( + (): SidebarLabels => ({ + addProject: t("sidebar.actions.addProject"), + home: t("sidebar.actions.home"), + settings: t("sidebar.actions.settings"), + switchHost: t("sidebar.host.switchTitle"), + searchHosts: t("sidebar.host.searchPlaceholder"), + sessions: t("sidebar.sections.sessions"), + closeSidebar: t("sidebar.actions.closeSidebar"), + }), + [t], + ); + const sharedProps = { theme, activeServerId, @@ -291,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleRefresh, handleHostSelect, renderHostOption, + labels, }; if (isCompactLayout) { @@ -425,12 +452,14 @@ function FooterIconButton({ function AddProjectTooltipContent({ newAgentKeys, + label, }: { newAgentKeys: ReturnType; + label: string; }) { return ( - Add project + {label} {newAgentKeys ? : null} ); @@ -465,6 +494,7 @@ function SidebarFooter({ handleOpenProject, handleHome, handleSettings, + labels, }: { theme: SidebarTheme; activeServerId: string | null; @@ -479,6 +509,13 @@ function SidebarFooter({ handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; + labels: { + addProject: string; + home: string; + settings: string; + switchHost: string; + searchHosts: string; + }; }) { const newAgentKeys = useShortcutKeys("new-agent"); return ( @@ -498,26 +535,26 @@ function SidebarFooter({ - + @@ -528,8 +565,8 @@ function SidebarFooter({ onSelect={handleHostSelect} renderOption={renderHostOption} searchable={false} - title="Switch host" - searchPlaceholder="Search hosts..." + title={labels.switchHost} + searchPlaceholder={labels.searchHosts} desktopMinWidth={280} open={isHostPickerOpen} onOpenChange={setIsHostPickerOpen} @@ -563,6 +600,7 @@ function MobileSidebar({ handleOpenProject, handleHome, handleSettings, + labels, insetsTop, insetsBottom, isOpen, @@ -748,7 +786,7 @@ function MobileSidebar({ {({ hovered, pressed }) => ( @@ -810,6 +848,7 @@ function MobileSidebar({ handleOpenProject={handleOpenProject} handleHome={handleHome} handleSettings={handleSettings} + labels={labels} /> @@ -842,6 +881,7 @@ function DesktopSidebar({ handleOpenProject, handleHome, handleSettings, + labels, insetsTop, isOpen, handleViewMore, @@ -919,7 +959,7 @@ function DesktopSidebar({ {/* Resize handle - absolutely positioned over right border */} diff --git a/packages/app/src/components/message-compaction-label.test.ts b/packages/app/src/components/message-compaction-label.test.ts index 670ab8541..caa4ebfea 100644 --- a/packages/app/src/components/message-compaction-label.test.ts +++ b/packages/app/src/components/message-compaction-label.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import { getCompactionMarkerLabel } from "./message-compaction-label"; describe("getCompactionMarkerLabel", () => { @@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => { ); expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted"); }); + + it("renders labels in the active app language", async () => { + await i18n.changeLanguage("zh-CN"); + try { + expect(getCompactionMarkerLabel({ status: "loading" })).toBe("正在压缩..."); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/components/message-compaction-label.ts b/packages/app/src/components/message-compaction-label.ts index 052d1d690..3282cc9fa 100644 --- a/packages/app/src/components/message-compaction-label.ts +++ b/packages/app/src/components/message-compaction-label.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; + export interface CompactionMarkerLabelInput { status: "loading" | "completed"; trigger?: "auto" | "manual"; @@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({ trigger, preTokens, }: CompactionMarkerLabelInput): string { - if (status === "loading") return "Compacting..."; - if (trigger === "auto") return "Context automatically compacted"; - if (trigger === "manual") return "Context manually compacted"; - if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`; - return "Context compacted"; + if (status === "loading") return i18n.t("message.compaction.loading"); + if (trigger === "auto") return i18n.t("message.compaction.auto"); + if (trigger === "manual") return i18n.t("message.compaction.manual"); + if (preTokens) { + return i18n.t("message.compaction.withTokens", { + tokens: Math.round(preTokens / 1000), + }); + } + return i18n.t("message.compaction.completed"); } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4d986a41d..56b87f262 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -10,6 +10,7 @@ import { ViewStyle, type TextStyle, } from "react-native"; +import { useTranslation } from "react-i18next"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import * as React from "react"; @@ -448,18 +449,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta return ; } -function getUserMessageAttachmentLabel(attachment: AgentAttachment): string { +function getUserMessageAttachmentLabel( + attachment: AgentAttachment, + t: ReturnType["t"], +): string { switch (attachment.type) { case "review": { const count = attachment.comments.length; - return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`; + return count === 1 + ? t("message.attachments.reviewOne") + : t("message.attachments.reviewMany", { count }); } case "github_pr": return `PR #${attachment.number}`; case "github_issue": return `Issue #${attachment.number}`; case "text": - return attachment.title ?? "Text attachment"; + return attachment.title ?? t("message.attachments.textAttachment"); default: return ""; } @@ -480,6 +486,7 @@ export const UserMessage = memo(function UserMessage({ disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; @@ -562,7 +569,7 @@ export const UserMessage = memo(function UserMessage({ style={userMessageStylesheet.structuredAttachmentPill} > - {getUserMessageAttachmentLabel(attachment)} + {getUserMessageAttachmentLabel(attachment, t)} ))} @@ -588,7 +595,7 @@ export const UserMessage = memo(function UserMessage({ ) : null} @@ -862,6 +869,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm const handleImageError = useCallback(() => { setLoadState({ status: "error" }); }, []); + const { t } = useTranslation(); const surfaceStyle = useMemo>( () => [ assistantMessageStylesheet.imageSurface, @@ -887,7 +895,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm {loadState.status === "loading" ? : null} {loadState.status === "error" ? ( - Image unavailable + + {t("message.attachments.imageUnavailable")} + ) : null} @@ -924,6 +934,7 @@ function AssistantMarkdownImage({ workspaceRoot?: string; serverId?: string; }) { + const { t } = useTranslation(); const resolution = useMemo( () => resolveAssistantImageSource({ source, workspaceRoot }), [source, workspaceRoot], @@ -953,7 +964,7 @@ function AssistantMarkdownImage({ const file = await client.readFile(resolution.cwd, resolution.path); if (file.kind !== "image") { - throw new Error("Image preview unavailable."); + throw new Error(t("message.attachments.imagePreviewUnavailable")); } return await persistAttachmentFromBytes({ @@ -1026,7 +1037,11 @@ function AssistantMarkdownImage({ ); } - const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error); + const errorText = resolveAssistantImageErrorText( + query.error, + dataImageQuery.error, + t("message.attachments.imagePreviewLoadFailed"), + ); return ( @@ -1035,10 +1050,14 @@ function AssistantMarkdownImage({ ); } -function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string { +function resolveAssistantImageErrorText( + fileError: unknown, + dataError: unknown, + fallbackText: string, +): string { if (fileError instanceof Error) return fileError.message; if (dataError instanceof Error) return dataError.message; - return "Unable to load image preview."; + return fallbackText; } function getInlineCodeAutoLinkUrl( @@ -1144,6 +1163,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ accessibilityLabel, copiedAccessibilityLabel, }: TurnCopyButtonProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef | null>(null); @@ -1185,7 +1205,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({ style={pressableStyle} accessibilityRole="button" accessibilityLabel={ - copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn") + copied + ? (copiedAccessibilityLabel ?? t("message.actions.copied")) + : (accessibilityLabel ?? t("message.actions.copyTurn")) } > {({ hovered }) => { @@ -1966,6 +1988,7 @@ export const SpeakMessage = memo(function SpeakMessage({ timestamp: _timestamp, disableOuterSpacing, }: SpeakMessageProps) { + const { t } = useTranslation(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const containerStyle = useMemo( () => [ @@ -1979,7 +2002,7 @@ export const SpeakMessage = memo(function SpeakMessage({ - Spoke + {t("message.speak.header")} {message} @@ -2080,6 +2103,7 @@ export const ActivityLog = memo(function ActivityLog({ onArtifactClick, disableOuterSpacing, }: ActivityLogProps) { + const { t } = useTranslation(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isExpanded, setIsExpanded] = useState(false); @@ -2149,7 +2173,9 @@ export const ActivityLog = memo(function ActivityLog({ {metadata && ( - Details + + {t("message.activity.details")} + {isExpanded ? ( ) : ( @@ -2306,6 +2332,7 @@ export const TodoListCard = memo(function TodoListCard({ items, disableOuterSpacing, }: TodoListCardProps) { + const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(false); const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); @@ -2319,7 +2346,7 @@ export const TodoListCard = memo(function TodoListCard({ {items.length === 0 ? ( - No tasks yet. + {t("message.todo.empty")} ) : ( items.map((item) => ( @@ -2328,11 +2355,11 @@ export const TodoListCard = memo(function TodoListCard({ ); - }, [items]); + }, [items, t]); return ( ({ helper: { @@ -61,6 +61,7 @@ export interface PairLinkModalProps { export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const isMobile = useIsCompactFormFactor(); @@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM if (isSaving) return; const raw = offerUrlRef.current.trim(); if (!raw) { - setErrorMessage("Paste a pairing link (…/#offer=...)"); + setErrorMessage(t("pairing.link.errors.required")); return; } if (!raw.includes("#offer=")) { - setErrorMessage("Link must include #offer=..."); + setErrorMessage(t("pairing.link.errors.missingOffer")); return; } @@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM const idx = raw.indexOf("#offer="); const encoded = raw.slice(idx + "#offer=".length).trim(); if (!encoded) { - throw new Error("Offer payload is empty"); + throw new Error(t("pairing.link.errors.emptyOffer")); } const payload = decodeOfferFragmentPayload(encoded); return ConnectionOfferSchema.parse(payload); } catch (error) { - const message = error instanceof Error ? error.message : "Invalid pairing link"; + const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid"); setErrorMessage(message); if (!isMobile) { - Alert.alert("Pairing failed", message); + Alert.alert(t("pairing.link.alert.failedTitle"), message); } return null; } @@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); handleClose(); } catch (error) { - const message = error instanceof Error ? error.message : "Unable to pair host"; + const message = + error instanceof Error ? error.message : t("pairing.link.errors.unableToPair"); setErrorMessage(message); if (!isMobile) { - Alert.alert("Pairing failed", message); + Alert.alert(t("pairing.link.alert.failedTitle"), message); } } finally { setIsSaving(false); } - }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]); + }, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]); const handleChangeOfferUrl = useCallback((next: string) => { offerUrlRef.current = next; @@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM void handleSave(); }, [handleSave]); + const header = useMemo(() => ({ title: t("pairing.link.title") }), [t]); + return ( - Paste the pairing link from your server. + {t("pairing.link.helper")} - Pairing link + {t("pairing.link.label")} - Cancel + {t("pairing.link.actions.cancel")} diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index c512220e8..e2d458e69 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import Markdown, { type ASTNode } from "react-native-markdown-display"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { getMarkdownListMarker } from "@/utils/markdown-list"; @@ -194,7 +195,7 @@ function createPlanMarkdownRules() { } export function PlanCard({ - title = "Plan", + title, description, text, footer, @@ -209,8 +210,10 @@ export function PlanCard({ testID?: string; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const markdownStyles = createMarkdownStyles(theme); const markdownRules = createPlanMarkdownRules(); + const resolvedTitle = title ?? t("agentStream.permission.plan"); const containerStyle = useMemo( () => [ @@ -234,7 +237,7 @@ export function PlanCard({ return ( - {title} + {resolvedTitle} {description ? {description} : null} {text} diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index aea9b1acb..a30ecb55e 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -11,6 +11,7 @@ import { import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { shortenPath } from "@/utils/shorten-path"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; @@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) { export function ProjectPickerModal() { const { theme } = useUnistyles(); + const { t } = useTranslation(); const serverId = useActiveServerId(); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); @@ -235,7 +237,7 @@ export function ProjectPickerModal() { ref={inputRef} value={query} onChangeText={handleChangeQuery} - placeholder="Type a directory path..." + placeholder={t("projectPicker.placeholder")} placeholderTextColor={theme.colors.foregroundMuted} style={inputStyle} autoCapitalize="none" @@ -253,9 +255,9 @@ export function ProjectPickerModal() { keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={false} > - {isSubmitting ? Opening project... : null} + {isSubmitting ? {t("projectPicker.opening")} : null} {!isSubmitting && options.length === 0 && !query.trim() ? ( - Start typing a path + {t("projectPicker.empty")} ) : null} {!isSubmitting && !(options.length === 0 && !query.trim()) ? ( <> diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index e9c8fa700..b73a72163 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import { SvgXml } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -48,6 +49,10 @@ interface CatalogRowProps { } function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { + const { t } = useTranslation(); + const actionLabel = installing + ? t("providerCatalog.actions.adding") + : t("providerCatalog.actions.add"); const handleInstall = useCallback(() => { onInstall(entry); }, [entry, onInstall]); @@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { - Install instructions + {t("providerCatalog.actions.installInstructions")} @@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { style={styles.actionButton} testID={`install-provider-${entry.id}`} > - {installing ? "Adding" : "Add"} + {actionLabel} ); @@ -114,6 +121,7 @@ export function ProviderCatalogList({ installingProviderId, onInstall, }: ProviderCatalogListProps) { + const { t } = useTranslation(); const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); @@ -139,10 +147,10 @@ export function ProviderCatalogList({ - - {search.trim().length > 0 ? "No providers found" : "All providers are installed"} - + {t("providerCatalog.noProviders")} ) : ( diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index c40854e41..664f736c7 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,5 +1,7 @@ import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native"; +import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, @@ -79,6 +81,7 @@ function CustomModelRow({ deleting: boolean; onDelete: (modelId: string) => void; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]); const deleteButtonStyle = useCallback( @@ -110,7 +113,7 @@ function CustomModelRow({ hitSlop={8} style={deleteButtonStyle} accessibilityRole="button" - accessibilityLabel={`Remove ${model.id}`} + accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })} > @@ -148,6 +151,7 @@ function AddCustomModelSubSheet({ onClose: () => void; refresh: (providers?: AgentProvider[]) => Promise; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const { config, patchConfig } = useDaemonConfig(serverId); const [input, setInput] = useState(""); @@ -182,12 +186,15 @@ function AddCustomModelSubSheet({ .then(() => refresh([provider])) .then(() => onClose()) .catch((err) => { - setError(err instanceof Error ? err.message : "Failed to save model"); + setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave")); }) .finally(() => setSaving(false)); - }, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]); + }, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]); - const header = useMemo(() => ({ title: "Add custom model" }), []); + const header = useMemo( + () => ({ title: t("settings.providers.models.addCustomTitle") }), + [t], + ); return ( - Model ID + {t("settings.providers.models.modelId")} {error} : null} @@ -239,6 +246,7 @@ function DiagnosticSubSheet({ visible: boolean; onClose: () => void; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); const [diagnostic, setDiagnostic] = useState(null); @@ -251,11 +259,13 @@ function DiagnosticSubSheet({ const result = await client.getProviderDiagnostic(provider); setDiagnostic(result.diagnostic); } catch (err) { - setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + setDiagnostic( + err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"), + ); } finally { setLoading(false); } - }, [client, provider]); + }, [client, provider, t]); useEffect(() => { if (visible) { @@ -280,7 +290,7 @@ function DiagnosticSubSheet({ const header = useMemo( () => ({ - title: "Diagnostic", + title: t("settings.providers.diagnostic.title"), actions: ( {loading ? ( @@ -302,6 +316,7 @@ function DiagnosticSubSheet({ handleRefreshPress, loading, refreshButtonStyle, + t, theme.colors.foregroundMuted, theme.iconSize.sm, ], @@ -312,7 +327,7 @@ function DiagnosticSubSheet({ body = ( - Running diagnostic… + {t("settings.providers.diagnostic.running")} ); } else if (diagnostic) { @@ -328,7 +343,7 @@ function DiagnosticSubSheet({ } else { body = ( - No diagnostic available + {t("settings.providers.diagnostic.none")} ); } @@ -366,6 +381,7 @@ interface ProviderSheetFooterInput { fetchedAtLabel: string | null; isCompact: boolean; modelsRefreshing: boolean; + t: TFunction; onOpenAddSheet: () => void; onOpenDiagSheet: () => void; onRefreshModels: () => void; @@ -375,6 +391,7 @@ function renderProviderSheetFooter({ fetchedAtLabel, isCompact, modelsRefreshing, + t, onOpenAddSheet, onOpenDiagSheet, onRefreshModels, @@ -388,7 +405,7 @@ function renderProviderSheetFooter({ {fetchedAtLabel || !isCompact ? ( - {fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""} + {fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""} ) : null} @@ -399,7 +416,7 @@ function renderProviderSheetFooter({ onPress={onOpenAddSheet} style={buttonStyle} > - Add model + {t("settings.providers.models.addModel")} @@ -426,6 +445,7 @@ function renderProviderSheetFooter({ } function ProviderModalBody(props: ProviderModalBodyProps) { + const { t } = useTranslation(); const { discoveredCount, additionalCount, @@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) { return ( - Loading models… + {t("settings.providers.models.loading")} ); } @@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) { {providerErrorMessage} ); @@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) { if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) { return ( - No models match your search + {t("settings.providers.models.noSearchMatches")} ); } if (discoveredCount === 0 && additionalCount === 0) { return ( - No models detected + {t("settings.providers.models.noneDetected")} ); } @@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) { <> {filteredDiscovered.length > 0 ? ( - + {filteredDiscovered.map((model) => ( @@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) { ) : null} {filteredCustom.length > 0 ? ( - + {filteredCustom.map((model) => ( ([]); @@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({ const [clockTick, setClockTick] = useState(0); useEffect(() => { if (!visible) return; - const id = setInterval(() => setClockTick((t) => t + 1), 10_000); + const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000); return () => clearInterval(id); }, [visible]); const fetchedAtLabel = useMemo(() => { @@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({ title: providerLabel, search: { onChange: setQuery, - placeholder: "Search models", + placeholder: t("settings.providers.models.searchPlaceholder"), testID: "provider-settings-search", }, }), - [providerLabel], + [providerLabel, t], ); return ( @@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({ fetchedAtLabel, isCompact, modelsRefreshing, + t, onOpenAddSheet: handleOpenAddSheet, onOpenDiagSheet: handleOpenDiagSheet, onRefreshModels: handleRefreshModels, diff --git a/packages/app/src/components/question-form-card-core.ts b/packages/app/src/components/question-form-card-core.ts index f6f116983..3924a5a1d 100644 --- a/packages/app/src/components/question-form-card-core.ts +++ b/packages/app/src/components/question-form-card-core.ts @@ -138,6 +138,9 @@ export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): b ); } -export function resolveDismissLabel(questions: QuestionFormQuestion[]): string { - return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss"; +export function resolveDismissLabel( + questions: QuestionFormQuestion[], + fallbackLabel = "Dismiss", +): string { + return questions.find((question) => question.dismissLabel)?.dismissLabel ?? fallbackLabel; } diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 1179d55a7..4d2f3c5e3 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -10,6 +10,7 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; @@ -33,9 +34,17 @@ interface QuestionFormCardProps { const IS_WEB = isWeb; -function getQuestionInputPlaceholder(question: QuestionFormQuestion): string { +function getQuestionInputPlaceholder({ + question, + answerPlaceholder, + otherPlaceholder, +}: { + question: QuestionFormQuestion; + answerPlaceholder: string; + otherPlaceholder: string; +}): string { return ( - question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...") + question.placeholder ?? (question.options.length === 0 ? answerPlaceholder : otherPlaceholder) ); } @@ -239,6 +248,7 @@ function QuestionOtherInput({ export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const questions = useMemo( () => parseQuestionFormQuestions(permission.request.input), @@ -367,7 +377,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ); const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered); - const primaryActionLabel = isLastQuestion ? "Submit" : "Next"; + const primaryActionLabel = isLastQuestion + ? t("message.question.submit") + : t("message.question.next"); const submitButtonStyle = useCallback( ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, @@ -417,7 +429,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi return null; } - const dismissLabel = resolveDismissLabel(questions); + const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss")); const selected = selections[resolvedActiveQuestionIndex] ?? new Set(); const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; @@ -470,7 +482,11 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi qIndex={resolvedActiveQuestionIndex} accessibilityLabel={activeQuestion.question} value={otherText} - placeholder={getQuestionInputPlaceholder(activeQuestion)} + placeholder={getQuestionInputPlaceholder({ + question: activeQuestion, + answerPlaceholder: t("message.question.answerPlaceholder"), + otherPlaceholder: t("message.question.otherPlaceholder"), + })} isResponding={isResponding} onChange={setOtherText} onSubmit={handlePrimaryAction} diff --git a/packages/app/src/components/quitting-overlay.tsx b/packages/app/src/components/quitting-overlay.tsx index 8151391cb..f0c55dff0 100644 --- a/packages/app/src/components/quitting-overlay.tsx +++ b/packages/app/src/components/quitting-overlay.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react"; import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { getIsElectronRuntime } from "@/constants/layout"; import { listenToDesktopEvent } from "@/desktop/electron/events"; export function QuittingOverlay() { + const { t } = useTranslation(); const { theme } = useUnistyles(); const [quitting, setQuitting] = useState(false); @@ -37,8 +39,8 @@ export function QuittingOverlay() { return ( - Quitting Paseo… - Stopping the local daemon. + {t("desktop.quitting.title")} + {t("desktop.quitting.detail")} ); } diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 7e04ac4af..31ec826af 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; @@ -23,6 +24,7 @@ export function RealtimeVoiceOverlay({ onStop, }: RealtimeVoiceOverlayProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const { volume, isSpeaking } = useVoiceTelemetry(); const muteButtonStyle = useMemo( () => [ @@ -53,7 +55,9 @@ export function RealtimeVoiceOverlay({ onPress={onToggleMute} disabled={isSwitching} accessibilityRole="button" - accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"} + accessibilityLabel={ + isMuted ? t("realtimeVoice.actions.unmute") : t("realtimeVoice.actions.mute") + } style={muteButtonStyle} > {isMuted ? ( @@ -67,7 +71,7 @@ export function RealtimeVoiceOverlay({ onPress={onStop} disabled={isSwitching} accessibilityRole="button" - accessibilityLabel="Stop realtime voice and interrupt turn" + accessibilityLabel={t("realtimeVoice.actions.stop")} style={stopButtonStyle} > {isSwitching ? ( diff --git a/packages/app/src/components/rename-modal.tsx b/packages/app/src/components/rename-modal.tsx index c0cfa7de1..2ccbf021f 100644 --- a/packages/app/src/components/rename-modal.tsx +++ b/packages/app/src/components/rename-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Text, TextInput, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { AdaptiveModalSheet, AdaptiveTextInput, @@ -27,13 +28,14 @@ export function AdaptiveRenameModal({ title, initialValue, placeholder, - submitLabel = "Rename", + submitLabel, onClose, onSubmit, validate, maxLength, testID, }: AdaptiveRenameModalProps) { + const { t } = useTranslation(); const [draft, setDraft] = useState(initialValue); const [error, setError] = useState(null); const [isPending, setIsPending] = useState(false); @@ -64,10 +66,10 @@ export function AdaptiveRenameModal({ const computeError = useCallback( (value: string): string | null => { - if (!value.trim()) return "Name is required"; + if (!value.trim()) return t("common.errors.nameRequired"); return validate ? validate(value) : null; }, - [validate], + [validate, t], ); const handleChange = useCallback((value: string) => { @@ -91,10 +93,11 @@ export function AdaptiveRenameModal({ onClose(); } catch (err) { setIsPending(false); - const message = err instanceof Error && err.message ? err.message : "Unable to save"; + const message = + err instanceof Error && err.message ? err.message : t("common.errors.unableToSave"); setError(message); } - }, [isPending, draft, initialValue, computeError, onSubmit, onClose]); + }, [isPending, draft, initialValue, computeError, onSubmit, onClose, t]); const handleCancel = useCallback(() => { if (isPending) return; @@ -147,7 +150,7 @@ export function AdaptiveRenameModal({ disabled={isPending} testID={cancelTestID} > - Cancel + {t("common.actions.cancel")} diff --git a/packages/app/src/components/rewind/rewind-menu.tsx b/packages/app/src/components/rewind/rewind-menu.tsx index 08ddb04e3..47df8b681 100644 --- a/packages/app/src/components/rewind/rewind-menu.tsx +++ b/packages/app/src/components/rewind/rewind-menu.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -42,7 +43,16 @@ export const RewindMenu = memo(function RewindMenu({ testID = "rewind-menu", }: RewindMenuProps) { const { theme } = useUnistyles(); - const items = useRewindCapabilities(capabilities); + const { t } = useTranslation(); + const rewindLabels = useMemo( + () => ({ + conversation: t("rewind.actions.conversation"), + files: t("rewind.actions.files"), + both: t("rewind.actions.both"), + }), + [t], + ); + const items = useRewindCapabilities(capabilities, rewindLabels); const [isOpen, setIsOpen] = useState(false); const [pendingMode, setPendingMode] = useState(null); const isLocked = isPendingProp || pendingMode !== null; @@ -79,10 +89,10 @@ export const RewindMenu = memo(function RewindMenu({ const tooltipContent = useMemo( () => ( - Rewind to this message + {t("rewind.tooltip")} ), - [], + [t], ); if (items.length === 0) { @@ -95,7 +105,7 @@ export const RewindMenu = memo(function RewindMenu({ - This action cannot be undone + {t("rewind.warning")} {items.map((item) => ( diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index 8428a422a..e684c4bf5 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { useToast } from "@/contexts/toast-context"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -25,11 +26,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { isPending: boolean; } { const toast = useToast(); + const { t } = useTranslation(); const composerRestore = useRewindComposerRestore(); const { isPending, mutateAsync } = useMutation({ mutationFn: async ({ mode }: RewindAgentInput) => { if (!input.client || !input.agentId || !input.messageId) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } await input.client.rewindAgent(input.agentId, input.messageId, mode); if (mode !== "files") { @@ -59,7 +61,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText); }, onError: (error) => { - toast.error(error instanceof Error ? error.message : "Failed to rewind agent"); + toast.error(error instanceof Error ? error.message : t("rewind.errors.failed")); }, }); diff --git a/packages/app/src/components/rewind/use-rewind-capabilities.test.ts b/packages/app/src/components/rewind/use-rewind-capabilities.test.ts index 62899a6f3..7bba33cbc 100644 --- a/packages/app/src/components/rewind/use-rewind-capabilities.test.ts +++ b/packages/app/src/components/rewind/use-rewind-capabilities.test.ts @@ -33,4 +33,21 @@ describe("resolveRewindMenuItems", () => { }, ]); }); + + test("uses caller-provided labels for available capabilities", () => { + expect( + resolveRewindMenuItems( + { + supportsRewindConversation: true, + supportsRewindFiles: true, + supportsRewindBoth: false, + }, + { + conversation: "Conversation label", + files: "Files label", + both: "Both label", + }, + ).map((item) => item.label), + ).toEqual(["Conversation label", "Files label"]); + }); }); diff --git a/packages/app/src/components/rewind/use-rewind-capabilities.ts b/packages/app/src/components/rewind/use-rewind-capabilities.ts index 0d66fd825..d125bfce3 100644 --- a/packages/app/src/components/rewind/use-rewind-capabilities.ts +++ b/packages/app/src/components/rewind/use-rewind-capabilities.ts @@ -9,6 +9,18 @@ export interface RewindMenuItem { testID: string; } +export interface RewindMenuLabels { + conversation: string; + files: string; + both: string; +} + +const DEFAULT_REWIND_MENU_LABELS: RewindMenuLabels = { + conversation: "Rewind conversation", + files: "Rewind files", + both: "Rewind conversation and files", +}; + export function resolveRewindMenuItems( capabilities: | Pick< @@ -17,29 +29,31 @@ export function resolveRewindMenuItems( > | null | undefined, + labelsInput?: Partial, ): RewindMenuItem[] { if (!capabilities) { return []; } + const labels = { ...DEFAULT_REWIND_MENU_LABELS, ...labelsInput }; const items: RewindMenuItem[] = []; if (capabilities.supportsRewindConversation) { items.push({ mode: "conversation", - label: "Rewind conversation", + label: labels.conversation, testID: "rewind-menu-conversation", }); } if (capabilities.supportsRewindFiles) { items.push({ mode: "files", - label: "Rewind files", + label: labels.files, testID: "rewind-menu-files", }); } if (capabilities.supportsRewindBoth) { items.push({ mode: "both", - label: "Rewind conversation and files", + label: labels.both, testID: "rewind-menu-both", }); } @@ -48,6 +62,7 @@ export function resolveRewindMenuItems( export function useRewindCapabilities( capabilities: Parameters[0], + labels?: Partial, ): RewindMenuItem[] { - return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]); + return useMemo(() => resolveRewindMenuItems(capabilities, labels), [capabilities, labels]); } diff --git a/packages/app/src/components/sidebar-callout.tsx b/packages/app/src/components/sidebar-callout.tsx index e5ad12e83..de379a63e 100644 --- a/packages/app/src/components/sidebar-callout.tsx +++ b/packages/app/src/components/sidebar-callout.tsx @@ -2,6 +2,7 @@ import { X } from "lucide-react-native"; import { useCallback, useMemo, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; export type SidebarCalloutActionVariant = "primary" | "secondary"; @@ -38,6 +39,7 @@ export function SidebarCallout({ onDismiss, testID, }: SidebarCalloutProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const visibleActions = (actions ?? []).slice(0, 2); const hasHeader = title != null || icon != null; @@ -69,7 +71,7 @@ export function SidebarCallout({ hitSlop={8} style={styles.dismissButton} testID={testID ? `${testID}-dismiss` : undefined} - accessibilityLabel="Dismiss" + accessibilityLabel={t("sidebarCallout.dismiss")} accessibilityRole="button" > {({ hovered }) => ( diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 40cc83cb5..9ec7405b4 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,6 +27,7 @@ import { type MutableRefObject, type Ref, } from "react"; +import { useTranslation } from "react-i18next"; import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace, @@ -123,7 +124,10 @@ import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; -import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; +import { + confirmRiskyWorktreeArchive, + type WorktreeArchiveWarningLabels, +} from "@/git/worktree-archive-warning"; import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, @@ -183,6 +187,40 @@ const syncedLoaderColorMapping = (theme: Theme) => ({ : theme.colors.palette.amber[500], }); +function getWorktreeArchiveWarningLabels( + t: (key: string, options?: Record) => string, +): WorktreeArchiveWarningLabels { + return { + title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }), + confirm: t("workspace.git.actions.archiveWarning.confirm"), + cancel: t("workspace.git.actions.archiveWarning.cancel"), + uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"), + uncommittedChangesWithDiff: (diffStat) => + t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }), + addedLine: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.addedLine" + : "workspace.git.actions.archiveWarning.addedLines", + { count }, + ), + deletedLine: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.deletedLine" + : "workspace.git.actions.archiveWarning.deletedLines", + { count }, + ), + unpushedCommit: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.unpushedCommit" + : "workspace.git.actions.archiveWarning.unpushedCommits", + { count }, + ), + }; +} + function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": @@ -307,6 +345,7 @@ function getWorkspaceArchiveStatus( } export function PrBadge({ hint }: { hint: PrHint }) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const handlePressIn = useCallback((event: GestureResponderEvent) => { @@ -330,7 +369,9 @@ export function PrBadge({ hint }: { hint: PrHint }) { return ( void; removeProjectStatus: "idle" | "pending" | "success"; }) { + const { t } = useTranslation(); const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { if (projectKey.trim().length === 0) return; @@ -589,16 +631,16 @@ function ProjectKebabMenu({ ?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath }) ?.catch((error) => { console.warn("[sidebar] openNew failed", error); - toast.error("Couldn't open a new window"); + toast.error(t("sidebar.project.actions.openNewWindowFailed")); }); - }, [projectPath, toast]); + }, [projectPath, t, toast]); return ( {renderKebabTriggerIcon} @@ -610,7 +652,7 @@ function ProjectKebabMenu({ leading={settingsLeadingIcon} onSelect={handleOpenProjectSettings} > - Open project settings + {t("sidebar.project.actions.openSettings")} ) : null} {canOpenInNewWindow ? ( @@ -619,17 +661,17 @@ function ProjectKebabMenu({ leading={openInNewWindowLeadingIcon} onSelect={handleOpenInNewWindow} > - Open in new window + {t("sidebar.project.actions.openNewWindow")} ) : null} - Remove project + {t("sidebar.project.actions.remove")} @@ -669,13 +711,16 @@ function WorkspaceRowRightGroup({ onCopyPath?: () => void; onRename?: () => void; }) { + const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebabInSlot = showKebab && !showShortcut; const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); return ( <> - {isCreating ? Creating... : null} + {isCreating ? ( + {t("sidebar.workspace.status.creating")} + ) : null} {shouldRenderActionSlot ? ( (archiveShortcutKeys ? : null), [archiveShortcutKeys], @@ -743,7 +789,7 @@ function WorkspaceKebabMenu({ hitSlop={8} style={workspaceKebabStyle} accessibilityRole={platformIsWeb ? undefined : "button"} - accessibilityLabel="Workspace actions" + accessibilityLabel={t("sidebar.workspace.actions.menu")} testID={`sidebar-workspace-kebab-${workspaceKey}`} > {renderKebabTriggerIcon} @@ -755,7 +801,7 @@ function WorkspaceKebabMenu({ leading={copyLeadingIcon} onSelect={onCopyPath} > - Copy path + {t("sidebar.workspace.actions.copyPath")} ) : null} {onCopyBranchName ? ( @@ -764,7 +810,7 @@ function WorkspaceKebabMenu({ leading={copyLeadingIcon} onSelect={onCopyBranchName} > - Copy branch name + {t("sidebar.workspace.actions.copyBranchName")} ) : null} {onRename ? ( @@ -773,7 +819,7 @@ function WorkspaceKebabMenu({ leading={renameLeadingIcon} onSelect={onRename} > - Rename workspace + {t("sidebar.workspace.actions.rename")} ) : null} {onMarkAsRead ? ( @@ -793,7 +839,7 @@ function WorkspaceKebabMenu({ pendingLabel={archivePendingLabel} onSelect={onArchive} > - {archiveLabel ?? "Archive"} + {archiveLabel ?? t("sidebar.workspace.actions.archive")} @@ -912,6 +958,7 @@ function NewWorktreeButton({ testID: string; showShortcutHint?: boolean; }) { + const { t } = useTranslation(); const newWorktreeKeys = useShortcutKeys("new-worktree"); const pressableStyle = useCallback( @@ -940,7 +987,9 @@ function NewWorktreeButton({ onPress={handlePress} disabled={loading} accessibilityRole={platformIsWeb ? undefined : "button"} - accessibilityLabel={`Create a new workspace for ${displayName}`} + accessibilityLabel={t("sidebar.workspace.actions.createWorkspaceFor", { + projectName: displayName, + })} testID={testID} > {({ hovered, pressed }) => @@ -959,7 +1008,9 @@ function NewWorktreeButton({ - New workspace + + {t("sidebar.workspace.actions.newWorkspace")} + {showShortcutHint && newWorktreeKeys ? ( ) : null} @@ -1481,6 +1532,7 @@ function WorkspaceRowWithMenu({ canCopyBranchName: boolean; isCreating?: boolean; }) { + const { t } = useTranslation(); const toast = useToast(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const queryClient = useQueryClient(); @@ -1513,12 +1565,15 @@ function WorkspaceRowWithMenu({ return; } - const confirmed = await confirmRiskyWorktreeArchive({ - worktreeName: workspace.name, - isDirty: workspace.archiveHasUncommittedChanges, - aheadOfOrigin: workspace.archiveUnpushedCommitCount, - diffStat: workspace.diffStat, - }); + const confirmed = await confirmRiskyWorktreeArchive( + { + worktreeName: workspace.name, + isDirty: workspace.archiveHasUncommittedChanges, + aheadOfOrigin: workspace.archiveUnpushedCommitCount, + diffStat: workspace.diffStat, + }, + getWorktreeArchiveWarningLabels(t), + ); if (!confirmed) { return; @@ -1530,12 +1585,16 @@ function WorkspaceRowWithMenu({ workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Workspace path not available"); + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); return; } if (!archiveDirectory) { - toast.error("Workspace path not available"); + toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable")); return; } @@ -1546,10 +1605,11 @@ function WorkspaceRowWithMenu({ cwd: archiveDirectory, worktreePath: archiveDirectory, }).catch((error) => { - const message = error instanceof Error ? error.message : "Failed to archive worktree"; + const message = + error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"); toast.error(message); }); - }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); + }, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]); const handleArchiveWorktree = useCallback(() => { void archiveWorktreeAfterConfirmation(); @@ -1561,10 +1621,10 @@ function WorkspaceRowWithMenu({ } const confirmed = await confirmDialog({ - title: "Hide workspace?", - message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Hide", - cancelLabel: "Cancel", + title: t("sidebar.workspace.confirmations.hideTitle"), + message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }), + confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"), + cancelLabel: t("sidebar.workspace.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -1573,7 +1633,7 @@ function WorkspaceRowWithMenu({ const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - toast.error("Host is not connected"); + toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; } @@ -1585,11 +1645,13 @@ function WorkspaceRowWithMenu({ afterHide: redirectAfterArchive, }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"), + ); } finally { setIsArchivingWorkspace(false); } - }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); + }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); const handleArchiveWorkspace = useCallback(() => { void hideWorkspaceAfterConfirmation(); @@ -1603,23 +1665,27 @@ function WorkspaceRowWithMenu({ workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Workspace path not available"); + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); return; } void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied("Path copied"); - }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); + toast.copied(t("sidebar.workspace.toasts.pathCopied")); + }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); - toast.copied("Branch name copied"); - }, [toast, workspace.name]); + toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); + }, [t, toast, workspace.name]); const renameMutation = useMutation({ mutationFn: async (branch: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } const targetCwd = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, @@ -1627,7 +1693,7 @@ function WorkspaceRowWithMenu({ }); const payload = await client.renameBranch({ cwd: targetCwd, branch }); if (!payload.success || payload.error) { - throw new Error(payload.error?.message ?? "Failed to rename branch"); + throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName")); } return { targetCwd }; }, @@ -1654,11 +1720,14 @@ function WorkspaceRowWithMenu({ [renameMutation], ); - const validateRenameSlug = useCallback((value: string): string | null => { - const result = validateBranchSlug(slugify(value)); - if (result.valid) return null; - return result.error ?? "Invalid branch name"; - }, []); + const validateRenameSlug = useCallback( + (value: string): string | null => { + const result = validateBranchSlug(slugify(value)); + if (result.valid) return null; + return result.error ?? t("sidebar.workspace.rename.invalidBranchName"); + }, + [t], + ); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ @@ -1700,9 +1769,17 @@ function WorkspaceRowWithMenu({ isCreating={isCreating} dragHandleProps={dragHandleProps} menuController={null} - archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"} + archiveLabel={ + isWorktree + ? t("sidebar.workspace.actions.archiveWorktree") + : t("sidebar.workspace.actions.hideFromSidebar") + } archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)} - archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."} + archivePendingLabel={ + isWorktree + ? t("sidebar.workspace.actions.archiving") + : t("sidebar.workspace.actions.hiding") + } onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace} onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} onCopyPath={handleCopyPath} @@ -1712,10 +1789,10 @@ function WorkspaceRowWithMenu({ /> { const confirmed = await confirmDialog({ - title: "Hide workspace?", - message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Hide", - cancelLabel: "Cancel", + title: t("sidebar.workspace.confirmations.hideTitle"), + message: t("sidebar.workspace.confirmations.hideMessage", { + workspaceName: workspace.name, + }), + confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"), + cancelLabel: t("sidebar.workspace.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -1781,7 +1861,7 @@ function NonGitProjectRowWithMenuContent({ const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - toast.error("Host is not connected"); + toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; } @@ -1794,13 +1874,15 @@ function NonGitProjectRowWithMenuContent({ afterHide: redirectAfterArchive, }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"), + ); } finally { setIsArchivingWorkspace(false); } })(); })(); - }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); + }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); return ( <> @@ -1831,11 +1913,11 @@ function NonGitProjectRowWithMenuContent({ - Hide from sidebar + {t("sidebar.workspace.actions.hideFromSidebar")} @@ -2212,6 +2294,7 @@ function ProjectBlock({ ); const toast = useToast(); + const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); const handleRemoveProject = useCallback(() => { @@ -2221,10 +2304,10 @@ function ProjectBlock({ void (async () => { const confirmed = await confirmDialog({ - title: "Remove project?", - message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: "Remove", - cancelLabel: "Cancel", + title: t("sidebar.project.confirmations.removeTitle"), + message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }), + confirmLabel: t("sidebar.project.confirmations.removeConfirm"), + cancelLabel: t("sidebar.project.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -2233,7 +2316,7 @@ function ProjectBlock({ const client = getHostRuntimeStore().getClient(serverId); if (!client) { - toast.error("Host is not connected"); + toast.error(t("sidebar.project.toasts.hostDisconnected")); return; } @@ -2243,13 +2326,13 @@ function ProjectBlock({ workspaces: project.workspaces, }).then((failures) => { if (failures.length > 0) { - toast.error("Failed to remove some workspaces"); + toast.error(t("sidebar.project.toasts.removeFailed")); } setIsRemovingProject(false); return; }); })(); - }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); + }, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]); const flattenedRowWorkspaceId = rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null; @@ -2476,6 +2559,7 @@ function ProjectModeList({ }: Omit & { pathname: string; }) { + const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), @@ -2688,10 +2772,10 @@ function ProjectModeList({ <> {projects.length === 0 ? ( - No projects yet - Add a project to get started + {t("sidebar.project.empty.title")} + {t("sidebar.project.empty.description")} ) : ( diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index dc092304f..c861fdc74 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -348,6 +349,7 @@ function StatusWorkspaceRowWithMenu({ showShortcutBadge: boolean; onPress: () => void; }) { + const { t } = useTranslation(); const toast = useToast(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const queryClient = useQueryClient(); @@ -419,7 +421,7 @@ function StatusWorkspaceRowWithMenu({ if (!confirmed) return; const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - toast.error("Host is not connected"); + toast.error(t("workspace.terminal.hostDisconnected")); return; } setIsArchivingWorkspace(true); @@ -434,7 +436,7 @@ function StatusWorkspaceRowWithMenu({ } finally { setIsArchivingWorkspace(false); } - }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); + }, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; @@ -459,7 +461,7 @@ function StatusWorkspaceRowWithMenu({ const renameMutation = useMutation({ mutationFn: async (branch: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) throw new Error("Host is not connected"); + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); const targetCwd = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index fde510ee8..2b9cac093 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -28,6 +28,7 @@ import { } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { View, Text } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; @@ -660,6 +661,7 @@ function DragOverlayTabChipInner({ normalizedServerId: string; normalizedWorkspaceId: string; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const chipStyle = useMemo( @@ -684,7 +686,8 @@ function DragOverlayTabChipInner({ workspaceId={normalizedWorkspaceId} > {(presentation) => { - const label = presentation.titleState === "loading" ? "Loading..." : presentation.label; + const label = + presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; return ( diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 27d99145d..12b28d976 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -10,6 +10,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated" import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useAppVisible } from "@/hooks/use-app-visible"; @@ -169,6 +170,7 @@ export function TerminalPane({ onOpenFileExplorer, onOpenWorkspaceFile, }: TerminalPaneProps) { + const { t } = useTranslation(); const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); const { settings } = useAppSettings(); @@ -750,7 +752,7 @@ export function TerminalPane({ if (!client || !isConnected) { return ( - Host is not connected + {t("workspace.terminal.hostDisconnected")} ); } diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index e4c5d55f2..082a03787 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; @@ -49,6 +50,7 @@ export function useToastHost(): { dismiss: () => void; } { const { theme } = useUnistyles(); + const { t } = useTranslation(); const [toast, setToast] = useState(null); const idRef = useRef(0); @@ -85,13 +87,13 @@ export function useToastHost(): { () => ({ show, copied: (label?: string) => - show(label ? `Copied ${label}` : "Copied", { + show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), { variant: "success", icon: , }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), - [show, theme.colors.foreground], + [show, theme.colors.foreground, t], ); const dismiss = useCallback(() => { diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index b83dcc56d..a77156478 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -8,6 +8,8 @@ import { } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; @@ -240,11 +242,12 @@ function WorktreeSetupDetailSection({ function resolveSubAgentFallbackHeader( subAgentType: string | null | undefined, description: string | null | undefined, + fallbackText: string, ): string { if (subAgentType && description) { return `${subAgentType}: ${description}`; } - return subAgentType ?? description ?? "Sub-agent activity"; + return subAgentType ?? description ?? fallbackText; } interface SubAgentDetailProps { @@ -365,8 +368,13 @@ function SubAgentDetailSection({ description, ds, }: SubAgentDetailProps) { + const { t } = useTranslation(); const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); - const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description); + const fallbackHeader = resolveSubAgentFallbackHeader( + subAgentType, + description, + t("toolCallDetails.subAgentActivity"), + ); const hasActions = actions.length > 0; return ( @@ -594,7 +602,7 @@ interface UnknownDetail { output: unknown; } -function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] { +function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunction): ReactNode[] { const plainInputText = typeof detail.input === "string" && detail.output === null ? detail.input : null; @@ -609,8 +617,8 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod } const sectionsFromTopLevel = [ - { title: "Input", value: detail.input }, - { title: "Output", value: detail.output }, + { title: t("toolCallDetails.input"), value: detail.input }, + { title: t("toolCallDetails.output"), value: detail.output }, ].filter((entry) => hasMeaningfulToolCallDetail({ type: "unknown", @@ -653,6 +661,7 @@ function buildDetailSections( detail: ToolCallDetail | undefined, diffLines: DiffLine[] | undefined, ds: DetailStyles, + t: TFunction, ): ReactNode[] { if (!detail) return []; if (detail.type === "shell") { @@ -723,15 +732,16 @@ function buildDetailSections( return []; } if (detail.type === "unknown") { - return buildUnknownSections(detail, ds); + return buildUnknownSections(detail, ds, t); } return []; } function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) { + const { t } = useTranslation(); return ( - Error + {t("toolCallDetails.error")} ); @@ -786,7 +797,7 @@ function ToolCallDetailsContentInner({ if (showLoadingSkeleton) { return ; } - return No additional details available; + return {t("toolCallDetails.empty")}; } return {sections}; diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 1d0b5c0f5..c6f3af60c 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -10,6 +10,7 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { File, Folder } from "lucide-react-native"; import type { Theme } from "@/styles/theme"; import { getAutocompleteScrollOffset } from "./autocomplete-utils"; @@ -122,11 +123,14 @@ export function Autocomplete({ onSelect, isLoading = false, errorMessage, - loadingText = "Loading...", - emptyText = "No results found", + loadingText, + emptyText, maxHeight = 220, }: AutocompleteProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); + const resolvedLoadingText = loadingText ?? t("common.states.loading"); + const resolvedEmptyText = emptyText ?? t("common.empty.noResults"); const scrollRef = useRef(null); const rowLayoutsRef = useRef>(new Map()); const viewportHeightRef = useRef(0); @@ -213,7 +217,7 @@ export function Autocomplete({ return ( - {loadingText} + {resolvedLoadingText} ); @@ -233,7 +237,7 @@ export function Autocomplete({ return ( - {emptyText} + {resolvedEmptyText} ); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 2440a6bba..edc92b10c 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -23,6 +23,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -1240,15 +1241,15 @@ export function Combobox({ renderOption, onSearchQueryChange, searchable = true, - placeholder = "Search...", + placeholder, searchPlaceholder, - emptyText = "No options match your search.", + emptyText, allowCustomValue = false, customValuePrefix = "Use", customValueDescription, customValueKind, optionsPosition = "below-search", - title = "Select", + title, header, mobileChildrenScrollEnabled = true, presentation, @@ -1263,7 +1264,11 @@ export function Combobox({ anchorRef, children, }: ComboboxProps): ReactElement | null { + const { t } = useTranslation(); const { theme } = useUnistyles(); + const resolvedPlaceholder = placeholder ?? t("common.placeholders.search"); + const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); + const resolvedTitle = title ?? t("common.actions.select"); const isMobile = useIsCompactFormFactor(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); @@ -1519,7 +1524,7 @@ export function Combobox({ [], ); - const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder; + const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder; const hasChildren = Boolean(children); if (isMobile) { @@ -1531,7 +1536,7 @@ export function Combobox({ handleSheetDismiss={handleSheetDismiss} handleIndicatorStyle={handleIndicatorStyle} titleColor={titleColor} - title={title} + title={resolvedTitle} header={header} onClose={handleClose} stickyHeader={stickyHeader} @@ -1547,7 +1552,7 @@ export function Combobox({ orderedVisibleOptions={orderedVisibleOptions} value={value} activeIndex={activeIndex} - emptyText={emptyText} + emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} > @@ -1580,7 +1585,7 @@ export function Combobox({ orderedVisibleOptions={orderedVisibleOptions} value={value} activeIndex={activeIndex} - emptyText={emptyText} + emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} hasChildren={hasChildren} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index dd75578c2..ea6ba574f 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -12,6 +12,7 @@ import { type ReactNode, type Ref, } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Dimensions, @@ -385,6 +386,7 @@ export function ContextMenuContent({ mobileMode?: MobileMenuMode; testID?: string; }>): ReactElement | null { + const { t } = useTranslation(); const context = useContextMenuContext("ContextMenuContent"); const { theme } = useUnistyles(); const webScrollbarStyle = useWebScrollbarStyle(); @@ -560,7 +562,7 @@ export function ContextMenuContent({ ): ReactElement | null { + const { t } = useTranslation(); const { open, setOpen, triggerRef, flushPendingSelect } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); @@ -625,7 +627,7 @@ export function DropdownMenuContent({ - Welcome to Paseo - Connect your computer to get started + {t("onboarding.title")} + {t("onboarding.subtitle")} {isNative ? ( paseo.sh @@ -288,7 +290,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { style={styles.settingsButton} testID="welcome-open-settings" > - Settings + {t("onboarding.actions.settings")} {appVersionText} diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 9c32fdd58..333e94e82 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from "react"; import { Dimensions, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native"; @@ -212,6 +213,7 @@ function WorkspaceHoverCardContent({ triggerRef: React.RefObject; contentRef: React.RefObject; }): ReactElement | null { + const { t } = useTranslation(); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -274,7 +276,7 @@ function WorkspaceHoverCardContent({ collapsable={false} onLayout={handleLayout} accessibilityRole="menu" - accessibilityLabel="Workspace scripts" + accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")} testID="workspace-hover-card" style={styles.card} frameStyle={frameStyle} @@ -373,6 +375,7 @@ function ChecksSummaryContent({ checks: NonNullable; hovered: boolean; }) { + const { t } = useTranslation(); const { passed, failed, pending } = getChecksSummaryCounts(checks); const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel; @@ -385,7 +388,7 @@ function ChecksSummaryContent({ ) : ( )} - Checks + {t("workspace.git.pr.sections.checks")} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 5792fade9..e999aed87 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Image, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Composer } from "@/composer"; @@ -96,8 +97,13 @@ async function callWorkspaceCreation({ return connectedClient.openProject(input.cwd); } -function failureMessageForCreationMethod(method: "create_worktree" | "open_project") { - return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project"; +function failureMessageForCreationMethod( + method: "create_worktree" | "open_project", + t: ReturnType["t"], +) { + return method === "create_worktree" + ? t("workspaceSetup.errors.failedCreateWorktree") + : t("workspaceSetup.errors.failedOpenProject"); } function buildCreateAgentOptions({ @@ -140,6 +146,7 @@ function buildCreateAgentOptions({ } export function WorkspaceSetupDialog() { + const { t } = useTranslation(); const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); @@ -170,7 +177,7 @@ export function WorkspaceSetupDialog() { }); const composerState = chatDraft.composerState; if (!composerState && pendingWorkspaceSetup) { - throw new Error("Workspace setup composer state is required"); + throw new Error(t("workspaceSetup.errors.composerStateRequired")); } const { icon: projectIcon } = useProjectIconQuery({ @@ -218,15 +225,15 @@ export function WorkspaceSetupDialog() { const withConnectedClient = useCallback(() => { if (!client || !isConnected) { - throw new Error("Host is not connected"); + throw new Error(t("workspaceSetup.errors.hostDisconnected")); } return client; - }, [client, isConnected]); + }, [client, isConnected, t]); const ensureWorkspace = useCallback( async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { if (!pendingWorkspaceSetup) { - throw new Error("No workspace setup is pending"); + throw new Error(t("workspaceSetup.errors.pendingRequired")); } if (createdWorkspace) { @@ -242,7 +249,7 @@ export function WorkspaceSetupDialog() { if (payload.error || !payload.workspace) { throw new Error( - payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod), + payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod, t), ); } @@ -259,6 +266,7 @@ export function WorkspaceSetupDialog() { mergeWorkspaces, pendingWorkspaceSetup, setHasHydratedWorkspaces, + t, withConnectedClient, ], ); @@ -284,10 +292,10 @@ export function WorkspaceSetupDialog() { const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); const connectedClient = withConnectedClient(); if (!composerState) { - throw new Error("Workspace setup composer state is required"); + throw new Error(t("workspaceSetup.errors.composerStateRequired")); } if (!composerState.selectedProvider) { - throw new Error("Select a model"); + throw new Error(t("workspaceSetup.errors.selectModel")); } const wirePayload = splitComposerAttachmentsForSubmit(attachments); @@ -334,6 +342,7 @@ export function WorkspaceSetupDialog() { serverId, setAgents, ensureWorkspace, + t, toast, withConnectedClient, ], @@ -392,8 +401,8 @@ export function WorkspaceSetupDialog() { ); const sheetHeader = useMemo( - () => ({ title: "Create workspace", subtitle: subtitleContent }), - [subtitleContent], + () => ({ title: t("workspaceSetup.title"), subtitle: subtitleContent }), + [subtitleContent, t], ); if (!pendingWorkspaceSetup || !sourceDirectory) { diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index ab67e4192..371af8f22 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -1,4 +1,5 @@ import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; +import { i18n } from "@/i18n/i18next"; import { buildProjectSettingsRoute } from "@/utils/host-routes"; export interface WorktreeSetupWorkspaceInput { @@ -64,10 +65,9 @@ export function buildWorktreeSetupCalloutPolicy( id: calloutKey, dismissalKey: calloutKey, priority: 100, - title: "Set up worktree scripts", - description: - "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", - actionLabel: "Open project settings", + title: i18n.t("sidebar.worktreeSetup.title"), + description: i18n.t("sidebar.worktreeSetup.description"), + actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"), projectSettingsRoute: buildProjectSettingsRoute(project.projectKey), testID: `worktree-setup-callout-${project.projectKey}`, }; diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 82d61c63d..e99ffc5bc 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -17,6 +17,7 @@ import { type UserMessageItem, } from "@/types/stream"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; +import { i18n } from "@/i18n/i18next"; export interface QueuedComposerMessage { id: string; @@ -244,6 +245,7 @@ export interface SendQueuedComposerMessageNowInput { messageId: string; queue: QueueWriter; submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise; + failedToSendMessage?: string; } export type SendQueuedComposerMessageNowResult = @@ -275,7 +277,10 @@ export async function sendQueuedComposerMessageNow( }); return { status: "failed", - errorMessage: error instanceof Error ? error.message : "Failed to send message", + errorMessage: + error instanceof Error + ? error.message + : (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")), }; } } diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index f8c81522f..0680b9414 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -8,6 +8,7 @@ import { type ReactNode, type RefObject, } from "react"; +import { useTranslation } from "react-i18next"; import { View, Text, @@ -56,7 +57,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife import { getFeatureHighlightColor, getFeatureTooltip, - getAgentControlHint, + getAgentControlHintKey, formatThinkingOptionLabel, resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; @@ -415,6 +416,7 @@ function ControlledAgentControls({ isCompactLayout, }: ControlledAgentControlsProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); const isCompact = isCompactLayout ?? isCompactFormFactor; const [activeSheet, setActiveSheet] = useState(null); @@ -432,7 +434,11 @@ function ControlledAgentControls({ onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, ); - const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); + const displayProvider = findOptionLabel( + providerOptions, + selectedProviderId, + t("agentControls.provider.fallback"), + ); const formattedThinkingOptions = useMemo( () => toThinkingControlOptions(thinkingOptions), [thinkingOptions], @@ -440,7 +446,7 @@ function ControlledAgentControls({ const displayThinking = findOptionLabel( formattedThinkingOptions, selectedThinkingOptionId, - formattedThinkingOptions[0]?.label ?? "Unknown", + formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"), ); const ProviderIcon = resolveProviderIcon(provider); @@ -709,6 +715,7 @@ const DESKTOP_SEARCH_THRESHOLD = 6; function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const { provider, providerOptions, @@ -764,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onPress={handleProviderPress} style={providerPressableStyle} accessibilityRole="button" - accessibilityLabel="Select agent provider" + accessibilityLabel={t("agentControls.provider.select")} testID="agent-provider-selector" > {displayProvider} @@ -805,7 +812,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { - {getAgentControlHint("model")} + {t(getAgentControlHintKey("model"))} ) : null} @@ -821,7 +828,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onPress={handleThinkingPress} style={thinkingPressableStyle} accessibilityRole="button" - accessibilityLabel={`Select thinking option (${displayThinking})`} + accessibilityLabel={t("agentControls.thinking.selectWithValue", { + value: displayThinking, + })} testID="agent-thinking-selector" > @@ -830,7 +839,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { - {getAgentControlHint("thinking")} + {t(getAgentControlHintKey("thinking"))} 0; const hasFeatures = Boolean(features && features.length > 0); + const featuresSheetHeader = useMemo( + () => ({ title: t("agentControls.features.title") }), + [t], + ); const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); @@ -1012,7 +1026,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { disabled={disabled || !canSelectThinking} style={thinkingButtonStyle} accessibilityRole="button" - accessibilityLabel="Select thinking option" + accessibilityLabel={t("agentControls.thinking.select")} testID="agent-controls-thinking" > @@ -1025,7 +1039,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { disabled={disabled} style={featuresButtonStyle} accessibilityRole="button" - accessibilityLabel="Open agent features" + accessibilityLabel={t("agentControls.features.open")} testID="agent-controls-features" > @@ -1038,7 +1052,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { value={selectedThinkingOptionId ?? ""} onSelect={handleSelectThinkingAndClose} searchable={false} - title="Thinking" + title={t("agentControls.thinking.title")} open={activeSheet === "thinking"} onOpenChange={handleThinkingSheetOpenChange} anchorRef={thinkingAnchorRef} @@ -1047,7 +1061,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { ) : null} void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const featureSelector: AgentControlSelector = `feature-${feature.id}`; const handleFeatureOpenChange = useMemo( @@ -1257,7 +1272,9 @@ function SheetFeatureItem({ )} /> {feature.label} - {feature.value ? "On" : "Off"} + + {feature.value ? t("agentControls.features.on") : t("agentControls.features.off")} + ); @@ -1344,8 +1361,6 @@ function ThinkingComboboxOption({ ); } -const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" }; - export const AgentControls = memo(function AgentControls({ agentId, serverId, diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 2032e8715..00a7a81a3 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -7,6 +7,7 @@ import { type ComponentType, type ReactElement, } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; @@ -108,6 +109,7 @@ function AgentModeControlView({ disabled = false, }: AgentModeControlViewProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const anchorRef = useRef(null); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -182,14 +184,14 @@ function AgentModeControlView({ const sheetHeader = useMemo( () => ({ - title: "Mode", + title: t("agentControls.mode.title"), search: { onChange: setSearchQuery, - placeholder: "Search modes...", + placeholder: t("agentControls.mode.searchPlaceholder"), testID: "mode-search-input", }, }), - [], + [t], ); if (!selectedMode) return null; @@ -203,7 +205,9 @@ function AgentModeControlView({ onPress={handlePress} style={pressableStyle} accessibilityRole="button" - accessibilityLabel={`Select agent mode (${selectedModeLabel})`} + accessibilityLabel={t("agentControls.mode.selectWithValue", { + value: selectedModeLabel, + })} testID="mode-control" > {Icon ? : null} diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 63b25dcaa..1ab8e0b22 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -3,17 +3,17 @@ import { formatAgentModeLabel, getFeatureHighlightColor, getFeatureTooltip, - getAgentControlHint, + getAgentControlHintKey, formatThinkingOptionLabel, normalizeModelId, resolveAgentModelSelection, } from "./utils"; -describe("getAgentControlHint", () => { - it("explains what each editable agent control does", () => { - expect(getAgentControlHint("thinking")).toBe("Thinking mode"); - expect(getAgentControlHint("model")).toBe("Change model"); - expect(getAgentControlHint("mode")).toBe("Change permission mode"); +describe("getAgentControlHintKey", () => { + it("returns translation keys for each editable agent control hint", () => { + expect(getAgentControlHintKey("thinking")).toBe("agentControls.hints.thinking"); + expect(getAgentControlHintKey("model")).toBe("agentControls.hints.model"); + expect(getAgentControlHintKey("mode")).toBe("agentControls.hints.mode"); }); }); diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 3e4dce724..e2229a0e9 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,16 +1,21 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { i18n } from "@/i18n/i18next"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; +export type AgentControlHintKey = + | "agentControls.hints.thinking" + | "agentControls.hints.model" + | "agentControls.hints.mode"; -export function getAgentControlHint(selector: ExplainedAgentControl): string { +export function getAgentControlHintKey(selector: ExplainedAgentControl): AgentControlHintKey { switch (selector) { case "thinking": - return "Thinking mode"; + return "agentControls.hints.thinking"; case "model": - return "Change model"; + return "agentControls.hints.model"; case "mode": - return "Change permission mode"; + return "agentControls.hints.mode"; default: throw new Error("unreachable"); } @@ -79,7 +84,7 @@ export function formatThinkingOptionLabel(option: ControlLabelInput): string { const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase(); if (compactId === "xhigh" || compactLabel === "xhigh") { - return "Extra high"; + return i18n.t("agentControls.thinking.extraHigh"); } return formatControlLabel(option, true); @@ -143,17 +148,19 @@ function resolveModelDisplay( selectedModel: AgentModelDefinition | null, preferredModelId: string | null, fallbackModel: AgentModelDefinition | null, + unknownModelLabel: string, ): { activeModelId: string | null; displayModel: string } { return { activeModelId: selectedModel?.id ?? preferredModelId ?? null, displayModel: - selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model", + selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? unknownModelLabel, }; } function resolveThinkingDisplay( effectiveThinking: ThinkingOption | null, selectedThinkingId: string | null, + unknownThinkingLabel: string, ): string { if (effectiveThinking) { return formatThinkingOptionLabel(effectiveThinking); @@ -163,7 +170,7 @@ function resolveThinkingDisplay( return formatThinkingOptionLabel({ id: selectedThinkingId }); } - return "Unknown"; + return unknownThinkingLabel; } export function resolveAgentModelSelection(input: { @@ -189,13 +196,18 @@ export function resolveAgentModelSelection(input: { selectedModel, preferredModelId, fallbackModel, + i18n.t("agentControls.model.unknown"), ); const thinkingOptions = selectedModel?.thinkingOptions ?? null; const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel); const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId); const selectedThinkingId = effectiveThinking?.id ?? null; - const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId); + const displayThinking = resolveThinkingDisplay( + effectiveThinking, + selectedThinkingId, + i18n.t("agentControls.thinking.unknown"), + ); return { selectedModel, diff --git a/packages/app/src/composer/attachments/workspace.tsx b/packages/app/src/composer/attachments/workspace.tsx index 83a013738..b8b088679 100644 --- a/packages/app/src/composer/attachments/workspace.tsx +++ b/packages/app/src/composer/attachments/workspace.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { MessageSquareCode, MousePointer2 } from "lucide-react-native"; import type { @@ -231,14 +232,15 @@ function WorkspaceAttachmentPill({ onOpen, onRemove, }: WorkspaceAttachmentPillProps) { + const { t } = useTranslation(); let label: string; if (attachment.kind === "browser_element") { - label = `Element · ${attachment.attachment.tag}`; + label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag }); } else { label = attachment.commentCount === 1 - ? "Review · 1 comment" - : `Review · ${attachment.commentCount} comments`; + ? t("message.attachments.reviewOne") + : t("message.attachments.reviewMany", { count: attachment.commentCount }); } const handleOpen = useCallback(() => { onOpen(attachment); @@ -253,13 +255,13 @@ function WorkspaceAttachmentPill({ onRemove={handleRemove} openAccessibilityLabel={ attachment.kind === "browser_element" - ? "Open browser element attachment" - : "Open review attachment" + ? t("composer.attachments.openBrowserElement") + : t("composer.attachments.openReview") } removeAccessibilityLabel={ attachment.kind === "browser_element" - ? "Remove browser element attachment" - : "Remove review attachment" + ? t("composer.attachments.removeBrowserElement") + : t("composer.attachments.removeReview") } disabled={disabled} > diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 9a9648189..3e85b2a8b 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useReducer } from "react"; +import { useTranslation } from "react-i18next"; import type { ComposerAttachment } from "@/attachments/types"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -105,6 +106,7 @@ export function useDraftAgentCreateFlow({ onCreateSuccess, onCreateError, }: UseDraftAgentCreateFlowOptions) { + const { t } = useTranslation(); const [machine, dispatch] = useReducer( reducer, initialAttempt, @@ -163,7 +165,7 @@ export function useDraftAgentCreateFlow({ async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { const pendingServerId = getPendingServerId(); if (!pendingServerId) { - const error = new Error("No host selected"); + const error = new Error(t("composer.errors.noHostSelected")); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } @@ -204,7 +206,8 @@ export function useDraftAgentCreateFlow({ await onCreateSuccess({ result: createResult.result, attempt }); } catch (error) { - const resolved = error instanceof Error ? error : new Error("Failed to create agent"); + const resolved = + error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); clearPendingCreateAttempt({ draftId }); @@ -222,6 +225,7 @@ export function useDraftAgentCreateFlow({ onBeforeSubmit, onCreateError, onCreateSuccess, + t, updatePendingAgentId, ], ); @@ -229,7 +233,7 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { if (isSubmitting) { - throw new Error("Already loading"); + throw new Error(t("composer.errors.alreadyLoading")); } dispatch({ type: "DRAFT_SET_ERROR", message: "" }); @@ -238,7 +242,7 @@ export function useDraftAgentCreateFlow({ const trimmedPrompt = text.trim(); if (!trimmedPrompt && !allowEmptyText) { - const error = new Error("Initial prompt is required"); + const error = new Error(t("composer.errors.initialPromptRequired")); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } @@ -256,7 +260,7 @@ export function useDraftAgentCreateFlow({ const pendingServerId = getPendingServerId(); if (!pendingServerId) { - const error = new Error("No host selected"); + const error = new Error(t("composer.errors.noHostSelected")); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } @@ -294,6 +298,7 @@ export function useDraftAgentCreateFlow({ onCreateStart, runCreateAttempt, setPendingCreateAttempt, + t, validateBeforeSubmit, ], ); diff --git a/packages/app/src/composer/draft/import-pill.tsx b/packages/app/src/composer/draft/import-pill.tsx index 98b452dc5..743693f2c 100644 --- a/packages/app/src/composer/draft/import-pill.tsx +++ b/packages/app/src/composer/draft/import-pill.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Import as ImportIcon } from "lucide-react-native"; import type { Theme } from "@/styles/theme"; @@ -13,6 +14,7 @@ interface ComposerImportPillProps { } export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); @@ -22,7 +24,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport - Import session + {t("importSession.title")} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index c4bf82cac..461dbcaa4 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -130,6 +131,8 @@ async function submitDraftCreateRequest(input: { effectiveThinkingOptionId: string | null; featureValues: Record | undefined; }; + hostDisconnectedMessage: string; + selectModelMessage: string; }): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> { const { attempt, @@ -146,12 +149,12 @@ async function submitDraftCreateRequest(input: { invariant(workspaceDirectory, "Workspace directory is required"); invariant(workspaceExecutionAuthority, "Workspace authority is required"); if (!client) { - throw new Error("Host is not connected"); + throw new Error(input.hostDisconnectedMessage); } const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { - throw new Error("Select a model"); + throw new Error(input.selectModelMessage); } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, @@ -199,6 +202,7 @@ function buildDraftAgentSnapshot(input: { selectedProvider: string | null; agentControls: { features?: Agent["features"] }; }; + selectModelMessage: string; }): Agent { const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input; invariant(workspaceDirectory, "Workspace directory is required"); @@ -213,7 +217,7 @@ function buildDraftAgentSnapshot(input: { }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { - throw new Error("Select a model"); + throw new Error(input.selectModelMessage); } return { serverId, @@ -309,6 +313,7 @@ export function WorkspaceDraftAgentTab({ onOpenWorkspaceFile, onOpenImportSheet, }: WorkspaceDraftAgentTabProps) { + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -456,6 +461,7 @@ export function WorkspaceDraftAgentTab({ workspaceDirectory: draftWorkingDirectory, autoSubmitConfig, composerState, + selectModelMessage: t("workspaceSetup.errors.selectModel"), }), createRequest: async ({ attempt, text, images, attachments }) => submitDraftCreateRequest({ @@ -468,6 +474,8 @@ export function WorkspaceDraftAgentTab({ workspaceExecutionAuthority, autoSubmitConfig, composerState, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + selectModelMessage: t("workspaceSetup.errors.selectModel"), }), onCreateSuccess: ({ result }) => { clearDraftInput("sent"); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d92e518d9..3cfd932bc 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -6,6 +6,7 @@ import { Image, type PressableStateCallbackType, } from "react-native"; +import type { TFunction } from "i18next"; import { useState, useEffect, @@ -16,6 +17,7 @@ import { type ReactElement, type ReactNode, } from "react"; +import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; @@ -144,8 +146,10 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean return override ?? formFactor; } -function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string { - return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; +function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string { + return isDesktopWebBreakpoint + ? t("composer.placeholders.desktop") + : t("composer.placeholders.mobile"); } function resolveGithubSearchEnabled( @@ -252,6 +256,12 @@ interface RenderAttachmentTrayArgs { isComposerLocked: boolean; handleOpenAttachment: (attachment: ComposerAttachment) => void; handleRemoveAttachment: (index: number) => void; + labels: { + openImage: string; + removeImage: string; + openGithub: (kind: string, number: number) => string; + removeGithub: (kind: string, number: number) => string; + }; } function renderComposerFooter( @@ -272,8 +282,13 @@ function renderComposerFooter( } function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { - const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } = - args; + const { + selectedAttachments, + isComposerLocked, + handleOpenAttachment, + handleRemoveAttachment, + labels, + } = args; if (selectedAttachments.length === 0) return null; return ( @@ -284,6 +299,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu disabled: isComposerLocked, onOpen: handleOpenAttachment, onRemove: handleRemoveAttachment, + labels, }), )} @@ -294,10 +310,13 @@ interface RenderQueueTrackArgs { queuedMessages: readonly QueuedMessage[]; handleEditQueuedMessage: (id: string) => void; handleSendQueuedNow: (id: string) => Promise; + editLabel: string; + sendNowLabel: string; } function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { - const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args; + const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } = + args; if (queuedMessages.length === 0) return null; return ( @@ -307,6 +326,8 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { item={item} onEdit={handleEditQueuedMessage} onSendNow={handleSendQueuedNow} + editLabel={editLabel} + sendNowLabel={sendNowLabel} /> ))} @@ -319,10 +340,11 @@ interface RenderComposerAttachmentPillArgs { disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; + labels: RenderAttachmentTrayArgs["labels"]; } function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement { - const { attachment, index, disabled, onOpen, onRemove } = args; + const { attachment, index, disabled, onOpen, onRemove, labels } = args; if (attachment.kind === "image") { return ( ); } @@ -352,6 +376,8 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R disabled={disabled} onOpen={onOpen} onRemove={onRemove} + openLabel={labels.openGithub} + removeLabel={labels.removeGithub} /> ); } @@ -472,9 +498,17 @@ interface QueuedMessageRowProps { item: QueuedMessage; onEdit: (id: string) => void; onSendNow: (id: string) => void; + editLabel: string; + sendNowLabel: string; } -function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { +function QueuedMessageRow({ + item, + onEdit, + onSendNow, + editLabel, + sendNowLabel, +}: QueuedMessageRowProps) { const handleEdit = useCallback(() => { onEdit(item.id); }, [onEdit, item.id]); @@ -490,7 +524,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { @@ -498,7 +532,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { @@ -523,6 +557,8 @@ interface ImageAttachmentPillProps { disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; + openLabel: string; + removeLabel: string; } function ImageAttachmentPill({ @@ -531,6 +567,8 @@ function ImageAttachmentPill({ disabled, onOpen, onRemove, + openLabel, + removeLabel, }: ImageAttachmentPillProps) { const handleOpen = useCallback(() => { onOpen(attachment); @@ -543,8 +581,8 @@ function ImageAttachmentPill({ testID="composer-image-attachment-pill" onOpen={handleOpen} onRemove={handleRemove} - openAccessibilityLabel="Open image attachment" - removeAccessibilityLabel="Remove image attachment" + openAccessibilityLabel={openLabel} + removeAccessibilityLabel={removeLabel} disabled={disabled} > @@ -558,6 +596,8 @@ interface GithubAttachmentPillProps { disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; + openLabel: (kind: string, number: number) => string; + removeLabel: (kind: string, number: number) => string; } function GithubAttachmentPill({ @@ -566,6 +606,8 @@ function GithubAttachmentPill({ disabled, onOpen, onRemove, + openLabel, + removeLabel, }: GithubAttachmentPillProps) { const item = attachment.item; const kindLabel = item.kind === "pr" ? "PR" : "issue"; @@ -580,8 +622,8 @@ function GithubAttachmentPill({ testID="composer-github-attachment-pill" onOpen={handleOpen} onRemove={handleRemove} - openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} - removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} + openAccessibilityLabel={openLabel(kindLabel, item.number)} + removeAccessibilityLabel={removeLabel(kindLabel, item.number)} disabled={disabled} > @@ -693,8 +735,6 @@ interface ComposerProps { } const EMPTY_ARRAY: readonly QueuedMessage[] = []; -const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; -const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; const StableMessageInput = memo(MessageInput); function resolveContextWindowValues( @@ -714,6 +754,7 @@ interface ComposerCancelButtonProps { isConnected: boolean; isCancellingAgent: boolean; agentInterruptKeys: ReturnType; + t: TFunction; } function ComposerCancelButton({ @@ -723,8 +764,11 @@ function ComposerCancelButton({ isConnected, isCancellingAgent, agentInterruptKeys, + t, }: ComposerCancelButtonProps) { - const accessibilityLabel = isCancellingAgent ? "Canceling agent" : "Stop agent"; + const accessibilityLabel = isCancellingAgent + ? t("composer.cancel.cancelingAgent") + : t("composer.cancel.stopAgent"); const icon = isCancellingAgent ? ( ) : ( @@ -744,7 +788,7 @@ function ComposerCancelButton({ - Interrupt + {t("composer.cancel.interrupt")} {shortcutNode} @@ -777,6 +821,7 @@ interface ComposerVoiceModeButtonProps { state: PressableStateCallbackType & { hovered?: boolean }, ) => (object | undefined)[]; voiceToggleKeys: ReturnType; + t: TFunction; } interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { @@ -819,6 +864,7 @@ function ComposerVoiceModeButton({ isVoiceSwitching, realtimeVoiceButtonStyle, voiceToggleKeys, + t, }: ComposerVoiceModeButtonProps) { const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( @@ -836,7 +882,7 @@ function ComposerVoiceModeButton({ @@ -844,7 +890,7 @@ function ComposerVoiceModeButton({ - Voice mode + {t("composer.voice.voiceMode")} {shortcutNode} @@ -887,6 +933,7 @@ export function Composer({ externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, }: ComposerProps) { + const { t } = useTranslation(); const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -920,7 +967,7 @@ export function Composer({ const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); - const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout); + const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); const userInput = value; const setUserInput = onChangeText; const { @@ -1070,11 +1117,11 @@ export function Composer({ return; } if (!sendAgentMessageRef.current) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments); }, - [cwd, onMessageSent], + [cwd, onMessageSent, t], ); useEffect(() => { @@ -1088,7 +1135,7 @@ export function Composer({ sendAttachments: ComposerAttachment[], ) => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const stream: AgentStreamWriter = { getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), @@ -1106,7 +1153,7 @@ export function Composer({ }); onAttentionPromptSend?.(); }; - }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]); + }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -1181,6 +1228,7 @@ export function Composer({ onSubmitError: (error) => { console.error("[AgentInput] Failed to send message:", error); }, + failedToSendMessage: t("composer.errors.failedToSend"), }); completeSubmit({ result, @@ -1198,6 +1246,7 @@ export function Composer({ setUserInput, submitBehavior, submitMessage, + t, ], ); @@ -1376,12 +1425,13 @@ export function Composer({ queue: queueWriter, submitMessage: ({ text, attachments: queuedAttachments }) => submitMessage(text, queuedAttachments), + failedToSendMessage: t("composer.errors.failedToSend"), }); if (result.status === "failed") { setSendError(result.errorMessage); } }, - [agentId, queueWriter, submitMessage], + [agentId, queueWriter, submitMessage, t], ); const handleQueue = useCallback( @@ -1433,6 +1483,7 @@ export function Composer({ isConnected={isConnected} isCancellingAgent={isCancellingAgent} agentInterruptKeys={agentInterruptKeys} + t={t} /> ), [ @@ -1445,6 +1496,7 @@ export function Composer({ isCancellingAgent, isConnected, isProcessing, + t, ], ); @@ -1463,6 +1515,7 @@ export function Composer({ isVoiceSwitching={isVoiceSwitching} realtimeVoiceButtonStyle={realtimeVoiceButtonStyle} voiceToggleKeys={voiceToggleKeys} + t={t} cancelButton={cancelButton} /> ), @@ -1479,6 +1532,7 @@ export function Composer({ isVoiceModeForAgent, isVoiceSwitching, realtimeVoiceButtonStyle, + t, voiceToggleKeys, ], ); @@ -1528,7 +1582,7 @@ export function Composer({ () => [ { id: "image", - label: "Add image", + label: t("composer.attachments.addImage"), icon: , onSelect: () => { void handlePickImage(); @@ -1536,14 +1590,14 @@ export function Composer({ }, { id: "github", - label: "Add issue or PR", + label: t("composer.attachments.addIssueOrPr"), icon: , onSelect: () => { setIsGithubPickerOpen(true); }, }, ], - [handlePickImage], + [handlePickImage, t], ); const handleToggleGithubItem = useCallback( @@ -1641,13 +1695,28 @@ export function Composer({ isComposerLocked, handleOpenAttachment, handleRemoveAttachment, + labels: { + openImage: t("composer.attachments.openImage"), + removeImage: t("composer.attachments.removeImage"), + openGithub: (kind: string, number: number) => + t("composer.attachments.openGithub", { kind, number }), + removeGithub: (kind: string, number: number) => + t("composer.attachments.removeGithub", { kind, number }), + }, }), - [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments], + [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t], ); const queueList = useMemo( - () => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }), - [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages], + () => + renderQueueTrack({ + queuedMessages, + handleEditQueuedMessage, + handleSendQueuedNow, + editLabel: t("composer.attachments.editQueuedMessage"), + sendNowLabel: t("composer.attachments.sendQueuedMessageNow"), + }), + [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t], ); const messageInputContainerRef = useRef(null); @@ -1660,8 +1729,8 @@ export function Composer({ [sendError], ); const githubEmptyText = githubSearchResultsQuery.isFetching - ? "Searching..." - : "No results found."; + ? t("composer.github.searching") + : t("composer.github.noResults"); const autocompleteVisible = autocomplete.isVisible && isPaneFocused; return ( @@ -1732,8 +1801,8 @@ export function Composer({ onSelect={noop} keepOpenOnSelect searchable - searchPlaceholder="Search issues and PRs..." - title="Attach issue or PR" + searchPlaceholder={t("composer.github.searchPlaceholder")} + title={t("composer.github.title")} open={isGithubPickerOpen} onOpenChange={handleGithubPickerOpenChange} onSearchQueryChange={setGithubSearchQuery} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index ac6cc23ac..ac658001c 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -22,6 +22,7 @@ import { forwardRef, } from "react"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; @@ -60,6 +61,12 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerHeightMirror } from "./height-mirror"; +import { + resolveSendTooltipLabel, + resolveSubmitAccessibilityLabel, + resolveVoiceAccessibilityLabel, + resolveVoiceTooltipText, +} from "./labels"; import { computeCanStartDictation } from "./state"; export interface AttachmentMenuItem { @@ -142,7 +149,6 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46; const DEFAULT_MAX_INPUT_HEIGHT = 160; const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; -const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" }; const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"]; type WebTextInputKeyPressEvent = NativeSyntheticEvent< @@ -259,18 +265,24 @@ function AttachmentDropdown({ attachButtonStyle, renderAttachButtonIcon, attachmentMenuItems, + addAttachmentLabel, }: { isConnected: boolean; disabled: boolean; attachButtonStyle: React.ComponentProps["style"]; renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; attachmentMenuItems: AttachmentMenuItem[]; + addAttachmentLabel: string; }) { const isCompact = useIsCompactFormFactor(); const [isSheetOpen, setIsSheetOpen] = useState(false); useDismissKeyboardOnOpen(isSheetOpen, isCompact); const isButtonDisabled = !isConnected || disabled; + const attachmentSheetHeader = useMemo( + () => ({ title: addAttachmentLabel }), + [addAttachmentLabel], + ); const handleOpenSheet = useCallback(() => { if (isButtonDisabled) return; setIsSheetOpen(true); @@ -306,7 +318,7 @@ function AttachmentDropdown({ <> - Add attachment + {addAttachmentLabel} ; } -function resolveSubmitAccessibilityLabel(input: { - submitButtonAccessibilityLabel: string | undefined; - canPressLoadingButton: boolean; - defaultActionQueues: boolean; - isAgentRunning: boolean; -}): string { - if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; - if (input.canPressLoadingButton) return "Interrupt agent"; - if (input.defaultActionQueues) return "Queue message"; - if (input.isAgentRunning) return "Send and interrupt"; - return "Send message"; -} - -function resolveVoiceAccessibilityLabel(input: { - isRealtimeVoiceForCurrentAgent: boolean; - isMuted: boolean; - isDictating: boolean; -}): string { - if (input.isRealtimeVoiceForCurrentAgent) { - return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode"; - } - if (input.isDictating) return "Stop dictation"; - return "Start dictation"; -} - -function resolveVoiceTooltipText(input: { - isRealtimeVoiceForCurrentAgent: boolean; - isMuted: boolean; -}): string { - if (input.isRealtimeVoiceForCurrentAgent) { - return input.isMuted ? "Unmute voice" : "Mute voice"; - } - return "Dictation"; -} - -function resolveSendTooltipLabel(input: { - submitButtonAccessibilityLabel: string | undefined; - defaultActionQueues: boolean; -}): string { - if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; - return input.defaultActionQueues ? "Queue" : "Send"; -} - interface DesktopKeyPressContext { onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; submitOnEnter: boolean; @@ -749,14 +718,16 @@ function MessageInputOverlay({ function FocusHint({ visible, focusInputKeys, + label, }: { visible: boolean; focusInputKeys: ShortcutChord | null | undefined; + label: string; }) { - if (!visible || !focusInputKeys) return null; + if (!visible || !focusInputKeys || !label.trim()) return null; return ( - {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus + {label} ); } @@ -812,9 +783,8 @@ function SendButtonTooltip({ isSubmitLoading, submitIcon, buttonIconSize, - submitButtonAccessibilityLabel, - defaultActionQueues, sendKeys, + sendTooltipLabel, }: { shouldShow: boolean; canPressLoadingButton: boolean; @@ -826,9 +796,8 @@ function SendButtonTooltip({ isSubmitLoading: boolean; submitIcon: "arrow" | "return"; buttonIconSize: number; - submitButtonAccessibilityLabel: string | undefined; - defaultActionQueues: boolean; sendKeys: ShortcutChord | null | undefined; + sendTooltipLabel: string; }) { if (!shouldShow) return null; return ( @@ -847,10 +816,7 @@ function SendButtonTooltip({ /> - + ); @@ -908,6 +874,7 @@ interface ToggleRealtimeVoiceContext { isAgentRunning: boolean; handleStopRealtimeVoice: () => Promise | void; toast: { error: (msg: string) => void }; + interruptBeforeVoiceMessage: string; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { @@ -920,7 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { return; } if (ctx.isAgentRunning) { - ctx.toast.error("Interrupt the agent before starting voice mode"); + ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { @@ -1199,7 +1166,7 @@ interface ResolvedMessageInputProps { onAddImages: ((images: ImageAttachment[]) => void) | undefined; client: DaemonClient | null; isReadyForDictation: boolean | undefined; - placeholder: string; + placeholder: string | undefined; autoFocus: boolean; autoFocusKey: string | undefined; disabled: boolean; @@ -1239,7 +1206,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu onAddImages: props.onAddImages, client: props.client, isReadyForDictation: props.isReadyForDictation, - placeholder: props.placeholder ?? "Message...", + placeholder: props.placeholder, autoFocus: props.autoFocus ?? false, autoFocusKey: props.autoFocusKey, disabled: props.disabled ?? false, @@ -1308,6 +1275,7 @@ export const MessageInput = forwardRef( inputWrapperStyle, attachmentSlot, } = resolveMessageInputProps(props); + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); const maxInputHeight = resolveMaxInputHeight(windowHeight); @@ -1564,12 +1532,14 @@ export const MessageInput = forwardRef( isAgentRunning, handleStopRealtimeVoice, toast, + interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), }); }, [ disabled, handleStopRealtimeVoice, isAgentRunning, isConnected, + t, toast, voice, voiceAgentId, @@ -1747,17 +1717,26 @@ export const MessageInput = forwardRef( canPressLoadingButton, defaultActionQueues, isAgentRunning, + t, }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), isDictating, + t, }); const voiceTooltipText = resolveVoiceTooltipText({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), + t, + }); + + const sendTooltipLabel = resolveSendTooltipLabel({ + submitButtonAccessibilityLabel, + defaultActionQueues, + t, }); const handleInputChange = useCallback( @@ -1854,9 +1833,9 @@ export const MessageInput = forwardRef( ref={textInputRef} value={value} onChangeText={handleInputChange} - placeholder={placeholder} + placeholder={placeholder ?? t("composer.placeholders.fallback")} uniProps={textInputPlaceholderColorMapping} - accessibilityLabel="Message agent..." + accessibilityLabel={t("composer.input.accessibilityLabel")} onFocus={handleInputFocus} onBlur={handleInputBlur} style={textInputStyle} @@ -1872,6 +1851,9 @@ export const MessageInput = forwardRef( @@ -1885,6 +1867,7 @@ export const MessageInput = forwardRef( attachButtonStyle={attachButtonStyle} renderAttachButtonIcon={renderAttachButtonIcon} attachmentMenuItems={attachmentMenuItems} + addAttachmentLabel={t("composer.input.addAttachment")} /> {leftContent} @@ -1915,9 +1898,8 @@ export const MessageInput = forwardRef( isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} buttonIconSize={buttonIconSize} - submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} - defaultActionQueues={defaultActionQueues} sendKeys={sendKeys} + sendTooltipLabel={sendTooltipLabel} /> diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts new file mode 100644 index 000000000..7a5492c94 --- /dev/null +++ b/packages/app/src/composer/input/labels.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + resolveSendTooltipLabel, + resolveSubmitAccessibilityLabel, + resolveVoiceAccessibilityLabel, + resolveVoiceTooltipText, +} from "./labels"; + +const translations: Record = { + "composer.input.interruptAgent": "Interrupt agent", + "composer.input.queueMessage": "Queue message", + "composer.input.sendAndInterrupt": "Send and interrupt", + "composer.input.sendMessage": "Send message", + "composer.input.queue": "Queue", + "composer.input.send": "Send", + "composer.voice.unmuteVoiceMode": "Unmute Voice mode", + "composer.voice.muteVoiceMode": "Mute Voice mode", + "composer.voice.stopDictation": "Stop dictation", + "composer.voice.startDictation": "Start dictation", + "composer.voice.unmuteVoice": "Unmute voice", + "composer.voice.muteVoice": "Mute voice", + "composer.voice.dictation": "Dictation", +}; + +const t = ((key: string) => translations[key] ?? key) as never; + +describe("composer input labels", () => { + it("resolves submit accessibility labels from translations", () => { + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: undefined, + canPressLoadingButton: true, + defaultActionQueues: false, + isAgentRunning: true, + t, + }), + ).toBe("Interrupt agent"); + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: undefined, + canPressLoadingButton: false, + defaultActionQueues: true, + isAgentRunning: true, + t, + }), + ).toBe("Queue message"); + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: undefined, + canPressLoadingButton: false, + defaultActionQueues: false, + isAgentRunning: true, + t, + }), + ).toBe("Send and interrupt"); + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: undefined, + canPressLoadingButton: false, + defaultActionQueues: false, + isAgentRunning: false, + t, + }), + ).toBe("Send message"); + }); + + it("keeps explicit submit labels untouched", () => { + expect( + resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel: "Run now", + canPressLoadingButton: false, + defaultActionQueues: false, + isAgentRunning: false, + t, + }), + ).toBe("Run now"); + }); + + it("resolves voice labels from translations", () => { + expect( + resolveVoiceAccessibilityLabel({ + isRealtimeVoiceForCurrentAgent: true, + isMuted: true, + isDictating: false, + t, + }), + ).toBe("Unmute Voice mode"); + expect( + resolveVoiceAccessibilityLabel({ + isRealtimeVoiceForCurrentAgent: true, + isMuted: false, + isDictating: false, + t, + }), + ).toBe("Mute Voice mode"); + expect( + resolveVoiceAccessibilityLabel({ + isRealtimeVoiceForCurrentAgent: false, + isMuted: false, + isDictating: true, + t, + }), + ).toBe("Stop dictation"); + expect( + resolveVoiceAccessibilityLabel({ + isRealtimeVoiceForCurrentAgent: false, + isMuted: false, + isDictating: false, + t, + }), + ).toBe("Start dictation"); + }); + + it("resolves tooltip labels from translations", () => { + expect( + resolveVoiceTooltipText({ + isRealtimeVoiceForCurrentAgent: false, + isMuted: false, + t, + }), + ).toBe("Dictation"); + expect( + resolveSendTooltipLabel({ + submitButtonAccessibilityLabel: undefined, + defaultActionQueues: true, + t, + }), + ).toBe("Queue"); + expect( + resolveSendTooltipLabel({ + submitButtonAccessibilityLabel: undefined, + defaultActionQueues: false, + t, + }), + ).toBe("Send"); + }); +}); diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts new file mode 100644 index 000000000..250b24c7b --- /dev/null +++ b/packages/app/src/composer/input/labels.ts @@ -0,0 +1,54 @@ +import type { TFunction } from "i18next"; + +export function resolveSubmitAccessibilityLabel(input: { + submitButtonAccessibilityLabel: string | undefined; + canPressLoadingButton: boolean; + defaultActionQueues: boolean; + isAgentRunning: boolean; + t: TFunction; +}): string { + if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; + if (input.canPressLoadingButton) return input.t("composer.input.interruptAgent"); + if (input.defaultActionQueues) return input.t("composer.input.queueMessage"); + if (input.isAgentRunning) return input.t("composer.input.sendAndInterrupt"); + return input.t("composer.input.sendMessage"); +} + +export function resolveVoiceAccessibilityLabel(input: { + isRealtimeVoiceForCurrentAgent: boolean; + isMuted: boolean; + isDictating: boolean; + t: TFunction; +}): string { + if (input.isRealtimeVoiceForCurrentAgent) { + return input.isMuted + ? input.t("composer.voice.unmuteVoiceMode") + : input.t("composer.voice.muteVoiceMode"); + } + if (input.isDictating) return input.t("composer.voice.stopDictation"); + return input.t("composer.voice.startDictation"); +} + +export function resolveVoiceTooltipText(input: { + isRealtimeVoiceForCurrentAgent: boolean; + isMuted: boolean; + t: TFunction; +}): string { + if (input.isRealtimeVoiceForCurrentAgent) { + return input.isMuted + ? input.t("composer.voice.unmuteVoice") + : input.t("composer.voice.muteVoice"); + } + return input.t("composer.voice.dictation"); +} + +export function resolveSendTooltipLabel(input: { + submitButtonAccessibilityLabel: string | undefined; + defaultActionQueues: boolean; + t: TFunction; +}): string { + if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; + return input.defaultActionQueues + ? input.t("composer.input.queue") + : input.t("composer.input.send"); +} diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 977799a5f..fb892d985 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; + export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed"; export interface AgentInputSubmitActionInput { @@ -17,6 +19,7 @@ export interface AgentInputSubmitActionInput { setSendError: (message: string | null) => void; setIsProcessing: (isProcessing: boolean) => void; onSubmitError?: (error: unknown) => void; + failedToSendMessage?: string; } export async function submitAgentInput( @@ -67,7 +70,11 @@ export async function submitAgentInput( input.setUserInput(trimmedMessage); input.setAttachments(attachments); } - input.setSendError(error instanceof Error ? error.message : "Failed to send message"); + input.setSendError( + error instanceof Error + ? error.message + : (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")), + ); input.setIsProcessing(false); return "failed"; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5adae71e7..ecb79892e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -2,6 +2,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react"; import { Buffer } from "buffer"; import { AppState } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; @@ -440,6 +441,7 @@ export function SessionProvider(props: SessionProviderProps) { } function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) { + const { t } = useTranslation(); const voiceRuntime = useVoiceRuntimeOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const queryClient = useQueryClient(); @@ -908,25 +910,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, setVoiceMode: async (enabled, agentId) => { if (!client) { - throw new Error("Daemon unavailable"); + throw new Error(t("common.errors.daemonUnavailable")); } await client.setVoiceMode(enabled, agentId); }, sendVoiceAudioChunk: async (audioData, mimeType) => { if (!client) { - throw new Error("Daemon unavailable"); + throw new Error(t("common.errors.daemonUnavailable")); } await client.sendVoiceAudioChunk(audioData, mimeType); }, audioPlayed: async (chunkId) => { if (!client) { - throw new Error("Daemon unavailable"); + throw new Error(t("common.errors.daemonUnavailable")); } await client.audioPlayed(chunkId); }, abortRequest: async () => { if (!client) { - throw new Error("Daemon unavailable"); + throw new Error(t("common.errors.daemonUnavailable")); } await client.abortRequest(); }, @@ -935,7 +937,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, }); return () => unregister?.(); - }, [client, serverId, setIsPlayingAudio, voiceRuntime]); + }, [client, serverId, setIsPlayingAudio, t, voiceRuntime]); useEffect(() => { voiceRuntime?.updateSessionConnection(serverId, isConnected); diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx index 0cf0eb389..f97050b7d 100644 --- a/packages/app/src/desktop/components/desktop-permission-row.tsx +++ b/packages/app/src/desktop/components/desktop-permission-row.tsx @@ -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({ - Granted + {labels.granted} {extraActionLabel && onExtraAction ? ( ) : null} ) : ( )} {shouldShowDetail ? ( diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index b9812ec30..0a2cdac9a 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -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")} ), - [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 ( - + {testNotificationError ? {testNotificationError} : null} diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index e032a2b27..9725a1848 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -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(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(() => ({ title: t("desktop.daemon.logs.modalTitle") }), [t]); + return ( - {daemonLogs?.logPath ?? "Log path unavailable"} + + {daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")} + - {daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"} + {daemonLogs?.contents?.length ? daemonLogs.contents : t("desktop.daemon.logs.empty")} @@ -155,11 +163,17 @@ function DaemonCliStatusModal({ cliStatusOutput, onCopy, }: DaemonCliStatusModalProps) { + const { t } = useTranslation(); + const header = useMemo( + () => ({ title: t("desktop.daemon.fullStatus.modalTitle") }), + [t], + ); + return ( @@ -169,10 +183,10 @@ function DaemonCliStatusModal({ @@ -200,6 +214,7 @@ interface DaemonInfoCardProps { } function DaemonInfoCard(props: DaemonInfoCardProps) { + const { t } = useTranslation(); const { daemonStatusStateText, daemonStatusDetailText, @@ -223,8 +238,8 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { - Status - Only the built-in desktop daemon is shown here + {t("desktop.daemon.status.title")} + {t("desktop.daemon.status.builtInOnly")} {daemonStatusStateText} @@ -233,39 +248,39 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { - Manage built-in daemon - Let Paseo start and stop the built-in daemon + {t("desktop.daemon.management.title")} + {t("desktop.daemon.management.hint")} - Keep daemon running after quit - Daemon keeps running when you quit Paseo + {t("desktop.daemon.keepRunning.title")} + {t("desktop.daemon.keepRunning.hint")} - Log file + {t("desktop.daemon.logs.title")} - {daemonLogs?.logPath ?? "Log path unavailable"} + {daemonLogs?.logPath ?? t("desktop.daemon.logs.unavailable")} {daemonLogs?.logPath ? ( ) : null} - Full status - - Runs `paseo daemon status` and shows the output - + {t("desktop.daemon.fullStatus.title")} + {t("desktop.daemon.fullStatus.hint")} @@ -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")} ), - [advancedSettingsIcon, handleOpenAdvancedSettings], + [advancedSettingsIcon, handleOpenAdvancedSettings, t], ); if (!showSection) { @@ -396,7 +415,7 @@ export function LocalDaemonSection() { return ( @@ -427,11 +446,7 @@ export function LocalDaemonSection() { {daemonVersionMismatch ? ( - - { - "App and daemon versions don't match. Update both to the same version for the best experience." - } - + {t("desktop.daemon.versionMismatch")} ) : 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" }; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 2b66f357b..531172eb3 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -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 = { add: 0, update: 1, delete: 2 }; +const OP_KIND_LABEL_KEY: Record = { + 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")} ), - [arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs], + [arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t], ); if (!showSection) { @@ -121,20 +151,24 @@ export function IntegrationsSection() { const skillsState = skillsStatus?.state ?? null; return ( - + - Command line + + {t("settings.integrations.commandLine.title")} + - Control and script agents from your terminal + + {t("settings.integrations.commandLine.description")} + {cliStatus?.installed ? ( - Installed + {t("settings.integrations.actions.installed")} ) : ( )} @@ -151,12 +187,12 @@ export function IntegrationsSection() { - Orchestration skills + {t("settings.integrations.skills.title")} {skillsState === "drift" - ? "Update available" - : "Teach your agents to orchestrate through the CLI"} + ? t("settings.integrations.skills.updateAvailable") + : t("settings.integrations.skills.description")} - Installed + {t("settings.integrations.actions.installed")} ); @@ -201,10 +238,12 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S return ( ); @@ -212,7 +251,9 @@ function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: S return ( ); } diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx index 570e48c64..a3dab342e 100644 --- a/packages/app/src/desktop/components/pair-device-modal.tsx +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -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( + () => ({ title: t("settings.integrations.pairDevices.rowTitle") }), + [t], + ); + return ( ({ + 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} /> @@ -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 ( - Loading pairing offer… + {labels.loadingOffer} ); } @@ -164,7 +195,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) { {viewState.message} ); @@ -172,11 +203,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) { return ( - - Scan this QR code with Paseo on your phone, or copy the link below. - + {labels.hint} - + @@ -189,7 +222,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) { /> @@ -199,12 +232,13 @@ function PairDeviceBody(props: PairDeviceBodyProps) { function PairDeviceQrContent(props: { qrImageSource: { uri: string } | null; qrQuery: { isError: boolean }; + unavailableLabel: string; }) { if (props.qrImageSource) { return ; } if (props.qrQuery.isError) { - return QR code unavailable.; + return {props.unavailableLabel}; } return ; } diff --git a/packages/app/src/desktop/daemon/daemon-management-error.ts b/packages/app/src/desktop/daemon/daemon-management-error.ts index 38a6816c6..2736b32d4 100644 --- a/packages/app/src/desktop/daemon/daemon-management-error.ts +++ b/packages/app/src/desktop/daemon/daemon-management-error.ts @@ -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, }; } diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index c2e39da17..2f8f30360 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -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, diff --git a/packages/app/src/desktop/hooks/use-daemon-status.ts b/packages/app/src/desktop/hooks/use-daemon-status.ts index a9188e2fc..7ec16b43d 100644 --- a/packages/app/src/desktop/hooks/use-daemon-status.ts +++ b/packages/app/src/desktop/hooks/use-daemon-status.ts @@ -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", }); diff --git a/packages/app/src/desktop/hooks/use-install-status.test.tsx b/packages/app/src/desktop/hooks/use-install-status.test.tsx index da6c020c4..9610788a9 100644 --- a/packages/app/src/desktop/hooks/use-install-status.test.tsx +++ b/packages/app/src/desktop/hooks/use-install-status.test.tsx @@ -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(); }); diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index 97414c41c..5c2ddda6e 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -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", }); }, diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index ebdc2985b..253ca5302 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -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"); + } + }); }); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 6aab5494e..189016e99 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -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), + }), }); } } diff --git a/packages/app/src/desktop/permissions/use-desktop-permissions.ts b/packages/app/src/desktop/permissions/use-desktop-permissions.ts index 856059629..bc59f72e5 100644 --- a/packages/app/src/desktop/permissions/use-desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/use-desktop-permissions.ts @@ -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; } -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(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(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) { diff --git a/packages/app/src/desktop/settings/desktop-settings.ts b/packages/app/src/desktop/settings/desktop-settings.ts index 6911fa88b..cbda11e02 100644 --- a/packages/app/src/desktop/settings/desktop-settings.ts +++ b/packages/app/src/desktop/settings/desktop-settings.ts @@ -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", }); }, diff --git a/packages/app/src/desktop/updates/desktop-app-updater.test.ts b/packages/app/src/desktop/updates/desktop-app-updater.test.ts index 76d453eeb..e4167df17 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.test.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts @@ -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"); + } + }); }); diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts index ad444d9f1..e15e9783d 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -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({ diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 1e16fd095..4e2c2adba 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -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"), }; } diff --git a/packages/app/src/desktop/updates/resolve-update-callout.test.ts b/packages/app/src/desktop/updates/resolve-update-callout.test.ts index 4dfdc6cbb..e5ecc09ec 100644 --- a/packages/app/src/desktop/updates/resolve-update-callout.test.ts +++ b/packages/app/src/desktop/updates/resolve-update-callout.test.ts @@ -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"); + } + }); }); diff --git a/packages/app/src/desktop/updates/resolve-update-callout.ts b/packages/app/src/desktop/updates/resolve-update-callout.ts index b87e83efe..7b80cec3e 100644 --- a/packages/app/src/desktop/updates/resolve-update-callout.ts +++ b/packages/app/src/desktop/updates/resolve-update-callout.ts @@ -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, }); diff --git a/packages/app/src/desktop/updates/rosetta-callout-source.tsx b/packages/app/src/desktop/updates/rosetta-callout-source.tsx index b059f1605..43b2a46eb 100644 --- a/packages/app/src/desktop/updates/rosetta-callout-source.tsx +++ b/packages/app/src/desktop/updates/rosetta-callout-source.tsx @@ -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["t"] }) { return ( <> - You're running the Intel build of Paseo under Rosetta on Apple Silicon. - - - This causes high CPU usage. Download the Apple Silicon build to fix it. + {t("desktop.rosetta.runningIntel")} + {t("desktop.rosetta.highCpu")} ); } export function RosettaCalloutSource() { + const { t } = useTranslation(); const callouts = useSidebarCallouts(); const [runtimeInfo, setRuntimeInfo] = useState(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: , + title: t("desktop.rosetta.title"), + description: , 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; } diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index 95d3dd0b2..d442651cb 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -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["t"]): ReactNode { + if (body.kind === "installing") return t("desktop.updates.callout.installingDescription"); if (body.kind === "error") return body.message; - return ; + return ; } 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 ? ( ) : 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["t"]; +}) { return ( <> {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")} - Upgrading the app will stop running agents and close terminal sessions. + {t("desktop.updates.callout.restartWarning")} ); diff --git a/packages/app/src/dictation/dictation-stream-sender.ts b/packages/app/src/dictation/dictation-stream-sender.ts index 08a748c46..e4562e0b9 100644 --- a/packages/app/src/dictation/dictation-stream-sender.ts +++ b/packages/app/src/dictation/dictation-stream-sender.ts @@ -1,5 +1,6 @@ import { generateMessageId } from "@/types/stream"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { i18n } from "@/i18n/i18next"; const MAX_CHUNKS_PER_FLUSH_TURN = 128; @@ -178,10 +179,10 @@ export class DictationStreamSender { async finish(finalSeq: number): Promise { const client = this.client; if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error(i18n.t("common.errors.daemonClientUnavailable")); } if (!client.isConnected) { - throw new Error("Daemon client is disconnected"); + throw new Error(i18n.t("common.errors.daemonClientDisconnected")); } if (!this.dictationId) { diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 53ba6b98e..a3f5d513a 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -8,6 +8,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronDown, Info, MoreVertical } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { DropdownMenu, DropdownMenuContent, @@ -75,6 +76,7 @@ function GitActionMenuItem({ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const toast = useToast(); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); @@ -162,7 +164,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli testID="changes-primary-cta-caret" style={caretTriggerStyle} accessibilityRole="button" - accessibilityLabel="More options" + accessibilityLabel={t("workspace.git.actions.moreOptions")} > @@ -176,7 +178,10 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli needsSeparator={action.startsGroup} showSeparator={index > 0} closeOnSelect={ - action.status === "idle" && action.id === "pr" && action.label === "View PR" + action.status === "idle" && + action.id === "pr" && + action.label === action.pendingLabel && + action.label === action.successLabel } /> ))} @@ -192,7 +197,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli hitSlop={8} style={overflowMenuButtonStyle} accessibilityRole="button" - accessibilityLabel="More actions" + accessibilityLabel={t("workspace.git.actions.moreActions")} > diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index a5056a669..3f3a094c7 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -18,6 +18,7 @@ import { resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-execution"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; +import { i18n } from "@/i18n/i18next"; const SUCCESS_DISPLAY_MS = 1000; @@ -52,7 +53,7 @@ function resolveClient(serverId: string) { const session = useSessionStore.getState().sessions[serverId]; const client = session?.client ?? null; if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error(i18n.t("common.errors.daemonClientUnavailable")); } return client; } diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 3c74a3f58..6b694289e 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -9,6 +9,7 @@ import { type ReactNode, type RefObject, } from "react"; +import { useTranslation } from "react-i18next"; import { DiffStat } from "@/components/diff-stat"; import { View, @@ -812,6 +813,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ onHeaderHeightChange, testID, }: DiffFileSectionProps) { + const { t } = useTranslation(); const layoutYRef = useRef(null); const pressHandledRef = useRef(false); const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); @@ -882,12 +884,12 @@ const DiffFileHeader = memo(function DiffFileHeader({ {file.isNew && ( - New + {t("workspace.git.diff.newFile")} )} {file.isDeleted && ( - Deleted + {t("workspace.git.diff.deletedFile")} )} @@ -924,6 +926,7 @@ function DiffFileBody({ const [scrollViewWidth, setScrollViewWidth] = useState(0); const [bodyWidth, setBodyWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); + const { t } = useTranslation(); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -949,7 +952,9 @@ function DiffFileBody({ return ( - {file.status === "binary" ? "Binary file" : "Diff too large to display"} + {file.status === "binary" + ? t("workspace.git.diff.binaryFile") + : t("workspace.git.diff.tooLarge")} ); @@ -1126,13 +1131,16 @@ function DiffLayoutToggleGroup({ onUnified, onSplit, }: DiffLayoutToggleGroupProps) { + const { t } = useTranslation(); + const unifiedLabel = t("workspace.git.diff.unified"); + const splitLabel = t("workspace.git.diff.split"); return ( - Unified diff + {unifiedLabel} - Side-by-side diff + {splitLabel} @@ -1187,12 +1195,14 @@ function DiffWhitespaceToggle({ toggleStyle, onToggle, }: DiffWhitespaceToggleProps) { + const { t } = useTranslation(); + const label = t("workspace.git.diff.hideWhitespace"); return ( - Hide whitespace + {label} ); @@ -1229,6 +1239,13 @@ function DiffFilesToolbar({ onToggleWrapLines, onToggleExpandAll, }: DiffFilesToolbarProps) { + const { t } = useTranslation(); + const wrapLinesLabel = wrapLines + ? t("workspace.git.diff.scrollLongLines") + : t("workspace.git.diff.wrapLongLines"); + const expandAllLabel = allExpanded + ? t("workspace.git.diff.collapseAll") + : t("workspace.git.diff.expandAll"); return ( @@ -1241,9 +1258,7 @@ function DiffFilesToolbar({ - - {wrapLines ? "Scroll long lines" : "Wrap long lines"} - + {wrapLinesLabel} @@ -1263,9 +1278,7 @@ function DiffFilesToolbar({ - - {allExpanded ? "Collapse all files" : "Expand all files"} - + {expandAllLabel} @@ -1282,12 +1295,16 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) { + const { t } = useTranslation(); + const refreshLabel = t("workspace.git.diff.refresh"); return ( - Refresh + {refreshLabel} ); @@ -1339,14 +1356,19 @@ function computeEmptyMessage( hideWhitespace: boolean, diffMode: "uncommitted" | "base", baseRefLabel: string, + labels: { + hiddenWhitespace: string; + uncommitted: string; + againstBase: (baseRefLabel: string) => string; + }, ): string { if (hideWhitespace) { - return "No visible changes after hiding whitespace"; + return labels.hiddenWhitespace; } if (diffMode === "uncommitted") { - return "No uncommitted changes"; + return labels.uncommitted; } - return `No changes vs ${baseRefLabel}`; + return labels.againstBase(baseRefLabel); } interface DiffBodyContentProps { @@ -1368,6 +1390,8 @@ interface DiffBodyContentProps { handleDiffListScroll: (event: NativeSyntheticEvent) => void; onContentSizeChange: (width: number, height: number) => void; showDesktopWebScrollbar: boolean; + checkingRepositoryLabel: string; + notRepositoryLabel: string; } function DiffBodyContent({ @@ -1389,12 +1413,14 @@ function DiffBodyContent({ handleDiffListScroll, onContentSizeChange, showDesktopWebScrollbar, + checkingRepositoryLabel, + notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( - Checking repository... + {checkingRepositoryLabel} ); } @@ -1408,7 +1434,7 @@ function DiffBodyContent({ if (notGit) { return ( - Not a git repository + {notRepositoryLabel} ); } @@ -1503,8 +1529,8 @@ function deriveStatusState({ }; } -function computeBaseRefLabel(baseRef: string | undefined): string { - if (!baseRef) return "base"; +function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string { + if (!baseRef) return fallbackLabel; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } @@ -1563,6 +1589,7 @@ export function GitDiffPane({ enabled, }: GitDiffPaneProps) { const { settings: appSettings } = useAppSettings(); + const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; @@ -1651,9 +1678,9 @@ export function GitDiffPane({ return; } void runRefresh({ serverId, cwd }).catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to refresh git state."); + toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, toast]); + }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); const { status, @@ -2064,7 +2091,10 @@ export function GitDiffPane({ const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? null; const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); - const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]); + const baseRefLabel = useMemo( + () => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")), + [baseRef, t], + ); const gitActionsIcons = useMemo( () => ({ commit: , @@ -2087,11 +2117,18 @@ export function GitDiffPane({ () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); + const uncommittedLabel = t("workspace.git.diff.uncommitted"); + const committedLabel = t("workspace.git.diff.committed"); const emptyMessage = computeEmptyMessage( changesPreferences.hideWhitespace, diffMode, baseRefLabel, + { + hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"), + uncommitted: t("workspace.git.diff.emptyUncommitted"), + againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }), + }, ); const bodyContent: ReactElement = ( @@ -2114,6 +2151,8 @@ export function GitDiffPane({ handleDiffListScroll={handleDiffListScroll} onContentSizeChange={scrollbar.onContentSizeChange} showDesktopWebScrollbar={showDesktopWebScrollbar} + checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} + notRepositoryLabel={t("workspace.git.diff.notRepository")} /> ); @@ -2139,10 +2178,10 @@ export function GitDiffPane({ style={diffModeTriggerStyle} testID="changes-diff-status" accessibilityRole="button" - accessibilityLabel="Diff mode" + accessibilityLabel={t("workspace.git.diff.diffMode")} > - {diffMode === "uncommitted" ? "Uncommitted" : "Committed"} + {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} @@ -2152,7 +2191,7 @@ export function GitDiffPane({ selected={diffMode === "uncommitted"} onSelect={handleSelectUncommitted} > - Uncommitted + {uncommittedLabel} - Committed + {committedLabel} diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index 3d8e34c8e..6a2e8484e 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages"; +import { i18n } from "@/i18n/i18next"; import { buildGitActions, type BuildGitActionsInput } from "./policy"; @@ -132,6 +133,10 @@ function createInput(overrides: Partial = {}): BuildGitAct } describe("git-actions-policy", () => { + afterEach(async () => { + await i18n.changeLanguage("en"); + }); + it("shows only remote sync actions on the base branch", () => { const actions = buildGitActions(createInput({ hasRemote: true })); @@ -599,6 +604,29 @@ describe("git-actions-policy", () => { expect(action).toMatchObject({ label: "Merge locally" }); }); + it("uses the active language for policy-owned action labels and unavailable messages", async () => { + await i18n.changeLanguage("zh-CN"); + const actions = buildGitActions( + createInput({ + hasRemote: true, + behindOfOrigin: 1, + isOnBaseBranch: false, + aheadCount: 0, + }), + ); + + expect(actions.primary).toMatchObject({ + id: "pull", + label: "Pull", + pendingLabel: "正在 pull...", + successLabel: "已 pull", + }); + expect(actions.secondary.find((entry) => entry.id === "pr")).toMatchObject({ + label: "创建 PR", + unavailableMessage: "无法创建 PR,因为此分支还没有新的 commit", + }); + }); + it.each([ ["draft", { pullRequestIsDraft: true }], ["merged", { pullRequestIsMerged: true }], diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index 936f31e0e..78cdacc60 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -1,6 +1,7 @@ import type { ReactElement } from "react"; import type { ActionStatus } from "@/components/ui/dropdown-menu"; +import { i18n } from "@/i18n/i18next"; import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse, @@ -108,7 +109,6 @@ interface PullRequestActionModel { interface PullRequestDirectMergeActionModel { readonly id: PullRequestDirectMergeActionId; readonly role: "direct"; - readonly label: string; readonly method: CheckoutPrMergeMethod; readonly startsGroup: boolean; } @@ -116,7 +116,6 @@ interface PullRequestDirectMergeActionModel { interface PullRequestAutoMergeEnableActionModel { readonly id: PullRequestAutoMergeEnableActionId; readonly role: "auto"; - readonly label: string; readonly method: CheckoutPrMergeMethod; readonly startsGroup: boolean; } @@ -125,21 +124,18 @@ const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [ { id: "merge-pr-squash", role: "direct", - label: "Squash and merge", method: "squash", startsGroup: true, }, { id: "merge-pr-merge", role: "direct", - label: "Create a merge commit", method: "merge", startsGroup: false, }, { id: "merge-pr-rebase", role: "direct", - label: "Rebase and merge", method: "rebase", startsGroup: false, }, @@ -149,21 +145,18 @@ const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [ { id: "enable-pr-auto-merge-squash", role: "auto", - label: "Enable auto-merge with squash", method: "squash", startsGroup: true, }, { id: "enable-pr-auto-merge-merge", role: "auto", - label: "Enable auto-merge with merge commit", method: "merge", startsGroup: false, }, { id: "enable-pr-auto-merge-rebase", role: "auto", - label: "Enable auto-merge with rebase", method: "rebase", startsGroup: false, }, @@ -204,9 +197,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("commit", { id: "commit", - label: "Commit", - pendingLabel: "Committing...", - successLabel: "Committed", + label: i18n.t("workspace.git.actions.commit.label"), + pendingLabel: i18n.t("workspace.git.actions.commit.pending"), + successLabel: i18n.t("workspace.git.actions.commit.success"), disabled: input.runtime.commit.disabled, status: input.runtime.commit.status, icon: input.runtime.commit.icon, @@ -216,9 +209,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("pull", { id: "pull", - label: "Pull", - pendingLabel: "Pulling...", - successLabel: "Pulled", + label: i18n.t("workspace.git.actions.pull.label"), + pendingLabel: i18n.t("workspace.git.actions.pull.pending"), + successLabel: i18n.t("workspace.git.actions.pull.success"), disabled: input.runtime.pull.disabled, status: input.runtime.pull.status, unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input), @@ -229,9 +222,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("push", { id: "push", - label: "Push", - pendingLabel: "Pushing...", - successLabel: "Pushed", + label: i18n.t("workspace.git.actions.push.label"), + pendingLabel: i18n.t("workspace.git.actions.push.pending"), + successLabel: i18n.t("workspace.git.actions.push.success"), disabled: input.runtime.push.disabled, status: input.runtime.push.status, unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input), @@ -242,9 +235,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("pull-and-push", { id: "pull-and-push", - label: "Pull and push", - pendingLabel: "Pulling and pushing...", - successLabel: "Pulled and pushed", + label: i18n.t("workspace.git.actions.pullAndPush.label"), + pendingLabel: i18n.t("workspace.git.actions.pullAndPush.pending"), + successLabel: i18n.t("workspace.git.actions.pullAndPush.success"), disabled: input.runtime["pull-and-push"].disabled, status: input.runtime["pull-and-push"].status, unavailableMessage: input.runtime["pull-and-push"].disabled @@ -261,9 +254,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("merge-branch", { id: "merge-branch", - label: "Merge locally", - pendingLabel: "Merging...", - successLabel: "Merged", + label: i18n.t("workspace.git.actions.mergeBranch.label"), + pendingLabel: i18n.t("workspace.git.actions.mergeBranch.pending"), + successLabel: i18n.t("workspace.git.actions.mergeBranch.success"), disabled: input.runtime["merge-branch"].disabled, status: input.runtime["merge-branch"].status, unavailableMessage: input.runtime["merge-branch"].disabled @@ -276,9 +269,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("merge-from-base", { id: "merge-from-base", - label: `Update from ${input.baseRefLabel}`, - pendingLabel: "Updating...", - successLabel: "Updated", + label: i18n.t("workspace.git.actions.mergeFromBase.label", { baseRef: input.baseRefLabel }), + pendingLabel: i18n.t("workspace.git.actions.mergeFromBase.pending"), + successLabel: i18n.t("workspace.git.actions.mergeFromBase.success"), disabled: input.runtime["merge-from-base"].disabled, status: input.runtime["merge-from-base"].status, unavailableMessage: input.runtime["merge-from-base"].disabled @@ -291,15 +284,15 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("archive-worktree", { id: "archive-worktree", - label: "Archive worktree", - pendingLabel: "Archiving...", - successLabel: "Archived", + label: i18n.t("workspace.git.actions.archive.label"), + pendingLabel: i18n.t("workspace.git.actions.archive.pending"), + successLabel: i18n.t("workspace.git.actions.archive.success"), disabled: input.runtime["archive-worktree"].disabled, status: input.runtime["archive-worktree"].status, unavailableMessage: input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree ? undefined - : "Archive isn't available here because this workspace was not created as a Paseo worktree", + : i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"), icon: input.runtime["archive-worktree"].icon, startsGroup: true, handler: input.runtime["archive-worktree"].handler, @@ -399,15 +392,15 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { if (input.hasPullRequest && input.pullRequestUrl) { return { id: "pr", - label: "View PR", - pendingLabel: "View PR", - successLabel: "View PR", + label: i18n.t("workspace.git.actions.viewPr"), + pendingLabel: i18n.t("workspace.git.actions.viewPr"), + successLabel: i18n.t("workspace.git.actions.viewPr"), disabled: input.runtime.pr.disabled, status: input.runtime.pr.status, unavailableMessage: input.runtime.pr.disabled || input.githubFeaturesEnabled ? undefined - : "View PR isn't available right now because GitHub isn't connected", + : i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"), icon: input.runtime.pr.icon, startsGroup: false, handler: input.runtime.pr.handler, @@ -416,9 +409,9 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { return { id: "pr", - label: "Create PR", - pendingLabel: "Creating PR...", - successLabel: "PR Created", + label: i18n.t("workspace.git.actions.createPr.label"), + pendingLabel: i18n.t("workspace.git.actions.createPr.pending"), + successLabel: i18n.t("workspace.git.actions.createPr.success"), disabled: input.runtime.pr.disabled, status: input.runtime.pr.status, unavailableMessage: input.runtime.pr.disabled @@ -438,9 +431,9 @@ function buildDirectPullRequestMergeAction( const unavailableMessage = getMergePrUnavailableMessage(input); return { id: model.id, - label: model.label, - pendingLabel: "Merging PR...", - successLabel: "PR merged", + label: getDirectPullRequestMergeActionLabel(model.id), + pendingLabel: i18n.t("workspace.git.actions.mergePr.pending"), + successLabel: i18n.t("workspace.git.actions.mergePr.success"), disabled: runtime.disabled || shouldDisableMergePrAction(input), status: runtime.status, unavailableMessage: runtime.disabled ? undefined : unavailableMessage, @@ -457,9 +450,9 @@ function buildEnablePullRequestAutoMergeAction( const runtime = input.runtime[model.id]; return { id: model.id, - label: model.label, - pendingLabel: "Enabling auto-merge...", - successLabel: "Auto-merge enabled", + label: getEnablePullRequestAutoMergeActionLabel(model.id), + pendingLabel: i18n.t("workspace.git.actions.autoMerge.enabling"), + successLabel: i18n.t("workspace.git.actions.autoMerge.enabled"), disabled: runtime.disabled, status: runtime.status, icon: runtime.icon, @@ -473,12 +466,12 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi const unavailableMessage = input.pullRequestGithub?.viewerCanDisableAutoMerge === true ? undefined - : "Auto-merge is enabled, but this account can't disable it"; + : i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable"); return { id: "disable-pr-auto-merge", - label: "Auto-merge enabled", - pendingLabel: "Disabling auto-merge...", - successLabel: "Auto-merge disabled", + label: i18n.t("workspace.git.actions.autoMerge.enabled"), + pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"), + successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"), disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true, status: runtime.status, unavailableMessage: runtime.disabled ? undefined : unavailableMessage, @@ -488,6 +481,28 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi }; } +function getDirectPullRequestMergeActionLabel(id: PullRequestDirectMergeActionId): string { + switch (id) { + case "merge-pr-squash": + return i18n.t("workspace.git.actions.mergePr.squash"); + case "merge-pr-merge": + return i18n.t("workspace.git.actions.mergePr.merge"); + case "merge-pr-rebase": + return i18n.t("workspace.git.actions.mergePr.rebase"); + } +} + +function getEnablePullRequestAutoMergeActionLabel(id: PullRequestAutoMergeEnableActionId): string { + switch (id) { + case "enable-pr-auto-merge-squash": + return i18n.t("workspace.git.actions.autoMerge.enableSquash"); + case "enable-pr-auto-merge-merge": + return i18n.t("workspace.git.actions.autoMerge.enableMerge"); + case "enable-pr-auto-merge-rebase": + return i18n.t("workspace.git.actions.autoMerge.enableRebase"); + } +} + function canPull(input: BuildGitActionsInput): boolean { return input.hasRemote && !input.hasUncommittedChanges && (input.behindOfOrigin ?? 0) > 0; } @@ -591,45 +606,45 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean { function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return "Pull isn't available here because this branch is not connected to a remote yet"; + return i18n.t("workspace.git.actions.unavailable.pullNoRemote"); } if (input.hasUncommittedChanges) { - return "Pull isn't available while you have local changes so commit or stash them first"; + return i18n.t("workspace.git.actions.unavailable.pullDirty"); } if (input.behindOfOrigin === null) { return "Pull isn't available here because this branch is not connected to a remote yet"; } if (input.behindOfOrigin === 0) { - return "Pull isn't available because this branch is already up to date"; + return i18n.t("workspace.git.actions.unavailable.pullUpToDate"); } return undefined; } function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return "Push isn't available here because this branch is not connected to a remote yet"; + return i18n.t("workspace.git.actions.unavailable.pushNoRemote"); } if ((input.behindOfOrigin ?? 0) > 0) { - return "Push isn't available yet because there are newer changes to bring in first"; + return i18n.t("workspace.git.actions.unavailable.pushBehind"); } if (!hasPushableCommits(input)) { - return "Push isn't available because there is nothing new to send"; + return i18n.t("workspace.git.actions.unavailable.pushNothing"); } return undefined; } function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return "Pull and push isn't available here because this branch is not connected to a remote yet"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushNoRemote"); } if (input.hasUncommittedChanges) { - return "Pull and push isn't available while you have local changes so commit or stash them first"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty"); } if (input.behindOfOrigin === null) { return "Pull and push isn't available because there are no incoming changes to pull first"; } if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) { - return "Pull and push isn't available because this branch is already in sync"; + return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync"); } if (input.behindOfOrigin === 0) { return "Pull and push isn't available because there are no incoming changes to pull first"; @@ -642,67 +657,69 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return "Create PR isn't available right now because GitHub isn't connected"; + return i18n.t("workspace.git.actions.unavailable.createPrNoGithub"); } if (input.aheadCount === 0) { - return "Create PR isn't available because this branch doesn't have any new commits yet"; + return i18n.t("workspace.git.actions.unavailable.createPrNoCommits"); } return undefined; } function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Merge isn't available because we couldn't determine the base branch"; + return i18n.t("workspace.git.actions.unavailable.mergeNoBase"); } if (input.hasUncommittedChanges) { - return "Merge isn't available while you have local changes so commit or stash them first"; + return i18n.t("workspace.git.actions.unavailable.mergeDirty"); } if (input.aheadCount === 0) { - return "Merge isn't available because this branch doesn't have anything new to merge yet"; + return i18n.t("workspace.git.actions.unavailable.mergeNothing"); } return undefined; } function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Update isn't available because we couldn't determine the base branch"; + return i18n.t("workspace.git.actions.unavailable.updateNoBase"); } if (input.hasUncommittedChanges) { - return "Update isn't available while you have local changes so commit or stash them first"; + return i18n.t("workspace.git.actions.unavailable.updateDirty"); } if (input.behindBaseCount === 0) { - return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`; + return i18n.t("workspace.git.actions.unavailable.updateCurrent", { + baseRef: input.baseRefLabel, + }); } return undefined; } function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return "Merge PR isn't available right now because GitHub isn't connected"; + return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub"); } if (!input.hasPullRequest) { - return "Merge PR isn't available because there isn't a pull request yet"; + return i18n.t("workspace.git.actions.unavailable.mergePrMissing"); } if (input.pullRequestIsDraft) { - return "Merge PR isn't available because the pull request is still a draft"; + return i18n.t("workspace.git.actions.unavailable.mergePrDraft"); } if (input.pullRequestIsMerged) { - return "Merge PR isn't available because the pull request is already merged"; + return i18n.t("workspace.git.actions.unavailable.mergePrMerged"); } if (input.pullRequestState === "closed") { - return "Merge PR isn't available because the pull request is closed"; + return i18n.t("workspace.git.actions.unavailable.mergePrClosed"); } if (input.pullRequestMergeable === "CONFLICTING") { - return "Merge PR isn't available because the pull request has conflicts"; + return i18n.t("workspace.git.actions.unavailable.mergePrConflicts"); } if (!hasPullRequestGithubFacts(input.pullRequestGithub)) { return undefined; } if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) { - return "Merge PR isn't available here because this repository uses a merge queue"; + return i18n.t("workspace.git.actions.unavailable.mergePrQueue"); } if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) { - return "Merge PR isn't available until GitHub reports the pull request is ready to merge"; + return i18n.t("workspace.git.actions.unavailable.mergePrNotReady"); } return undefined; } diff --git a/packages/app/src/git/pr-pane.tsx b/packages/app/src/git/pr-pane.tsx index a04ff952d..4eb5e2901 100644 --- a/packages/app/src/git/pr-pane.tsx +++ b/packages/app/src/git/pr-pane.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { @@ -16,7 +17,6 @@ import { MessageSquare, } from "lucide-react-native"; import { openExternalUrl } from "@/utils/open-external-url"; -import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data"; import type { CheckStatus, PrPaneActivity, @@ -35,6 +35,7 @@ function activityPressableStyle({ hovered }: { hovered?: boolean }) { export function PrPane({ data }: { data: PrPaneData }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const [checksOpen, setChecksOpen] = useState(true); const [reviewsOpen, setReviewsOpen] = useState(true); @@ -66,7 +67,7 @@ export function PrPane({ data }: { data: PrPaneData }) { const stateColor = getStateColor(data.state, theme); const StateIcon = getStateIcon(data.state); - const stateLabel = getStateLabel(data.state); + const stateLabel = t(`workspace.git.pr.states.${data.state}`); const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]); const keyedActivity = useMemo( () => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })), @@ -117,7 +118,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
{ void openExternalUrl(item.url); }, [item.url]); @@ -285,7 +288,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) { {item.author} {verb} - {item.age} + {age} {item.body} @@ -295,6 +298,18 @@ function ActivityRow({ item }: { item: PrPaneActivity }) { ); } +function getTranslatedActivityVerb( + item: Pick, + t: (key: string) => string, +): string { + if (item.kind === "comment") return t("workspace.git.pr.activity.commented"); + if (item.reviewState === "approved") return t("workspace.git.pr.activity.approved"); + if (item.reviewState === "changes_requested") { + return t("workspace.git.pr.activity.requestedChanges"); + } + return t("workspace.git.pr.activity.reviewed"); +} + function getStateColor(state: PrState, theme: ReturnType["theme"]): string { if (state === "open") return theme.colors.statusSuccess; if (state === "draft") return theme.colors.foregroundMuted; diff --git a/packages/app/src/git/use-actions.ts b/packages/app/src/git/use-actions.ts index db4d795c3..445e1d08b 100644 --- a/packages/app/src/git/use-actions.ts +++ b/packages/app/src/git/use-actions.ts @@ -1,17 +1,26 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import { router, type Href } from "expo-router"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useTranslation } from "react-i18next"; import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store"; import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query"; import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; -import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy"; +import { + buildGitActions, + narrowPullRequestState, + type GitAction, + type GitActions, +} from "@/git/policy"; import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; -import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; +import { + confirmRiskyWorktreeArchive, + type WorktreeArchiveWarningLabels, +} from "@/git/worktree-archive-warning"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -26,18 +35,20 @@ function isActionDisabled(actionsDisabled: boolean, status: CheckoutGitActionSta function resolveBranchLabel(input: { currentBranch: string | null | undefined; notGit: boolean; + notRepositoryLabel: string; + unknownLabel: string; }): string { if (input.currentBranch && input.currentBranch !== "HEAD") { return input.currentBranch; } if (input.notGit) { - return "Not a git repository"; + return input.notRepositoryLabel; } - return "Unknown"; + return input.unknownLabel; } -function formatBaseRefLabel(baseRef: string | undefined): string { - if (!baseRef) return "base"; +function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string { + if (!baseRef) return fallbackLabel; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } @@ -153,6 +164,7 @@ interface UseGitActionsResult { } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { + const { t } = useTranslation(); const toast = useToast(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr"); @@ -170,10 +182,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use cwd, enabled: isGit, }); - const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]); + const baseRefLabel = useMemo( + () => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")), + [baseRef, t], + ); const branchLabel = resolveBranchLabel({ currentBranch: gitStatus?.currentBranch, notGit, + notRepositoryLabel: t("workspace.git.diff.notRepository"), + unknownLabel: t("workspace.git.diff.branchUnknown"), }); // Ship default persistence @@ -308,58 +325,58 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const handleCommit = useCallback(() => { void runCommit({ serverId, cwd }) .then(() => { - toastActionSuccess("Committed"); + toastActionSuccess(t("workspace.git.actions.commit.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to commit"); + toastActionError(err, t("workspace.git.actions.toasts.failedCommit")); }); - }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); + }, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]); const handlePull = useCallback(() => { void runPull({ serverId, cwd }) .then(() => { - toastActionSuccess("Pulled"); + toastActionSuccess(t("workspace.git.actions.pull.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to pull"); + toastActionError(err, t("workspace.git.actions.toasts.failedPull")); }); - }, [cwd, runPull, serverId, toastActionError, toastActionSuccess]); + }, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { void runPush({ serverId, cwd }) .then(() => { - toastActionSuccess("Pushed"); + toastActionSuccess(t("workspace.git.actions.push.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to push"); + toastActionError(err, t("workspace.git.actions.toasts.failedPush")); }); - }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); + }, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]); const handlePullAndPush = useCallback(() => { void runPullAndPush({ serverId, cwd }) .then(() => { - toastActionSuccess("Pulled and pushed"); + toastActionSuccess(t("workspace.git.actions.pullAndPush.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to pull and push"); + toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush")); }); - }, [cwd, runPullAndPush, serverId, toastActionError, toastActionSuccess]); + }, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); void runCreatePr({ serverId, cwd }) .then(() => { - toastActionSuccess("PR created"); + toastActionSuccess(t("workspace.git.actions.createPr.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to create PR"); + toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr")); }); - }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); + }, [cwd, persistShipDefault, runCreatePr, serverId, t, toastActionError, toastActionSuccess]); const handleMergePr = useCallback( (method: CheckoutPrMergeMethod) => { @@ -367,14 +384,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runMergePr({ serverId, cwd, method }) .then(() => { setPostShipArchiveSuggested(true); - toastActionSuccess("PR merged"); + toastActionSuccess(t("workspace.git.actions.mergePr.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to merge PR"); + toastActionError(err, t("workspace.git.actions.toasts.failedMergePr")); }); }, - [cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess], + [cwd, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess], ); const handleEnablePrAutoMerge = useCallback( @@ -382,41 +399,49 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void persistShipDefault("pr"); void runEnablePrAutoMerge({ serverId, cwd, method }) .then(() => { - toastActionSuccess("Auto-merge enabled"); + toastActionSuccess(t("workspace.git.actions.autoMerge.enabled")); return; }) .catch((err) => { - toastActionError(err, "Failed to enable auto-merge"); + toastActionError(err, t("workspace.git.actions.toasts.failedEnableAutoMerge")); }); }, - [cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess], + [ + cwd, + persistShipDefault, + runEnablePrAutoMerge, + serverId, + t, + toastActionError, + toastActionSuccess, + ], ); const handleDisablePrAutoMerge = useCallback(() => { void runDisablePrAutoMerge({ serverId, cwd }) .then(() => { - toastActionSuccess("Auto-merge disabled"); + toastActionSuccess(t("workspace.git.actions.autoMerge.disabled")); return; }) .catch((err) => { - toastActionError(err, "Failed to disable auto-merge"); + toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge")); }); - }, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]); + }, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - toast.error("Base ref unavailable"); + toast.error(t("workspace.git.actions.toasts.baseRefUnavailable")); return; } void persistShipDefault("merge"); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); - toastActionSuccess("Merged"); + toastActionSuccess(t("workspace.git.actions.mergeBranch.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to merge"); + toastActionError(err, t("workspace.git.actions.toasts.failedMerge")); }); }, [ baseRef, @@ -424,6 +449,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use persistShipDefault, runMergeBranch, serverId, + t, toast, toastActionError, toastActionSuccess, @@ -431,23 +457,23 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const handleMergeFromBase = useCallback(() => { if (!baseRef) { - toast.error("Base ref unavailable"); + toast.error(t("workspace.git.actions.toasts.baseRefUnavailable")); return; } void runMergeFromBase({ serverId, cwd, baseRef }) .then(() => { - toastActionSuccess("Updated"); + toastActionSuccess(t("workspace.git.actions.mergeFromBase.success")); return; }) .catch((err) => { - toastActionError(err, "Failed to merge from base"); + toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase")); }); - }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); + }, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); const archiveWorktreeAfterConfirmation = useCallback(async () => { const worktreePath = status?.cwd; if (!worktreePath) { - toast.error("Worktree path unavailable"); + toast.error(t("workspace.git.actions.toasts.worktreePathUnavailable")); return; } @@ -456,12 +482,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const workspace = workspaceList.find( (candidate) => candidate.workspaceDirectory === worktreePath, ); - const confirmed = await confirmRiskyWorktreeArchive({ - worktreeName: workspace?.name ?? branchLabel, - isDirty: gitStatus?.isDirty, - aheadOfOrigin: gitStatus?.aheadOfOrigin, - diffStat: workspace?.diffStat ?? null, - }); + const confirmed = await confirmRiskyWorktreeArchive( + { + worktreeName: workspace?.name ?? branchLabel, + isDirty: gitStatus?.isDirty, + aheadOfOrigin: gitStatus?.aheadOfOrigin, + diffStat: workspace?.diffStat ?? null, + }, + getWorktreeArchiveWarningLabels(t), + ); if (!confirmed) { return; } @@ -479,7 +508,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }) as Href, ); void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => { - toastActionError(err, "Failed to archive worktree"); + toastActionError(err, t("workspace.git.actions.toasts.failedArchive")); }); }, [ branchLabel, @@ -489,6 +518,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use runArchiveWorktree, serverId, status?.cwd, + t, toast, toastActionError, ]); @@ -530,7 +560,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Build actions const gitActions: GitActions = useMemo(() => { - return buildGitActions({ + const actions = buildGitActions({ isGit, githubFeaturesEnabled, githubAutoMergeActionsEnabled, @@ -646,7 +676,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }, }, }); + return translateGitActions(actions, { baseRefLabel, hasPullRequest, t }); }, [ + t, isGit, hasRemote, hasPullRequest, @@ -701,3 +733,263 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use return { gitActions, branchLabel, isGit }; } + +function translateGitActions( + actions: GitActions, + input: { + baseRefLabel: string; + hasPullRequest: boolean; + t: (key: string, options?: Record) => string; + }, +): GitActions { + return { + primary: actions.primary ? translateGitAction(actions.primary, input) : null, + secondary: actions.secondary.map((action) => translateGitAction(action, input)), + menu: actions.menu.map((action) => translateGitAction(action, input)), + }; +} + +function translateGitAction( + action: GitAction, + { + baseRefLabel, + hasPullRequest, + t, + }: { + baseRefLabel: string; + hasPullRequest: boolean; + t: (key: string, options?: Record) => string; + }, +): GitAction { + const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, t }); + return { + ...action, + ...labels, + unavailableMessage: translateGitActionUnavailableMessage(action.unavailableMessage, { + baseRefLabel, + t, + }), + }; +} + +function getTranslatedGitActionLabels( + action: GitAction, + { + baseRefLabel, + hasPullRequest, + t, + }: { + baseRefLabel: string; + hasPullRequest: boolean; + t: (key: string, options?: Record) => string; + }, +): Pick { + switch (action.id) { + case "commit": + return { + label: t("workspace.git.actions.commit.label"), + pendingLabel: t("workspace.git.actions.commit.pending"), + successLabel: t("workspace.git.actions.commit.success"), + }; + case "pull": + return { + label: t("workspace.git.actions.pull.label"), + pendingLabel: t("workspace.git.actions.pull.pending"), + successLabel: t("workspace.git.actions.pull.success"), + }; + case "push": + return { + label: t("workspace.git.actions.push.label"), + pendingLabel: t("workspace.git.actions.push.pending"), + successLabel: t("workspace.git.actions.push.success"), + }; + case "pull-and-push": + return { + label: t("workspace.git.actions.pullAndPush.label"), + pendingLabel: t("workspace.git.actions.pullAndPush.pending"), + successLabel: t("workspace.git.actions.pullAndPush.success"), + }; + case "pr": + return hasPullRequest + ? { + label: t("workspace.git.actions.viewPr"), + pendingLabel: t("workspace.git.actions.viewPr"), + successLabel: t("workspace.git.actions.viewPr"), + } + : { + label: t("workspace.git.actions.createPr.label"), + pendingLabel: t("workspace.git.actions.createPr.pending"), + successLabel: t("workspace.git.actions.createPr.success"), + }; + case "merge-pr-squash": + return { + label: t("workspace.git.actions.mergePr.squash"), + pendingLabel: t("workspace.git.actions.mergePr.pending"), + successLabel: t("workspace.git.actions.mergePr.success"), + }; + case "merge-pr-merge": + return { + label: t("workspace.git.actions.mergePr.merge"), + pendingLabel: t("workspace.git.actions.mergePr.pending"), + successLabel: t("workspace.git.actions.mergePr.success"), + }; + case "merge-pr-rebase": + return { + label: t("workspace.git.actions.mergePr.rebase"), + pendingLabel: t("workspace.git.actions.mergePr.pending"), + successLabel: t("workspace.git.actions.mergePr.success"), + }; + case "enable-pr-auto-merge-squash": + return { + label: t("workspace.git.actions.autoMerge.enableSquash"), + pendingLabel: t("workspace.git.actions.autoMerge.enabling"), + successLabel: t("workspace.git.actions.autoMerge.enabled"), + }; + case "enable-pr-auto-merge-merge": + return { + label: t("workspace.git.actions.autoMerge.enableMerge"), + pendingLabel: t("workspace.git.actions.autoMerge.enabling"), + successLabel: t("workspace.git.actions.autoMerge.enabled"), + }; + case "enable-pr-auto-merge-rebase": + return { + label: t("workspace.git.actions.autoMerge.enableRebase"), + pendingLabel: t("workspace.git.actions.autoMerge.enabling"), + successLabel: t("workspace.git.actions.autoMerge.enabled"), + }; + case "disable-pr-auto-merge": + return { + label: t("workspace.git.actions.autoMerge.enabled"), + pendingLabel: t("workspace.git.actions.autoMerge.disabling"), + successLabel: t("workspace.git.actions.autoMerge.disabled"), + }; + case "merge-branch": + return { + label: t("workspace.git.actions.mergeBranch.label"), + pendingLabel: t("workspace.git.actions.mergeBranch.pending"), + successLabel: t("workspace.git.actions.mergeBranch.success"), + }; + case "merge-from-base": + return { + label: t("workspace.git.actions.mergeFromBase.label", { baseRef: baseRefLabel }), + pendingLabel: t("workspace.git.actions.mergeFromBase.pending"), + successLabel: t("workspace.git.actions.mergeFromBase.success"), + }; + case "archive-worktree": + return { + label: t("workspace.git.actions.archive.label"), + pendingLabel: t("workspace.git.actions.archive.pending"), + successLabel: t("workspace.git.actions.archive.success"), + }; + } +} + +function translateGitActionUnavailableMessage( + message: string | undefined, + { + baseRefLabel, + t, + }: { + baseRefLabel: string; + t: (key: string, options?: Record) => string; + }, +): string | undefined { + if (!message) return undefined; + const keyByMessage: Record = { + "View PR isn't available right now because GitHub isn't connected": + "workspace.git.actions.unavailable.viewPrNoGithub", + "Pull isn't available here because this branch is not connected to a remote yet": + "workspace.git.actions.unavailable.pullNoRemote", + "Pull isn't available while you have local changes so commit or stash them first": + "workspace.git.actions.unavailable.pullDirty", + "Pull isn't available because this branch is already up to date": + "workspace.git.actions.unavailable.pullUpToDate", + "Push isn't available here because this branch is not connected to a remote yet": + "workspace.git.actions.unavailable.pushNoRemote", + "Push isn't available yet because there are newer changes to bring in first": + "workspace.git.actions.unavailable.pushBehind", + "Push isn't available because there is nothing new to send": + "workspace.git.actions.unavailable.pushNothing", + "Pull and push isn't available here because this branch is not connected to a remote yet": + "workspace.git.actions.unavailable.pullAndPushNoRemote", + "Pull and push isn't available while you have local changes so commit or stash them first": + "workspace.git.actions.unavailable.pullAndPushDirty", + "Pull and push isn't available because this branch is already in sync": + "workspace.git.actions.unavailable.pullAndPushInSync", + "Create PR isn't available right now because GitHub isn't connected": + "workspace.git.actions.unavailable.createPrNoGithub", + "Create PR isn't available because this branch doesn't have any new commits yet": + "workspace.git.actions.unavailable.createPrNoCommits", + "Merge isn't available because we couldn't determine the base branch": + "workspace.git.actions.unavailable.mergeNoBase", + "Merge isn't available while you have local changes so commit or stash them first": + "workspace.git.actions.unavailable.mergeDirty", + "Merge isn't available because this branch doesn't have anything new to merge yet": + "workspace.git.actions.unavailable.mergeNothing", + "Update isn't available because we couldn't determine the base branch": + "workspace.git.actions.unavailable.updateNoBase", + "Update isn't available while you have local changes so commit or stash them first": + "workspace.git.actions.unavailable.updateDirty", + "Archive isn't available here because this workspace was not created as a Paseo worktree": + "workspace.git.actions.unavailable.archiveNotWorktree", + "Merge PR isn't available right now because GitHub isn't connected": + "workspace.git.actions.unavailable.mergePrNoGithub", + "Merge PR isn't available because there isn't a pull request yet": + "workspace.git.actions.unavailable.mergePrMissing", + "Merge PR isn't available because the pull request is still a draft": + "workspace.git.actions.unavailable.mergePrDraft", + "Merge PR isn't available because the pull request is already merged": + "workspace.git.actions.unavailable.mergePrMerged", + "Merge PR isn't available because the pull request is closed": + "workspace.git.actions.unavailable.mergePrClosed", + "Merge PR isn't available because the pull request has conflicts": + "workspace.git.actions.unavailable.mergePrConflicts", + "Merge PR isn't available here because this repository uses a merge queue": + "workspace.git.actions.unavailable.mergePrQueue", + "Merge PR isn't available until GitHub reports the pull request is ready to merge": + "workspace.git.actions.unavailable.mergePrNotReady", + "Auto-merge is enabled, but this account can't disable it": + "workspace.git.actions.unavailable.autoMergeCannotDisable", + }; + if ( + message.startsWith("Update isn't available because this branch is already up to date with ") + ) { + return t("workspace.git.actions.unavailable.updateCurrent", { baseRef: baseRefLabel }); + } + const key = keyByMessage[message]; + return key ? t(key) : message; +} + +function getWorktreeArchiveWarningLabels( + t: (key: string, options?: Record) => string, +): WorktreeArchiveWarningLabels { + return { + title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }), + confirm: t("workspace.git.actions.archiveWarning.confirm"), + cancel: t("workspace.git.actions.archiveWarning.cancel"), + uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"), + uncommittedChangesWithDiff: (diffStat) => + t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }), + addedLine: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.addedLine" + : "workspace.git.actions.archiveWarning.addedLines", + { count }, + ), + deletedLine: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.deletedLine" + : "workspace.git.actions.archiveWarning.deletedLines", + { count }, + ), + unpushedCommit: (count) => + t( + count === 1 + ? "workspace.git.actions.archiveWarning.unpushedCommit" + : "workspace.git.actions.archiveWarning.unpushedCommits", + { count }, + ), + }; +} diff --git a/packages/app/src/git/use-github-search-query.ts b/packages/app/src/git/use-github-search-query.ts index b1af449ef..aa1cd22a8 100644 --- a/packages/app/src/git/use-github-search-query.ts +++ b/packages/app/src/git/use-github-search-query.ts @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { GitHubSearchRequest, GitHubSearchResponse } from "@getpaseo/protocol/messages"; +import { i18n } from "@/i18n/i18next"; export const GITHUB_SEARCH_STALE_TIME = 30_000; @@ -24,6 +26,7 @@ interface GitHubSearchQueryInput { query: string; kinds?: GitHubSearchRequest["kinds"]; enabled: boolean; + hostDisconnectedMessage?: string; } export function githubSearchQueryKey( @@ -46,7 +49,9 @@ export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) { queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds), queryFn: async (): Promise => { if (!input.client) { - throw new Error("Host is not connected"); + throw new Error( + input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), + ); } const request = { cwd: input.cwd, query, limit: 20 }; if (input.kinds) { @@ -60,5 +65,11 @@ export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) { } export function useGithubSearchQuery(input: GitHubSearchQueryInput) { - return useQuery(buildGithubSearchQueryOptions(input)); + const { t } = useTranslation(); + return useQuery( + buildGithubSearchQueryOptions({ + ...input, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + }), + ); } diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index 1a32f6b3a..b9aa1a6a6 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -1,5 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; import { checkoutPrStatusQueryKey } from "@/git/query-keys"; @@ -79,6 +80,7 @@ export function useCheckoutPrStatusQuery({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -101,7 +103,7 @@ export function useCheckoutPrStatusQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return await client.checkoutPrStatus(cwd); }, @@ -128,6 +130,7 @@ export function useWorkspacePrHint({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -150,7 +153,7 @@ export function useWorkspacePrHint({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return await client.checkoutPrStatus(cwd); }, diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 115260a51..00fde81ae 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,5 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { checkoutStatusQueryKey } from "@/git/query-keys"; import { @@ -26,6 +27,7 @@ function fetchCheckoutStatus( } export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -44,7 +46,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery queryKey: checkoutStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd }); }, @@ -70,13 +72,14 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * only the visible agents. */ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { + const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); return useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return await fetchCheckoutStatus(client, cwd); }, diff --git a/packages/app/src/git/worktree-archive-warning.ts b/packages/app/src/git/worktree-archive-warning.ts index 280c19848..691defefb 100644 --- a/packages/app/src/git/worktree-archive-warning.ts +++ b/packages/app/src/git/worktree-archive-warning.ts @@ -1,4 +1,5 @@ import { confirmDialog } from "@/utils/confirm-dialog"; +import { i18n } from "@/i18n/i18next"; export interface WorktreeArchiveRisk { isDirty?: boolean | null; @@ -10,27 +11,61 @@ export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk { worktreeName: string; } -function pluralize(count: number, singular: string, plural = `${singular}s`): string { - return count === 1 ? singular : plural; +export interface WorktreeArchiveWarningLabels { + title: (worktreeName: string) => string; + confirm: string; + cancel: string; + uncommittedChanges: string; + uncommittedChangesWithDiff: (diffStat: string) => string; + addedLine: (count: number) => string; + deletedLine: (count: number) => string; + unpushedCommit: (count: number) => string; } -function formatDiffStat(diffStat: WorktreeArchiveRisk["diffStat"]): string | null { +export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = { + title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }), + confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"), + cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"), + uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"), + uncommittedChangesWithDiff: (diffStat) => + i18n.t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }), + addedLine: (count) => + count === 1 + ? i18n.t("workspace.git.actions.archiveWarning.addedLine", { count }) + : i18n.t("workspace.git.actions.archiveWarning.addedLines", { count }), + deletedLine: (count) => + count === 1 + ? i18n.t("workspace.git.actions.archiveWarning.deletedLine", { count }) + : i18n.t("workspace.git.actions.archiveWarning.deletedLines", { count }), + unpushedCommit: (count) => + count === 1 + ? i18n.t("workspace.git.actions.archiveWarning.unpushedCommit", { count }) + : i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }), +}; + +function formatDiffStat( + diffStat: WorktreeArchiveRisk["diffStat"], + labels: WorktreeArchiveWarningLabels, +): string | null { if (!diffStat) { return null; } const parts: string[] = []; if (diffStat.additions > 0) { - parts.push(`${diffStat.additions} added ${pluralize(diffStat.additions, "line")}`); + parts.push(labels.addedLine(diffStat.additions)); } if (diffStat.deletions > 0) { - parts.push(`${diffStat.deletions} deleted ${pluralize(diffStat.deletions, "line")}`); + parts.push(labels.deletedLine(diffStat.deletions)); } return parts.length > 0 ? parts.join(", ") : null; } -export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): string[] { +export function buildWorktreeArchiveRiskReasons( + input: WorktreeArchiveRisk, + labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, +): string[] { const reasons: string[] = []; const diffStat = input.diffStat; const hasDiffStatChanges = diffStat ? diffStat.additions > 0 || diffStat.deletions > 0 : false; @@ -38,13 +73,15 @@ export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): str input.isDirty === true || (input.isDirty == null && hasDiffStatChanges); if (hasUncommittedChanges) { - const diffStatLabel = formatDiffStat(diffStat); - reasons.push(diffStatLabel ? `Uncommitted changes (${diffStatLabel})` : "Uncommitted changes"); + const diffStatLabel = formatDiffStat(diffStat, labels); + reasons.push( + diffStatLabel ? labels.uncommittedChangesWithDiff(diffStatLabel) : labels.uncommittedChanges, + ); } if ((input.aheadOfOrigin ?? 0) > 0) { const aheadOfOrigin = input.aheadOfOrigin ?? 0; - reasons.push(`${aheadOfOrigin} unpushed ${pluralize(aheadOfOrigin, "commit")}`); + reasons.push(labels.unpushedCommit(aheadOfOrigin)); } return reasons; @@ -52,8 +89,9 @@ export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): str export function buildWorktreeArchiveConfirmationMessage( input: WorktreeArchiveConfirmationInput, + labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, ): string | null { - const reasons = buildWorktreeArchiveRiskReasons(input); + const reasons = buildWorktreeArchiveRiskReasons(input, labels); if (reasons.length === 0) { return null; } @@ -63,17 +101,18 @@ export function buildWorktreeArchiveConfirmationMessage( export async function confirmRiskyWorktreeArchive( input: WorktreeArchiveConfirmationInput, + labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, ): Promise { - const message = buildWorktreeArchiveConfirmationMessage(input); + const message = buildWorktreeArchiveConfirmationMessage(input, labels); if (!message) { return true; } return await confirmDialog({ - title: `Archive "${input.worktreeName}"?`, + title: labels.title(input.worktreeName), message, - confirmLabel: "Archive", - cancelLabel: "Cancel", + confirmLabel: labels.confirm, + cancelLabel: labels.cancel, destructive: true, }); } diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index b3478ae54..19c57f7e4 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,4 +1,5 @@ import type { DesktopDialogBridge } from "@/desktop/host"; +import { i18n } from "@/i18n/i18next"; import { isAbsolutePath } from "@/utils/path"; export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; @@ -85,8 +86,13 @@ export async function openImagePathsWithDesktopDialog( const options = { directory: false, multiple: true, - filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }], - title: "Attach images", + filters: [ + { + name: i18n.t("imageAttachmentPicker.dialogFilterName"), + extensions: IMAGE_FILE_EXTENSIONS, + }, + ], + title: i18n.t("imageAttachmentPicker.dialogTitle"), }; const dialogOpen = dialog?.open; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 370609a6f..d0a6c0e11 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { useAgentCommandsQuery, @@ -115,13 +117,14 @@ function mapDirectorySuggestionsToEntries(payload: { })); } -function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption { +function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption { const command = entry.command; const base = { id: command.name, label: `/${command.name}`, detail: command.argumentHint || undefined, - description: command.description, + description: + entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description, kind: "command" as const, }; if (entry.source === "client") { @@ -148,6 +151,7 @@ interface BuildAutocompleteOptionsInput { activeSlashCommand: SlashCommandRange | null; activeFileMention: FileMentionRange | null; fileSuggestions: DirectorySuggestionEntry[]; + t: TFunction; } function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { @@ -177,7 +181,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { input.commandFilterQuery, ); const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map(mapCommandToOption); + return orderedMatches.map((entry) => mapCommandToOption(entry, input.t)); } const activeFileMention = input.activeFileMention; @@ -258,9 +262,12 @@ function resolveAutocompleteErrorMessage(args: { isCommandError: boolean; commandError: Error | null; fileSuggestionsError: unknown; + t: TFunction; }): string | undefined { if (args.mode === "command") { - return args.isCommandError ? (args.commandError?.message ?? "Failed to load") : undefined; + return args.isCommandError + ? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad")) + : undefined; } if (args.mode === "file") { return args.fileSuggestionsError instanceof Error @@ -271,6 +278,7 @@ function resolveAutocompleteErrorMessage(args: { } export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { + const { t } = useTranslation(); const { userInput, cursorIndex, @@ -366,7 +374,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut ], queryFn: async (): Promise => { if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error(t("common.errors.daemonClientUnavailable")); } const response = await client.getDirectorySuggestions({ cwd: autocompleteCwd, @@ -402,6 +410,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut isDraftContext, isVisible, mode, + t, }), [ activeFileMention, @@ -412,6 +421,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut isDraftContext, isVisible, mode, + t, ], ); @@ -488,10 +498,15 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut isCommandError: isError, commandError: error, fileSuggestionsError: fileSuggestionsQuery.error, + t, }); - const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands..."; - const emptyText = mode === "file" ? "No files or directories found" : "No commands found"; + const loadingText = + mode === "file" + ? t("agentAutocomplete.searchingWorkspace") + : t("agentAutocomplete.loadingCommands"); + const emptyText = + mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands"); return { isVisible, diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 392c16f8e..b6418d4c9 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; @@ -65,6 +66,7 @@ export function useAgentCommandsQuery({ enabled = true, draftConfig, }: UseAgentCommandsQueryOptions) { + const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -72,7 +74,7 @@ export function useAgentCommandsQuery({ queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig), queryFn: async () => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return fetchAgentCommands({ client, agentId, draftConfig }); }, diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index ff9fa0101..1abfc56c2 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -5,6 +5,7 @@ import type { } from "@getpaseo/client/internal/daemon-client"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; @@ -76,6 +77,7 @@ export function useAgentHistory(options: { serverId?: string | null; enabled?: boolean; }): AgentHistoryResult { + const { t } = useTranslation(); const daemons = useHosts(); const serverId = useMemo(() => { const value = options.serverId; @@ -102,7 +104,7 @@ export function useAgentHistory(options: { lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null, queryFn: async ({ pageParam }) => { if (!serverId || !client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } return fetchAgentHistoryPage({ client, serverId, cursor: pageParam }); }, diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 1e0389363..f37e4622b 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore } from "@/stores/session-store"; import { @@ -9,6 +10,7 @@ import { rejectInitDeferred, } from "@/utils/agent-initialization"; import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; +import { i18n } from "@/i18n/i18next"; export const INIT_TIMEOUT_MS = 30_000; @@ -19,6 +21,7 @@ export interface EnsureAgentIsInitializedInput { agentId: string; client: Pick | null; setAgentInitializing: SetAgentInitializing; + hostDisconnectedMessage?: string; } export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise { @@ -48,7 +51,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): if (!client) { setAgentInitializing(agentId, false); - rejectInitDeferred(key, new Error("Host is not connected")); + rejectInitDeferred( + key, + new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")), + ); return deferred.promise; } @@ -64,12 +70,13 @@ export interface RefreshAgentInput { agentId: string; client: Pick | null; setAgentInitializing: SetAgentInitializing; + hostDisconnectedMessage?: string; } export async function refreshAgent(input: RefreshAgentInput): Promise { const { agentId, client, setAgentInitializing } = input; if (!client) { - throw new Error("Host is not connected"); + throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } setAgentInitializing(agentId, true); @@ -105,6 +112,7 @@ export function useAgentInitialization({ serverId: string; client: DaemonClient | null; }) { + const { t } = useTranslation(); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setAgentInitializing = useMemo( () => createSetAgentInitializing(serverId, setInitializingAgents), @@ -113,13 +121,25 @@ export function useAgentInitialization({ const ensureAgentIsInitializedCallback = useCallback( (agentId: string): Promise => - ensureAgentIsInitialized({ serverId, agentId, client, setAgentInitializing }), - [client, serverId, setAgentInitializing], + ensureAgentIsInitialized({ + serverId, + agentId, + client, + setAgentInitializing, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + }), + [client, serverId, setAgentInitializing, t], ); const refreshAgentCallback = useCallback( - (agentId: string): Promise => refreshAgent({ agentId, client, setAgentInitializing }), - [client, setAgentInitializing], + (agentId: string): Promise => + refreshAgent({ + agentId, + client, + setAgentInitializing, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + }), + [client, setAgentInitializing, t], ); return { diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 333e0b71c..800a58080 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey } from "./agent-history-query-key"; @@ -382,6 +383,7 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet export function useArchiveAgent() { const queryClient = useQueryClient(); + const { t } = useTranslation(); const pendingQuery = useArchiveAgentPendingQuery(); @@ -389,7 +391,7 @@ export function useArchiveAgent() { mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => { const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null; if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return await client.archiveAgent(input.agentId); }, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index 192426527..9fe9c1acf 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useMemo } from "react"; import { useQuery, type QueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; @@ -35,13 +36,14 @@ export function useBranchSwitcher({ toast, queryClient, }: UseBranchSwitcherInput): UseBranchSwitcherResult { + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const branchSuggestionsQuery = useQuery({ queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], queryFn: async () => { if (!client) { - throw new Error("Daemon client unavailable"); + throw new Error(t("common.errors.daemonClientUnavailable")); } const payload = await client.getBranchSuggestions({ cwd: normalizedWorkspaceId, @@ -85,35 +87,34 @@ export function useBranchSwitcher({ const targetStash = stashPayload.entries.find((e) => e.branch === branchId); if (!targetStash) return; const shouldRestore = await confirmDialog({ - title: "Restore stashed changes?", - message: - "This branch has stashed changes from a previous session. Would you like to restore them?", - confirmLabel: "Restore", - cancelLabel: "Later", + title: t("branchSwitcher.restoreStashTitle"), + message: t("branchSwitcher.restoreStashMessage"), + confirmLabel: t("branchSwitcher.restore"), + cancelLabel: t("branchSwitcher.later"), }); if (!shouldRestore) return; const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); if (popPayload.error) { toast.error(popPayload.error.message); } else { - toast.show("Stashed changes restored"); + toast.show(t("branchSwitcher.stashRestored")); } await invalidateStashAndCheckout(); } catch { // Non-critical — user can still restore on next branch switch } }, - [client, invalidateStashAndCheckout, normalizedWorkspaceId, toast], + [client, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], ); const stashAndSwitch = useCallback( async (branchId: string) => { if (!client) return; const shouldStash = await confirmDialog({ - title: "Uncommitted changes", - message: "You have uncommitted changes. Stash them before switching branches?", - confirmLabel: "Stash & Switch", - cancelLabel: "Cancel", + title: t("branchSwitcher.uncommittedTitle"), + message: t("branchSwitcher.uncommittedMessage"), + confirmLabel: t("branchSwitcher.stashAndSwitch"), + cancelLabel: t("common.actions.cancel"), }); if (!shouldStash) return; @@ -133,10 +134,10 @@ export function useBranchSwitcher({ } await invalidateStashAndCheckout(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to stash changes"); + toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToStash")); } }, - [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast], + [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast, t], ); const handleBranchSelect = useCallback( @@ -160,7 +161,7 @@ export function useBranchSwitcher({ await invalidateStashAndCheckout(); await maybeRestoreStashForBranch(branchId); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to switch branch"); + toast.error(err instanceof Error ? err.message : t("branchSwitcher.failedToSwitch")); } })(); }, @@ -171,6 +172,7 @@ export function useBranchSwitcher({ maybeRestoreStashForBranch, normalizedWorkspaceId, stashAndSwitch, + t, toast, ], ); diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts index 3ec4ecb2d..0ba74c97e 100644 --- a/packages/app/src/hooks/use-clear-workspace-attention.ts +++ b/packages/app/src/hooks/use-clear-workspace-attention.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { i18n } from "@/i18n/i18next"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -25,7 +26,7 @@ export function useClearWorkspaceAttention({ } const client = getHostRuntimeStore().getClient(serverId); if (!client) { - throw new Error("Host is not connected"); + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); } await client.clearWorkspaceAttention(workspaceId); }, [hasClearableAttention, serverId, workspaceId]); diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 4269cd804..a5871fa73 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; @@ -52,7 +53,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { interface CommandCenterActionDefinition { id: string; - title: string; + titleKey: + | "shell.commandCenter.openProject" + | "shell.commandCenter.home" + | "sidebar.actions.settings"; icon?: "plus" | "settings" | "home"; actionId?: string; keywords: string[]; @@ -62,7 +66,7 @@ interface CommandCenterActionDefinition { const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ { id: "new-agent", - title: "Open project", + titleKey: "shell.commandCenter.openProject", icon: "plus", actionId: "new-agent", keywords: ["open", "project", "folder", "workspace", "repo"], @@ -70,24 +74,28 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ }, { id: "home", - title: "Home", + titleKey: "shell.commandCenter.home", icon: "home", keywords: ["home", "start", "import", "session", "pair", "device", "providers"], routeKind: "home", }, { id: "settings", - title: "Settings", + titleKey: "sidebar.actions.settings", icon: "settings", keywords: ["settings", "preferences", "config", "configuration"], routeKind: "settings", }, ]; -function matchesActionQuery(query: string, action: CommandCenterActionDefinition): boolean { +function matchesActionQuery( + query: string, + action: CommandCenterActionDefinition, + title: string, +): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) return true; - if (action.title.toLowerCase().includes(normalized)) { + if (title.toLowerCase().includes(normalized)) { return true; } return action.keywords.some((keyword) => keyword.includes(normalized)); @@ -129,6 +137,7 @@ function resolveActionShortcutKeys( } export function useCommandCenter() { + const { t } = useTranslation(); const pathname = usePathname(); const routeActiveServerId = useActiveServerId(); const { overrides } = useKeyboardShortcutOverrides(); @@ -174,7 +183,7 @@ export function useCommandCenter() { } return COMMAND_CENTER_ACTIONS.filter((action) => { if (action.routeKind === "home" && !homeRoute) return false; - return matchesActionQuery(query, action); + return matchesActionQuery(query, action, t(action.titleKey)); }).map((action) => { let route: Href | undefined; if (action.routeKind === "settings") route = settingsRoute; @@ -182,13 +191,13 @@ export function useCommandCenter() { return { kind: "action", id: action.id, - title: action.title, + title: t(action.titleKey), icon: action.icon, route, shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), }; }); - }, [open, query, settingsRoute, homeRoute, overrides]); + }, [open, query, settingsRoute, homeRoute, overrides, t]); const items = useMemo(() => { if (!open) { diff --git a/packages/app/src/hooks/use-daemon-config.ts b/packages/app/src/hooks/use-daemon-config.ts index 5e94fd5ec..422f5d540 100644 --- a/packages/app/src/hooks/use-daemon-config.ts +++ b/packages/app/src/hooks/use-daemon-config.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -14,6 +15,7 @@ interface UseDaemonConfigResult { } export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); @@ -25,7 +27,7 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult staleTime: Infinity, queryFn: async () => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const result = await client.getDaemonConfig(); return result.config; diff --git a/packages/app/src/hooks/use-dictation.shared.ts b/packages/app/src/hooks/use-dictation.shared.ts index b7a7293d5..441aa9120 100644 --- a/packages/app/src/hooks/use-dictation.shared.ts +++ b/packages/app/src/hooks/use-dictation.shared.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; + export type DictationStatus = "idle" | "recording" | "uploading" | "failed"; export interface UseDictationOptions { @@ -37,5 +39,5 @@ export const toError = (error: unknown): Error => { if (typeof error === "string" && error.trim().length > 0) { return new Error(error); } - return new Error("An unexpected error occurred while handling dictation."); + return new Error(i18n.t("common.errors.unexpectedDictationError")); }; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index a7329610a..2b01d9c52 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { DictationStreamSender } from "@/dictation/dictation-stream-sender"; import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source"; @@ -14,6 +15,7 @@ import { } from "./use-dictation.shared"; export function useDictation(options: UseDictationOptions): UseDictationResult { + const { t } = useTranslation(); const { client, onTranscript, @@ -389,7 +391,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { try { if (!client?.isConnected) { - throw new Error("Daemon client is disconnected"); + throw new Error(t("common.errors.daemonClientDisconnected")); } senderRef.current.resetStreamForReplay(); const finalSeq = senderRef.current.getFinalSeq(); @@ -401,7 +403,13 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { } handleDictationFailure(err); } - }, [client, ensureFinalTranscript, handleDictationFailure, handleStreamingTranscriptionSuccess]); + }, [ + client, + ensureFinalTranscript, + handleDictationFailure, + handleStreamingTranscriptionSuccess, + t, + ]); const discardFailedDictation = useCallback(() => { setIsProcessing(false); diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index 68281acbc..207ac72e8 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; @@ -23,6 +24,7 @@ export function useDraftAgentFeatures(input: { thinkingOptionId: string | null | undefined; initialFeatureValues?: Record; }) { + const { t } = useTranslation(); const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } = input; const [localFeatureValues, setLocalFeatureValues] = useState>( @@ -67,7 +69,7 @@ export function useDraftAgentFeatures(input: { staleTime: 5 * 60 * 1000, queryFn: async () => { if (!client || !draftConfig) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const payload = await client.listProviderFeatures(draftConfig); if (payload.error) { diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 6cf587376..0502b0820 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; @@ -51,6 +52,7 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope } export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) { + const { t } = useTranslation(); const { serverId, workspaceId, workspaceRoot } = params; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); @@ -114,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: "Workspace is unavailable", + lastError: t("workspace.fileExplorer.states.unavailable"), pendingRequest: null, })); return false; @@ -124,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: "Host is not connected", + lastError: t("workspace.terminal.hostDisconnected"), pendingRequest: null, })); return false; @@ -153,13 +155,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: error instanceof Error ? error.message : "Failed to list directory", + lastError: + error instanceof Error + ? error.message + : t("workspace.fileExplorer.errors.failedToListDirectory"), pendingRequest: null, })); return false; } }, - [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], + [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], ); const requestFilePreview = useCallback( @@ -179,7 +184,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: "Workspace is unavailable", + lastError: t("workspace.fileExplorer.states.unavailable"), pendingRequest: null, })); return; @@ -189,7 +194,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: "Host is not connected", + lastError: t("workspace.terminal.hostDisconnected"), pendingRequest: null, })); return; @@ -218,21 +223,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor updateExplorerState((state) => ({ ...state, isLoading: false, - lastError: error instanceof Error ? error.message : "Failed to load file preview", + lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"), pendingRequest: null, })); } }, - [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], + [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], ); const requestFileDownloadToken = useCallback( async (path: string) => { if (!normalizedWorkspaceRoot) { - throw new Error("Workspace is unavailable"); + throw new Error(t("workspace.fileExplorer.states.unavailable")); } if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path); if (payload.error) { @@ -240,7 +245,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } return payload; }, - [client, normalizedWorkspaceRoot], + [client, normalizedWorkspaceRoot, t], ); const selectExplorerEntry = useCallback( diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 0475f3ef8..30790bc09 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; import * as ImagePicker from "expo-image-picker"; +import { useTranslation } from "react-i18next"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { normalizePickedImageAssets, @@ -14,6 +15,7 @@ interface UseImageAttachmentPickerResult { } export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { + const { t } = useTranslation(); const [mediaPermission, requestMediaPermission] = ImagePicker.useMediaLibraryPermissions(); const isPickingRef = useRef(false); @@ -31,14 +33,14 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { if (!currentPermission?.granted) { Alert.alert( - "Permission required", - "Please allow access to your photo library to attach images.", + t("imageAttachmentPicker.permissionTitle"), + t("imageAttachmentPicker.permissionMessage"), ); return false; } return true; - }, [mediaPermission, requestMediaPermission]); + }, [mediaPermission, requestMediaPermission, t]); const pickImages = useCallback(async () => { if (isPickingRef.current) { @@ -83,12 +85,12 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { return await normalizePickedImageAssets(result.assets); } catch (error) { console.error("[ImageAttachmentPicker] Failed to pick image:", error); - Alert.alert("Error", "Failed to select image"); + Alert.alert(t("imageAttachmentPicker.errorTitle"), t("imageAttachmentPicker.failedToSelect")); return null; } finally { isPickingRef.current = false; } - }, [ensurePermission]); + }, [ensurePermission, t]); return { pickImages }; } diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 6474559a0..775f5f23a 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -1,5 +1,7 @@ import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; import type { ToastApi } from "@/components/toast-host"; +import { i18n } from "@/i18n/i18next"; import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; @@ -27,13 +29,15 @@ export interface LoadOlderAgentHistoryDeps { setInFlight: (value: boolean) => void; toast?: ToastApi | null; logger?: LoadOlderAgentHistoryLogger; + failedMessage?: string; } export async function loadOlderAgentHistory( agentId: string, deps: LoadOlderAgentHistoryDeps, ): Promise { - const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger } = deps; + const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = + deps; if (!client || !cursor || !hasOlder || isLoadingOlder) { return; } @@ -46,7 +50,7 @@ export async function loadOlderAgentHistory( ); } catch (error) { (logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error); - toast?.show("Couldn't load older history", { + toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), { durationMs: 2200, testID: "agent-load-older-history-toast", }); @@ -64,6 +68,7 @@ export function useLoadOlderAgentHistory({ agentId: string; toast?: ToastApi | null; }) { + const { t } = useTranslation(); const hasOlder = useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) === true; @@ -98,8 +103,9 @@ export function useLoadOlderAgentHistory({ isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, setInFlight, toast, + failedMessage: t("loadOlderHistory.failed"), }); - }, [agentId, serverId, setInFlight, toast]); + }, [agentId, serverId, setInFlight, toast, t]); return { isLoadingOlder, diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index a9178fead..d5d449303 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -1,5 +1,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse, @@ -9,6 +10,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { prPaneTimelineQueryKey } from "@/git/query-keys"; +import { i18n } from "@/i18n/i18next"; type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"]; type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; @@ -153,6 +155,8 @@ export interface SelectPrPaneStateInput { timelineError: Error | null; timelineIsLoading: boolean; timelineIsFetching: boolean; + statusLoadFailedLabel?: string; + activityLoadFailedLabel?: string; } export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult { @@ -176,6 +180,10 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR statusError: input.statusError, timelineError: input.timelineError, timelinePayloadError: input.timelinePayload?.error ?? null, + statusLoadFailedLabel: + input.statusLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.statusLoadFailed"), + activityLoadFailedLabel: + input.activityLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.activityLoadFailed"), }), githubFeaturesEnabled: input.githubFeaturesEnabled, }; @@ -187,6 +195,7 @@ export function usePrPaneData({ enabled = true, timelineEnabled = enabled, }: UsePrPaneDataOptions): UsePrPaneDataResult { + const { t } = useTranslation(); const daemonClient = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); @@ -221,7 +230,7 @@ export function usePrPaneData({ identity.repoOwner === null || identity.repoName === null ) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } return fetchPrPaneTimelinePage({ client: daemonClient, @@ -255,6 +264,8 @@ export function usePrPaneData({ timelineError: timelineQuery.error, timelineIsLoading: timelineQuery.isLoading, timelineIsFetching: timelineQuery.isFetching, + statusLoadFailedLabel: t("workspace.git.pr.errors.statusLoadFailed"), + activityLoadFailedLabel: t("workspace.git.pr.errors.activityLoadFailed"), }); } @@ -263,14 +274,18 @@ function firstNonSuppressedError({ statusError, timelineError, timelinePayloadError, + statusLoadFailedLabel, + activityLoadFailedLabel, }: { statusPayloadError: CheckoutPrStatusPayloadError; statusError: Error | null; timelineError: Error | null; timelinePayloadError: PullRequestTimeline["error"]; + statusLoadFailedLabel: string; + activityLoadFailedLabel: string; }): Error | null { if (statusPayloadError) { - return new Error(statusPayloadError.message || "Unable to load pull request status"); + return new Error(statusPayloadError.message || statusLoadFailedLabel); } if (statusError) { @@ -282,7 +297,7 @@ function firstNonSuppressedError({ } if (timelinePayloadError) { - return new Error(timelinePayloadError.message || "Unable to load pull request activity"); + return new Error(timelinePayloadError.message || activityLoadFailedLabel); } return null; diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts index 79cd9f64a..7b3ee5892 100644 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { ProjectIcon } from "@getpaseo/protocol/messages"; @@ -19,6 +20,7 @@ interface UseProjectIconQueryOptions { } export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { + const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -26,7 +28,7 @@ export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOption queryKey: projectIconQueryKey(serverId, cwd), queryFn: async (): Promise => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } const result = await client.requestProjectIcon(cwd); return result.icon; diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index ea383e585..6e036be8f 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -112,6 +113,7 @@ export function useProvidersSnapshot( serverId: string | null, options: UseProvidersSnapshotOptions = {}, ): UseProvidersSnapshotResult { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); @@ -129,7 +131,7 @@ export function useProvidersSnapshot( staleTime: 60_000, queryFn: async () => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } return fetchProvidersSnapshot({ client, cwd }); }, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 8baa326cf..fb8be8f85 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { queryClient as appQueryClient } from "@/query/query-client"; +import type { AppLanguage } from "@/i18n/locales"; import { DEFAULT_DESKTOP_SETTINGS, loadDesktopSettings, @@ -58,6 +59,7 @@ export { }; export type { AppSettings, + AppLanguage, DesktopSettingsBridge, KeyValueStorage, ReleaseChannel, @@ -143,6 +145,9 @@ export function useSettings(): UseSettingsReturn { if (updates.theme !== undefined) { appUpdates.theme = updates.theme; } + if (updates.language !== undefined) { + appUpdates.language = updates.language; + } if (updates.sendBehavior !== undefined) { appUpdates.sendBehavior = updates.sendBehavior; } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index e20d6e31b..796a362dd 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -47,11 +47,20 @@ describe("loadAppSettingsFromStorage", () => { const result = await loadAppSettingsFromStorage(deps); expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); + expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( JSON.stringify(DEFAULT_CLIENT_SETTINGS), ); }); + it("defaults language to system when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.language).toBe("system"); + }); + it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -95,6 +104,30 @@ describe("loadAppSettingsFromStorage", () => { }); expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result)); }); + + it("loads a persisted explicit language", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ language: "zh-CN" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.language).toBe("zh-CN"); + }); + + it("drops an unknown persisted language back to system", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ language: "klingon" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.language).toBe("system"); + }); }); describe("loadSettingsFromStorage", () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index a06c73afe..ff2c180d1 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -1,6 +1,7 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight"; import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import { parseAppLanguage, type AppLanguage } from "@/i18n/locales"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; export const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -26,6 +27,7 @@ export const MAX_FONT_FAMILY_LENGTH = 200; export interface AppSettings { theme: ThemeName | "auto"; + language: AppLanguage; sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; terminalScrollbackLines: number; @@ -43,6 +45,7 @@ export interface Settings extends AppSettings { export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: "auto", + language: "system", sendBehavior: "interrupt", serviceUrlBehavior: "ask", terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, @@ -149,6 +152,10 @@ function pickAppSettings(stored: Partial): Partial { if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { result.theme = stored.theme; } + const language = parseAppLanguage(stored.language); + if (language !== null) { + result.language = language; + } if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { result.sendBehavior = stored.sendBehavior; } diff --git a/packages/app/src/i18n/i18next.ts b/packages/app/src/i18n/i18next.ts new file mode 100644 index 000000000..9ad28632b --- /dev/null +++ b/packages/app/src/i18n/i18next.ts @@ -0,0 +1,35 @@ +import { createInstance } from "i18next"; +import { initReactI18next } from "react-i18next"; +import { observeI18nInit } from "./init"; +import { ar } from "./resources/ar"; +import { en } from "./resources/en"; +import { es } from "./resources/es"; +import { fr } from "./resources/fr"; +import { ru } from "./resources/ru"; +import { zhCN } from "./resources/zh-CN"; + +const i18n = createInstance(); + +observeI18nInit( + i18n.use(initReactI18next).init({ + compatibilityJSON: "v4", + fallbackLng: "en", + lng: "en", + resources: { + ar: { translation: ar }, + en: { translation: en }, + es: { translation: es }, + fr: { translation: fr }, + ru: { translation: ru }, + "zh-CN": { translation: zhCN }, + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, + }), +); + +export { i18n }; diff --git a/packages/app/src/i18n/init.test.ts b/packages/app/src/i18n/init.test.ts new file mode 100644 index 000000000..b4456e186 --- /dev/null +++ b/packages/app/src/i18n/init.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from "vitest"; +import { observeI18nInit } from "./init"; + +describe("observeI18nInit", () => { + it("reports initialization failures", async () => { + const error = new Error("init failed"); + const reportError = vi.fn(); + + observeI18nInit(Promise.reject(error), reportError); + await Promise.resolve(); + + expect(reportError).toHaveBeenCalledWith("[i18n] Failed to initialize", error); + }); +}); diff --git a/packages/app/src/i18n/init.ts b/packages/app/src/i18n/init.ts new file mode 100644 index 000000000..d80f4e523 --- /dev/null +++ b/packages/app/src/i18n/init.ts @@ -0,0 +1,10 @@ +type InitReporter = (message: string, error: unknown) => void; + +export function observeI18nInit( + initPromise: Promise, + report: InitReporter = console.error, +): void { + initPromise.catch((error: unknown) => { + report("[i18n] Failed to initialize", error); + }); +} diff --git a/packages/app/src/i18n/locales.test.ts b/packages/app/src/i18n/locales.test.ts new file mode 100644 index 000000000..e10270091 --- /dev/null +++ b/packages/app/src/i18n/locales.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { LANGUAGE_OPTIONS, parseAppLanguage, resolveSupportedLocale } from "./locales"; + +describe("parseAppLanguage", () => { + it("accepts system and all UN official language locales", () => { + expect(["system", "ar", "en", "es", "fr", "ru", "zh-CN"].map(parseAppLanguage)).toEqual([ + "system", + "ar", + "en", + "es", + "fr", + "ru", + "zh-CN", + ]); + }); + + it("returns null for unknown values", () => { + expect(parseAppLanguage("de")).toBeNull(); + expect(parseAppLanguage(null)).toBeNull(); + }); + + it("offers system plus the six UN official languages", () => { + expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([ + "system", + "ar", + "en", + "es", + "fr", + "ru", + "zh-CN", + ]); + }); +}); + +describe("resolveSupportedLocale", () => { + it("respects explicit language choices", () => { + expect(resolveSupportedLocale("ar", ["en-US"])).toBe("ar"); + expect(resolveSupportedLocale("en", ["zh-CN"])).toBe("en"); + expect(resolveSupportedLocale("es", ["en-US"])).toBe("es"); + expect(resolveSupportedLocale("fr", ["en-US"])).toBe("fr"); + expect(resolveSupportedLocale("ru", ["en-US"])).toBe("ru"); + expect(resolveSupportedLocale("zh-CN", ["en-US"])).toBe("zh-CN"); + }); + + it("maps UN official system locales", () => { + expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar"); + expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es"); + expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr"); + expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru"); + }); + + it("maps Chinese system locales to Simplified Chinese", () => { + expect(resolveSupportedLocale("system", ["zh"])).toBe("zh-CN"); + expect(resolveSupportedLocale("system", ["zh-CN"])).toBe("zh-CN"); + expect(resolveSupportedLocale("system", ["zh-Hans-US"])).toBe("zh-CN"); + }); + + it("does not map Traditional Chinese system locales to Simplified Chinese", () => { + expect(resolveSupportedLocale("system", ["zh-TW"])).toBe("en"); + expect(resolveSupportedLocale("system", ["zh-Hant"])).toBe("en"); + expect(resolveSupportedLocale("system", ["zh-HK"])).toBe("en"); + }); + + it("maps unsupported or missing system locales to English", () => { + expect(resolveSupportedLocale("system", ["de-DE"])).toBe("en"); + expect(resolveSupportedLocale("system", [])).toBe("en"); + }); +}); diff --git a/packages/app/src/i18n/locales.ts b/packages/app/src/i18n/locales.ts new file mode 100644 index 000000000..30689fee8 --- /dev/null +++ b/packages/app/src/i18n/locales.ts @@ -0,0 +1,57 @@ +export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"; +export type AppLanguage = "system" | SupportedLocale; + +export interface LanguageOption { + value: AppLanguage; + labelKey: string; +} + +export const DEFAULT_LOCALE: SupportedLocale = "en"; + +export const LANGUAGE_OPTIONS: LanguageOption[] = [ + { value: "system", labelKey: "settings.general.language.options.system" }, + { value: "ar", labelKey: "settings.general.language.options.ar" }, + { value: "en", labelKey: "settings.general.language.options.en" }, + { value: "es", labelKey: "settings.general.language.options.es" }, + { value: "fr", labelKey: "settings.general.language.options.fr" }, + { value: "ru", labelKey: "settings.general.language.options.ru" }, + { value: "zh-CN", labelKey: "settings.general.language.options.zhCN" }, +]; + +const SUPPORTED_LANGUAGES = new Set(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]); + +export function parseAppLanguage(value: unknown): AppLanguage | null { + return typeof value === "string" && SUPPORTED_LANGUAGES.has(value as AppLanguage) + ? (value as AppLanguage) + : null; +} + +export function resolveSupportedLocale( + language: AppLanguage, + systemLocales: readonly string[], +): SupportedLocale { + if (language !== "system") { + return language; + } + + for (const locale of systemLocales) { + const normalized = locale.toLowerCase(); + if (normalized === "ar" || normalized.startsWith("ar-")) { + return "ar"; + } + if (normalized === "es" || normalized.startsWith("es-")) { + return "es"; + } + if (normalized === "fr" || normalized.startsWith("fr-")) { + return "fr"; + } + if (normalized === "ru" || normalized.startsWith("ru-")) { + return "ru"; + } + if (normalized === "zh" || normalized === "zh-cn" || normalized.startsWith("zh-hans")) { + return "zh-CN"; + } + } + + return DEFAULT_LOCALE; +} diff --git a/packages/app/src/i18n/provider.tsx b/packages/app/src/i18n/provider.tsx new file mode 100644 index 000000000..a9768f7e8 --- /dev/null +++ b/packages/app/src/i18n/provider.tsx @@ -0,0 +1,30 @@ +import * as Localization from "expo-localization"; +import { type ReactNode, useMemo } from "react"; +import { I18nextProvider } from "react-i18next"; +import { isWeb } from "@/constants/platform"; +import { useAppSettings } from "@/hooks/use-settings"; +import { i18n } from "./i18next"; +import { resolveSupportedLocale } from "./locales"; +import { ensureI18nLanguageForRender } from "./sync-language"; + +interface I18nProviderProps { + children: ReactNode; +} + +function getSystemLocales(): string[] { + if (isWeb && typeof navigator !== "undefined" && navigator.languages.length > 0) { + return [...navigator.languages]; + } + + return Localization.getLocales().map((locale) => locale.languageTag); +} + +export function I18nProvider({ children }: I18nProviderProps) { + const { settings } = useAppSettings(); + const systemLocales = useMemo(() => getSystemLocales(), []); + const locale = resolveSupportedLocale(settings.language, systemLocales); + + ensureI18nLanguageForRender(locale, i18n); + + return {children}; +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts new file mode 100644 index 000000000..cf510c474 --- /dev/null +++ b/packages/app/src/i18n/resources.test.ts @@ -0,0 +1,590 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ar } from "./resources/ar"; +import { en } from "./resources/en"; +import { es } from "./resources/es"; +import { fr } from "./resources/fr"; +import { ru } from "./resources/ru"; +import { zhCN } from "./resources/zh-CN"; + +function flattenKeys(value: unknown, prefix = ""): string[] { + if (typeof value !== "object" || value === null) { + return [prefix]; + } + + const entries = Object.entries(value); + return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key)); +} + +function flattenStrings(value: unknown, prefix = ""): Record { + if (typeof value === "string") { + return { [prefix]: value }; + } + if (typeof value !== "object" || value === null) { + return {}; + } + + return Object.fromEntries( + Object.entries(value).flatMap(([key, child]) => + Object.entries(flattenStrings(child, prefix ? `${prefix}.${key}` : key)), + ), + ); +} + +function countMatchingEnglishStrings(resource: unknown): number { + const englishStrings = flattenStrings(en); + const localeStrings = flattenStrings(resource); + return Object.entries(englishStrings).filter(([key, value]) => localeStrings[key] === value) + .length; +} + +function findInterpolationMismatches(resource: unknown): string[] { + const interpolationPattern = /\{\{[^}]+\}\}/g; + const englishStrings = flattenStrings(en); + const localeStrings = flattenStrings(resource); + return Object.entries(englishStrings).flatMap(([key, value]) => { + const expected = [...value.matchAll(interpolationPattern)].map((match) => match[0]).sort(); + const actual = [...(localeStrings[key] ?? "").matchAll(interpolationPattern)] + .map((match) => match[0]) + .sort(); + return expected.join("|") === actual.join("|") + ? [] + : [`${key}: ${expected.join(", ")} -> ${actual.join(", ")}`]; + }); +} + +const appSourceRoot = join(__dirname, ".."); +const untranslatedConnectionErrors = [ + "Daemon unavailable", + "Daemon client unavailable", + "Daemon client not available", + "Daemon client is disconnected", + "Host is not connected", +] as const; +const untranslatedLocalFallbacks = [ + "No file found for ", + "Unable to load pull request status", + "Unable to load pull request activity", + "An unexpected error occurred while handling dictation.", + "Unable to load desktop settings.", + "Unable to save desktop settings.", +] as const; + +function collectSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === "i18n") { + return []; + } + return collectSourceFiles(path); + } + if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\./.test(entry.name)) { + return []; + } + return [path]; + }); +} + +function findUntranslatedConnectionErrors(): string[] { + return collectSourceFiles(appSourceRoot).flatMap((path) => { + const contents = readFileSync(path, "utf8"); + const matches = [...untranslatedConnectionErrors, ...untranslatedLocalFallbacks].filter( + (text) => contents.includes(`"${text}"`) || contents.includes(`\`${text}`), + ); + if (matches.length === 0) { + return []; + } + return [`${relative(appSourceRoot, path)}: ${matches.join(", ")}`]; + }); +} + +describe("translation resources", () => { + it("keeps UN official language keys in sync with English", () => { + const englishKeys = flattenKeys(en).sort(); + expect(flattenKeys(ar).sort()).toEqual(englishKeys); + expect(flattenKeys(es).sort()).toEqual(englishKeys); + expect(flattenKeys(fr).sort()).toEqual(englishKeys); + expect(flattenKeys(ru).sort()).toEqual(englishKeys); + expect(flattenKeys(zhCN).sort()).toEqual(englishKeys); + }); + + it("keeps non-English UN official languages translated beyond fallback labels", () => { + const totalStrings = Object.keys(flattenStrings(en)).length; + const maxFallbackStrings = Math.floor(totalStrings * 0.25); + expect(countMatchingEnglishStrings(ar)).toBeLessThan(maxFallbackStrings); + expect(countMatchingEnglishStrings(es)).toBeLessThan(maxFallbackStrings); + expect(countMatchingEnglishStrings(fr)).toBeLessThan(maxFallbackStrings); + expect(countMatchingEnglishStrings(ru)).toBeLessThan(maxFallbackStrings); + }); + + it("preserves interpolation placeholders in every language", () => { + expect(findInterpolationMismatches(ar)).toEqual([]); + expect(findInterpolationMismatches(es)).toEqual([]); + expect(findInterpolationMismatches(fr)).toEqual([]); + expect(findInterpolationMismatches(ru)).toEqual([]); + expect(findInterpolationMismatches(zhCN)).toEqual([]); + }); + + it("keeps local connection fallback errors translated", () => { + expect(findUntranslatedConnectionErrors()).toEqual([]); + }); + + it("includes shared shell keys for the Batch 1 migration", () => { + expect(en.common.actions.back).toBe("Back"); + expect(en.common.actions.cancel).toBe("Cancel"); + expect(en.common.actions.close).toBe("Close"); + expect(en.common.actions.dismiss).toBe("Dismiss"); + expect(en.common.actions.retry).toBe("Retry"); + expect(en.common.actions.search).toBe("Search"); + expect(en.common.states.starting).toBe("Starting..."); + expect(en.common.states.downloadComplete).toBe("Download complete"); + expect(en.common.states.downloadFailed).toBe("Download failed"); + expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar"); + expect(en.shell.menu.open).toBe("Open menu"); + expect(en.shell.menu.close).toBe("Close menu"); + expect(en.shell.commandCenter.placeholder).toBe("Type a command or search agents..."); + expect(en.shell.commandCenter.noMatches).toBe("No matches"); + expect(en.shell.commandCenter.actions).toBe("Actions"); + expect(en.shell.commandCenter.agents).toBe("Agents"); + expect(en.shell.commandCenter.newAgent).toBe("New agent"); + expect(en.shell.commandCenter.openProject).toBe("Open project"); + expect(en.shell.commandCenter.home).toBe("Home"); + }); + + it("includes composer and agent workflow keys for the Batch 2 migration", () => { + expect(en.composer.placeholders.desktop).toBe( + "Message the agent, tag @files, or use /commands and /skills", + ); + expect(en.composer.input.addAttachment).toBe("Add attachment"); + expect(en.composer.input.sendMessage).toBe("Send message"); + expect(en.composer.voice.startDictation).toBe("Start dictation"); + expect(en.composer.attachments.addIssueOrPr).toBe("Add issue or PR"); + expect(en.composer.github.title).toBe("Attach issue or PR"); + expect(en.agentControls.provider.fallback).toBe("Provider"); + expect(en.agentControls.hints.model).toBe("Change model"); + expect(en.agentControls.features.title).toBe("Features"); + expect(en.agentControls.mode.title).toBe("Mode"); + expect(en.agentStream.permission.required).toBe("Permission Required"); + expect(en.agentStream.permission.proposedPlan).toBe("Proposed plan"); + expect(en.agentPanel.unavailable.selectedHost).toBe("Selected host"); + expect(en.agentPanel.states.notFound).toBe("Agent not found"); + expect(en.panels.draft.newAgent).toBe("New Agent"); + }); + + it("includes Settings expansion keys for the Batch 3A migration", () => { + expect(en.settings.diagnostics.title).toBe("Diagnostics"); + expect(en.settings.about.title).toBe("About"); + expect(en.settings.about.releaseChannel.label).toBe("Release channel"); + expect(en.settings.appearance.theme.title).toBe("Theme"); + expect(en.settings.appearance.fonts.interfaceFont).toBe("Interface font"); + expect(en.settings.shortcuts.actions.rebind).toBe("Rebind"); + expect(en.settings.integrations.commandLine.title).toBe("Command line"); + expect(en.settings.integrations.skills.updateAvailable).toBe("Update available"); + expect(en.settings.permissions.notifications).toBe("Notifications"); + expect(en.settings.permissions.actions.request).toBe("Request"); + }); + + it("includes Settings expansion keys for the Batch 3B migration", () => { + expect(en.settings.host.notFound).toBe("Host not found"); + expect(en.settings.host.connections.title).toBe("Connections"); + expect(en.settings.host.daemon.restart.title).toBe("Restart daemon"); + expect(en.settings.host.orchestration.enableTools.title).toBe("Enable Paseo tools"); + expect(en.settings.providers.title).toBe("Providers"); + expect(en.settings.providers.models.addModel).toBe("Add model"); + expect(en.settings.providers.diagnostic.title).toBe("Diagnostic"); + expect(en.settings.project.worktree.title).toBe("Worktree lifecycle hooks"); + expect(en.settings.project.scripts.actions.add).toBe("Add script"); + expect(en.settings.project.metadata.title).toBe("Metadata generation"); + expect(en.settings.project.actions.save).toBe("Save"); + }); + + it("includes workspace and panel keys for the Batch 4A migration", () => { + expect(en.importSession.title).toBe("Import session"); + expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions"); + expect(en.importSession.actions.refresh).toBe("Refresh sessions"); + expect(en.workspace.fileExplorer.sort.name).toBe("Name"); + expect(en.workspace.fileExplorer.empty.noFiles).toBe("No files"); + expect(en.workspace.setup.status.running).toBe("Running"); + expect(en.workspace.setup.empty.noCommands).toBe("No setup commands ran for this workspace."); + expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only"); + expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL"); + expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected"); + expect(en.panels.file.executionDirectoryMissing).toBe( + "Workspace execution directory not found.", + ); + }); + + it("includes workspace Git and review keys for the Batch 4B migration", () => { + expect(en.workspace.tabs.actions.newAgent).toBe("New agent tab"); + expect(en.workspace.header.actions.copyPath).toBe("Copy workspace path"); + expect(en.workspace.scripts.actions.run).toBe("Run"); + expect(en.workspace.git.actions.commit.label).toBe("Commit"); + expect(en.workspace.git.diff.binaryFile).toBe("Binary file"); + expect(en.workspace.git.pr.sections.checks).toBe("Checks"); + expect(en.review.comment.placeholder).toBe("Leave a comment"); + }); + + it("includes sidebar and workspace creation keys for the Batch 4C migration", () => { + expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path"); + expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?"); + expect(en.newWorkspace.title).toBe("New workspace"); + expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs"); + expect(en.openProject.tiles.addProject.title).toBe("Add a project"); + }); + + it("includes provider selector and pairing keys for the Batch 4D migration", () => { + expect(en.modelSelector.title).toBe("Select provider"); + expect(en.modelSelector.favorites).toBe("Favorites"); + expect(en.providerCatalog.title).toBe("Add provider"); + expect(en.providerCatalog.actions.installInstructions).toBe("Install instructions"); + expect(en.pairing.link.title).toBe("Paste pairing link"); + expect(en.pairing.connectionMethods.direct.title).toBe("Direct connection"); + }); + + it("includes onboarding and direct connection keys for the Batch 4E migration", () => { + expect(en.onboarding.title).toBe("Welcome to Paseo"); + expect(en.onboarding.actions.settings).toBe("Settings"); + expect(en.pairing.direct.title).toBe("Direct connection"); + expect(en.pairing.direct.fields.host).toBe("Host"); + expect(en.pairing.scan.title).toBe("Scan QR"); + expect(en.pairing.device.copy).toBe("Copy"); + }); + + it("includes shared utility chrome keys for the Batch 4F migration", () => { + expect(en.realtimeVoice.actions.mute).toBe("Mute realtime voice"); + expect(en.rewind.actions.conversation).toBe("Rewind conversation"); + expect(en.rewind.warning).toBe("This action cannot be undone"); + expect(en.diffViewer.empty).toBe("No changes to display"); + expect(en.serviceUrl.title).toBe("Open service URL"); + }); + + it("includes keyboard shortcut help keys for the Batch 4G migration", () => { + expect(en.settings.shortcuts.dialogTitle).toBe("Shortcuts"); + expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes"); + expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center"); + expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe( + "Available when focus is not in a text field or terminal.", + ); + }); + + it("includes sessions and agent list keys for the Batch 4H migration", () => { + expect(en.sessions.title).toBe("Sessions"); + expect(en.sessions.empty).toBe("No sessions yet"); + expect(en.sessions.actions.loadMore).toBe("Load more"); + expect(en.agentList.fallbackTitle).toBe("New session"); + expect(en.agentList.dateSections.today).toBe("Today"); + expect(en.agentList.dateSections.older).toBe("Older"); + expect(en.agentList.status.initializing).toBe("Starting"); + expect(en.agentList.status.running).toBe("Running"); + expect(en.agentList.badges.archived).toBe("Archived"); + expect(en.agentList.badges.pending).toBe("{{count}} pending"); + expect(en.agentList.badges.attention).toBe("Attention"); + expect(en.agentList.archiveSheet.hostOffline).toBe("Host offline"); + expect(en.agentList.archiveSheet.runningAgent).toBe( + "This agent is still running. Archiving it will stop the agent.", + ); + expect(en.agentList.archiveSheet.archive).toBe("Archive"); + }); + + it("includes message utility keys for the Batch 4I migration", () => { + expect(en.message.actions.copyCode).toBe("Copy code"); + expect(en.message.actions.copyTurn).toBe("Copy turn"); + expect(en.message.actions.copyMessage).toBe("Copy message"); + expect(en.message.actions.copied).toBe("Copied"); + expect(en.message.attachments.dismissImage).toBe("Dismiss image"); + expect(en.message.attachments.closeImage).toBe("Close image"); + expect(en.message.attachments.imageLoadFailed).toBe("Couldn't load image"); + expect(en.message.attachments.imageUnavailable).toBe("Image unavailable"); + expect(en.message.dictation.start).toBe("Start voice dictation"); + expect(en.message.dictation.cancel).toBe("Cancel dictation"); + expect(en.message.dictation.retry).toBe("Retry dictation"); + expect(en.message.dictation.insert).toBe("Insert transcription"); + expect(en.message.dictation.insertAndSend).toBe("Insert transcription and send"); + expect(en.message.dictation.failed).toBe("Dictation failed: {{error}}"); + expect(en.message.dictation.failedRetry).toBe("Dictation failed. Tap retry."); + expect(en.message.question.submit).toBe("Submit"); + expect(en.message.question.answerPlaceholder).toBe("Type your answer..."); + expect(en.message.question.otherPlaceholder).toBe("Other..."); + expect(en.message.todo.title).toBe("Tasks"); + expect(en.message.todo.empty).toBe("No tasks yet."); + }); + + it("includes workspace tab toast keys for the Batch 4J migration", () => { + expect(en.workspace.tabs.emptyPane).toBe("No tabs in this pane."); + expect(en.workspace.tabs.toasts.copyFailed).toBe("Copy failed"); + expect(en.workspace.tabs.toasts.agentIdCopiedLabel).toBe("Agent ID"); + expect(en.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("resume command"); + expect(en.workspace.tabs.toasts.resumeIdUnavailable).toBe("Resume ID not available"); + expect(en.workspace.tabs.toasts.resumeCommandUnavailable).toBe("Resume command not available"); + expect(en.workspace.tabs.toasts.reloadingAgent).toBe("Reloading agent..."); + expect(en.workspace.tabs.toasts.reloadedAgent).toBe("Reloaded agent"); + expect(en.workspace.tabs.toasts.failedToReloadAgent).toBe("Failed to reload agent"); + expect(en.workspace.header.toasts.workspacePathCopiedLabel).toBe("Workspace path"); + expect(en.workspace.header.toasts.branchNameCopiedLabel).toBe("Branch name"); + }); + + it("includes sidebar project list keys for the Batch 4K migration", () => { + expect(en.sidebar.host.noHost).toBe("No host"); + expect(en.sidebar.host.switchTitle).toBe("Switch host"); + expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts..."); + expect(en.sidebar.actions.addProject).toBe("Add project"); + expect(en.sidebar.actions.home).toBe("Home"); + expect(en.sidebar.actions.settings).toBe("Settings"); + expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar"); + expect(en.sidebar.sections.sessions).toBe("Sessions"); + expect(en.sidebar.workspace.actions.newWorkspace).toBe("New workspace"); + expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe( + "Create a new workspace for {{projectName}}", + ); + expect(en.sidebar.project.empty.title).toBe("No projects yet"); + expect(en.sidebar.project.empty.description).toBe("Add a project to get started"); + expect(en.settings.projectList.hostLoadFailed).toBe( + "Couldn't load projects from host {{hostName}}: {{message}}", + ); + }); + + it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => { + expect(en.projectPicker.placeholder).toBe("Type a directory path..."); + expect(en.projectPicker.opening).toBe("Opening project..."); + expect(en.projectPicker.empty).toBe("Start typing a path"); + expect(en.branchSwitcher.currentBranch).toBe( + "Current branch: {{branchName}}. Press to switch branch.", + ); + expect(en.branchSwitcher.placeholder).toBe("Switch branch..."); + expect(en.branchSwitcher.searchPlaceholder).toBe("Filter branches..."); + expect(en.branchSwitcher.empty).toBe("No branches found."); + expect(en.branchSwitcher.title).toBe("Switch branch"); + expect(en.panels.file.loading).toBe("Loading file..."); + expect(en.panels.file.noPreview).toBe("No preview available"); + expect(en.panels.file.binaryPreviewUnavailable).toBe("Binary preview unavailable"); + expect(en.panels.file.failedToLoad).toBe("Failed to load file"); + expect(en.toolCallDetails.error).toBe("Error"); + expect(en.toolCallDetails.empty).toBe("No additional details available"); + expect(en.message.actions.openFile).toBe("Open file"); + }); + + it("includes hook and modal utility keys for the Batch 4M migration", () => { + expect(en.imageAttachmentPicker.permissionTitle).toBe("Permission required"); + expect(en.imageAttachmentPicker.permissionMessage).toBe( + "Please allow access to your photo library to attach images.", + ); + expect(en.imageAttachmentPicker.errorTitle).toBe("Error"); + expect(en.imageAttachmentPicker.failedToSelect).toBe("Failed to select image"); + expect(en.imageAttachmentPicker.dialogTitle).toBe("Attach images"); + expect(en.imageAttachmentPicker.dialogFilterName).toBe("Images"); + expect(en.common.states.copied).toBe("Copied"); + expect(en.common.states.copiedLabel).toBe("Copied {{label}}"); + expect(en.common.errors.unableToSave).toBe("Unable to save"); + expect(en.common.errors.nameRequired).toBe("Name is required"); + expect(en.common.errors.daemonUnavailable).toBe("Daemon unavailable"); + expect(en.common.errors.daemonClientUnavailable).toBe("Daemon client unavailable"); + expect(en.common.errors.daemonClientDisconnected).toBe("Daemon client is disconnected"); + expect(en.common.errors.noFileFound).toBe("No file found for {{token}}"); + expect(en.common.errors.unexpectedDictationError).toBe( + "An unexpected error occurred while handling dictation.", + ); + expect(en.common.connectionStatus.online).toBe("Online"); + expect(en.common.connectionStatus.connecting).toBe("Connecting"); + expect(en.common.connectionStatus.offline).toBe("Offline"); + expect(en.common.connectionStatus.idle).toBe("Idle"); + expect(en.agentList.dateSections.recent).toBe("Recent"); + expect(en.message.attachments.imagePreviewUnavailable).toBe("Image preview unavailable."); + expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview."); + expect(en.workspace.tabs.explorer.changes).toBe("Changes"); + expect(en.workspace.tabs.explorer.files).toBe("Files"); + expect(en.branchSwitcher.uncommittedTitle).toBe("Uncommitted changes"); + expect(en.branchSwitcher.uncommittedMessage).toBe( + "You have uncommitted changes. Stash them before switching branches?", + ); + expect(en.branchSwitcher.stashAndSwitch).toBe("Stash & Switch"); + expect(en.branchSwitcher.failedToStash).toBe("Failed to stash changes"); + expect(en.branchSwitcher.failedToSwitch).toBe("Failed to switch branch"); + expect(en.workspaceSetup.errors.failedCreateWorktree).toBe("Failed to create worktree"); + expect(en.workspaceSetup.errors.failedOpenProject).toBe("Failed to open project"); + expect(en.workspaceSetup.errors.selectModel).toBe("Select a model"); + expect(en.workspaceSetup.errors.hostDisconnected).toBe("Host is not connected"); + expect(en.workspaceSetup.errors.pendingRequired).toBe("No workspace setup is pending"); + expect(en.workspaceSetup.errors.composerStateRequired).toBe( + "Workspace setup composer state is required", + ); + expect(en.workspaceSetup.title).toBe("Create workspace"); + expect(en.workspace.git.pr.errors.statusLoadFailed).toBe("Unable to load pull request status"); + expect(en.workspace.git.pr.errors.activityLoadFailed).toBe( + "Unable to load pull request activity", + ); + expect(en.desktop.settings.loadFailed).toBe("Unable to load desktop settings."); + expect(en.desktop.settings.saveFailed).toBe("Unable to save desktop settings."); + expect(en.toolCallDetails.input).toBe("Input"); + expect(en.toolCallDetails.output).toBe("Output"); + expect(en.renameModal.rename).toBe("Rename"); + expect(en.renameModal.saving).toBe("Saving..."); + expect(en.sidebarCallout.dismiss).toBe("Dismiss"); + expect(en.contextWindow.title).toBe("Context window"); + expect(en.contextWindow.used).toBe("{{percentage}}% used"); + }); + + it("includes view-model and policy utility keys for the Batch 4N migration", () => { + expect(en.importSession.preview.untitledSession).toBe("Untitled session"); + expect(en.importSession.preview.noPrompt).toBe("No prompt preview"); + expect(en.importSession.empty.noRecent).toBe("No recent sessions to import."); + expect(en.importSession.empty.alreadyImported).toBe( + "All recent sessions are already imported.", + ); + expect(en.importSession.empty.noProviderSessions).toBe("No {{provider}} sessions found."); + expect(en.sidebar.worktreeSetup.title).toBe("Set up worktree scripts"); + expect(en.sidebar.worktreeSetup.description).toBe( + "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", + ); + expect(en.sidebar.worktreeSetup.openProjectSettings).toBe("Open project settings"); + }); + + it("includes remaining small utility chrome keys for the Batch 4O migration", () => { + expect(en.workspace.route.loading).toBe("Loading workspace"); + expect(en.workspace.route.connecting).toBe("Connecting"); + expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline"); + expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}"); + expect(en.workspace.route.hostStatus).toBe("Host status: {{status}}"); + expect(en.workspace.route.missing).toBe("Workspace not found"); + expect(en.message.compaction.loading).toBe("Compacting..."); + expect(en.message.compaction.auto).toBe("Context automatically compacted"); + expect(en.message.compaction.manual).toBe("Context manually compacted"); + expect(en.message.compaction.withTokens).toBe("Context compacted ({{tokens}}K tokens)"); + expect(en.message.compaction.completed).toBe("Context compacted"); + expect(en.agentPanel.archived.callout).toBe("This agent is archived"); + expect(en.agentPanel.archived.unarchive).toBe("Unarchive"); + expect(en.desktop.quitting.title).toBe("Quitting Paseo..."); + expect(en.desktop.quitting.detail).toBe("Stopping the local daemon."); + expect(en.composer.attachments.dropImagesHere).toBe("Drop images here"); + }); + + it("includes provider selection utility keys for the Batch 4P migration", () => { + expect(en.providerSelection.defaultModel).toBe("Default"); + expect(en.providerSelection.selectModel).toBe("Select model"); + expect(en.providerSelection.loading).toBe("Loading..."); + expect(en.providerSelection.error).toBe("Error"); + expect(en.providerSelection.unavailable).toBe("Unavailable"); + expect(en.providerSelection.unknownError).toBe("Unknown error"); + expect(en.providerSelection.readiness.initialPromptRequired).toBe("Initial prompt is required"); + expect(en.providerSelection.readiness.noProviders).toBe( + "No available providers on the selected host", + ); + expect(en.providerSelection.readiness.modelDefaultsLoading).toBe( + "Model defaults are still loading", + ); + }); + + it("includes desktop update utility keys for the Batch 4Q migration", () => { + expect(en.desktop.updates.status.checking).toBe("Checking for app updates..."); + expect(en.desktop.updates.status.installing).toBe("Installing app update..."); + expect(en.desktop.updates.status.upToDate).toBe("App is up to date."); + expect(en.desktop.updates.status.pending).toBe("We'll let you know when the update is ready."); + expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}"); + expect(en.desktop.updates.status.available).toBe("An app update is ready to install."); + expect(en.desktop.updates.status.installed).toBe("App update installed. Restart required."); + expect(en.desktop.updates.status.failed).toBe("Failed to update app."); + expect(en.desktop.updates.status.idle).toBe("Update status has not been checked yet."); + expect(en.desktop.updates.installError).toBe("Unable to install the desktop app update."); + expect(en.desktop.updates.callout.installingTitle).toBe("Installing update"); + expect(en.desktop.updates.callout.failedTitle).toBe("Update failed"); + expect(en.desktop.updates.callout.availableTitle).toBe("Update available"); + expect(en.desktop.updates.callout.genericError).toBe("Something went wrong."); + expect(en.desktop.updates.callout.whatsNew).toBe("What's new"); + expect(en.desktop.updates.callout.installAndRestart).toBe("Install & restart"); + expect(en.desktop.updates.callout.installingDescription).toBe("Installing and restarting..."); + expect(en.desktop.updates.callout.versionReady).toBe("{{version}} is ready to install."); + expect(en.desktop.updates.callout.newVersionReady).toBe("A new version is ready to install."); + expect(en.desktop.updates.callout.restartWarning).toBe( + "Upgrading the app will stop running agents and close terminal sessions.", + ); + expect(en.desktop.rosetta.title).toBe("Download the Apple Silicon build"); + expect(en.desktop.rosetta.runningIntel).toBe( + "You're running the Intel build of Paseo under Rosetta on Apple Silicon.", + ); + expect(en.desktop.rosetta.highCpu).toBe( + "This causes high CPU usage. Download the Apple Silicon build to fix it.", + ); + expect(en.desktop.rosetta.download).toBe("Download"); + }); + + it("includes desktop permission utility keys for the Batch 4R migration", () => { + expect(en.desktop.permissions.notifications.allowed).toBe( + "Notifications are allowed by the OS.", + ); + expect(en.desktop.permissions.notifications.denied).toBe( + "Notifications are denied in system settings.", + ); + expect(en.desktop.permissions.notifications.unexpectedState).toBe( + "Unexpected notification permission state: {{state}}", + ); + expect(en.desktop.permissions.microphone.granted).toBe("Microphone access is granted."); + expect(en.desktop.permissions.microphone.statusApiUnavailable).toBe( + "Microphone status API is unavailable in this runtime. Use Request to check access.", + ); + expect(en.desktop.permissions.microphone.requestDenied).toBe( + "Microphone permission was denied by the user or system.", + ); + expect(en.desktop.permissions.empty.notifications).toBe( + "Notification status has not been checked yet.", + ); + expect(en.desktop.permissions.testNotification.title).toBe("Paseo notification test"); + expect(en.desktop.permissions.testNotification.failed).toBe("Failed to send notification."); + }); + + it("includes desktop daemon settings keys for the Batch 4S migration", () => { + expect(en.desktop.daemon.title).toBe("Daemon"); + expect(en.desktop.daemon.status.title).toBe("Status"); + expect(en.desktop.daemon.status.builtInOnly).toBe( + "Only the built-in desktop daemon is shown here", + ); + expect(en.desktop.daemon.status.notRunning).toBe("not running"); + expect(en.desktop.daemon.status.pid).toBe("PID {{pid}}"); + expect(en.desktop.daemon.management.pauseTitle).toBe("Pause built-in daemon"); + expect(en.desktop.daemon.management.pauseAndStop).toBe("Pause and stop"); + expect(en.desktop.daemon.logs.modalTitle).toBe("Daemon logs"); + expect(en.desktop.daemon.logs.unavailable).toBe("Log path unavailable"); + expect(en.desktop.daemon.fullStatus.modalTitle).toBe("Daemon status"); + expect(en.desktop.daemon.fullStatus.fetchFailed).toBe( + "Failed to fetch daemon status: {{message}}", + ); + expect(en.desktop.daemon.loadFailed).toBe("Unable to load desktop daemon status."); + expect(en.desktop.integrations.cli.installFailed).toBe("Unable to install the Paseo CLI."); + expect(en.desktop.integrations.skills.installFailed).toBe( + "Unable to install orchestration skills.", + ); + }); + + it("includes remaining utility chrome keys for the Batch 4T migration", () => { + expect(en.message.attachments.reviewOne).toBe("Review · 1 comment"); + expect(en.message.attachments.reviewMany).toBe("Review · {{count}} comments"); + expect(en.message.attachments.textAttachment).toBe("Text attachment"); + expect(en.composer.attachments.browserElement).toBe("Element · {{tag}}"); + expect(en.workspace.hoverCard.scriptsAccessibility).toBe("Workspace scripts"); + expect(en.branchSwitcher.restoreStashTitle).toBe("Restore stashed changes?"); + expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored"); + expect(en.agentAutocomplete.searchingWorkspace).toBe("Searching workspace..."); + expect(en.agentAutocomplete.noCommands).toBe("No commands found"); + expect(en.agentAutocomplete.failedToLoad).toBe("Failed to load"); + expect(en.loadOlderHistory.failed).toBe("Couldn't load older history"); + expect(en.agentControls.thinking.extraHigh).toBe("Extra high"); + expect(en.agentControls.model.unknown).toBe("Unknown model"); + expect(en.panels.draft.creatingAgent).toBe("Creating agent"); + }); + + it("includes shared default utility keys for the Batch 4U migration", () => { + expect(en.common.actions.select).toBe("Select"); + expect(en.common.placeholders.search).toBe("Search..."); + expect(en.common.empty.noResults).toBe("No results found"); + expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search."); + expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity"); + expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview"); + }); + + it("includes remaining local wrapper keys for the Batch 4W migration", () => { + expect(en.workspace.header.toasts.branchNameUnavailable).toBe("Branch name not available"); + expect(en.startup.logs.loading).toBe("Loading daemon logs..."); + expect(en.startup.logs.unavailable).toBe("No daemon logs available."); + expect(en.startup.logs.loadFailed).toBe("Unable to load daemon logs: {{message}}"); + }); +}); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts new file mode 100644 index 000000000..28e5ec410 --- /dev/null +++ b/packages/app/src/i18n/resources/ar.ts @@ -0,0 +1,1801 @@ +import type { TranslationResources } from "./en"; + +export const ar: TranslationResources = { + common: { + back: "خلف", + loading: "تحميل...", + actions: { + back: "خلف", + cancel: "يلغي", + close: "يغلق", + copy: "ينسخ", + dismiss: "رفض", + retry: "أعد المحاولة", + search: "يبحث", + select: "يختار", + }, + placeholders: { + search: "يبحث...", + }, + empty: { + noResults: "لم يتم العثور على نتائج", + noOptionsMatchSearch: "لا توجد خيارات تطابق بحثك.", + }, + states: { + loading: "تحميل...", + starting: "جارٍ البدء...", + copied: "منقول", + copiedLabel: "منسوخ{{label}}", + downloadComplete: "اكتمل التنزيل", + downloadFailed: "فشل التنزيل", + }, + errors: { + error: "خطأ", + unableToSave: "غير قادر على الحفظ", + nameRequired: "الاسم مطلوب", + daemonUnavailable: "Daemon غير متوفر", + daemonClientUnavailable: "عميل Daemon غير متوفر", + daemonClientDisconnected: "تم قطع اتصال عميل Daemon", + noFileFound: "لم يتم العثور على ملف لـ{{token}}", + unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.", + }, + connectionStatus: { + online: "متصل", + connecting: "الاتصال", + offline: "غير متصل", + error: "خطأ", + idle: "عاطل", + }, + }, + shell: { + menu: { + toggleSidebar: "تبديل الشريط الجانبي", + open: "فتح القائمة", + close: "إغلاق القائمة", + }, + commandCenter: { + placeholder: "اكتب أمرًا أو وكلاء بحث...", + noMatches: "لا توجد مباريات", + actions: "الإجراءات", + agents: "الوكلاء", + newAgent: "وكيل جديد", + openProject: "مشروع مفتوح", + home: "بيت", + }, + }, + composer: { + placeholders: { + desktop: "أرسل رسالة إلى الوكيل أو ضع علامة على @files أو استخدم /commands و /skills", + mobile: "الرسالة، @files ، /commands", + fallback: "رسالة...", + }, + input: { + accessibilityLabel: "وكيل الرسائل...", + focusHint: "{{shortcut}}للتركيز", + addAttachment: "إضافة مرفق", + interruptAgent: "عامل المقاطعة", + queueMessage: "رسالة قائمة الانتظار", + sendAndInterrupt: "إرسال ومقاطعة", + sendMessage: "أرسل رسالة", + queue: "طابور", + send: "يرسل", + }, + cancel: { + cancelingAgent: "وكيل الإلغاء", + stopAgent: "توقف الوكيل", + interrupt: "مقاطعة", + }, + voice: { + enableVoiceMode: "تمكين الوضع الصوتي", + voiceMode: "وضع الصوت", + unmuteVoiceMode: "إلغاء كتم وضع الصوت", + muteVoiceMode: "وضع كتم الصوت", + stopDictation: "توقف عن الإملاء", + startDictation: "بدء الإملاء", + unmuteVoice: "إلغاء كتم الصوت", + muteVoice: "كتم الصوت", + dictation: "الإملاء", + interruptBeforeVoice: "قم بمقاطعة الوكيل قبل بدء الوضع الصوتي", + }, + attachments: { + addImage: "أضف صورة", + addIssueOrPr: "أضف مشكلة أو PR", + dropImagesHere: "إسقاط الصور هنا", + editQueuedMessage: "تحرير الرسالة في قائمة الانتظار", + sendQueuedMessageNow: "إرسال رسالة في قائمة الانتظار الآن", + openImage: "فتح مرفق الصورة", + removeImage: "إزالة مرفق الصورة", + openGithub: "افتح{{kind}}#{{number}}", + removeGithub: "إزالة{{kind}}#{{number}}", + browserElement: "العنصر ·{{tag}}", + openBrowserElement: "افتح مرفق عنصر المتصفح", + removeBrowserElement: "إزالة مرفق عنصر المتصفح", + openReview: "فتح مرفق المراجعة", + removeReview: "إزالة مرفق المراجعة", + }, + errors: { + failedToSend: "فشل في إرسال الرسالة", + failedToCreateAgent: "فشل في إنشاء الوكيل", + noHostSelected: "لم يتم تحديد مضيف", + initialPromptRequired: "مطلوب موجه الأولي", + alreadyLoading: "جارٍ التحميل بالفعل", + }, + clientCommands: { + archiveAgent: "أرشفة الوكيل الحالي", + freshDraft: "أرشفة هذا الوكيل وابدأ مسودة جديدة", + }, + github: { + searching: "جارٍ البحث...", + noResults: "لم يتم العثور على نتائج.", + searchPlaceholder: "بحث القضايا والعلاقات العامة...", + title: "إرفاق المشكلة أو PR", + }, + }, + agentControls: { + provider: { + fallback: "مزود", + select: "حدد مزود الوكيل", + }, + thinking: { + title: "التفكير", + unknown: "مجهول", + extraHigh: "ارتفاع إضافي", + select: "حدد خيار التفكير", + selectWithValue: "حدد خيار التفكير ({{value}})", + }, + model: { + unknown: "نموذج غير معروف", + }, + features: { + title: "سمات", + open: "ميزات الوكيل المفتوح", + on: "على", + off: "عن", + }, + mode: { + title: "وضع", + searchPlaceholder: "أوضاع البحث...", + selectWithValue: "حدد وضع الوكيل ({{value}})", + }, + hints: { + thinking: "وضع التفكير", + model: "تغيير النموذج", + mode: "تغيير وضع الإذن", + }, + }, + agentStream: { + empty: "ابدأ الدردشة مع هذا الوكيل...", + scrollToBottom: "قم بالتمرير إلى الأسفل", + permission: { + plan: "يخطط", + required: "الإذن مطلوب", + deny: "ينكر", + accept: "يقبل", + implement: "ينفذ", + question: "كيف تريد المتابعة؟", + proposedPlan: "الخطة المقترحة", + }, + }, + agentPanel: { + states: { + notFound: "لم يتم العثور على Agent", + failedToLoad: "فشل تحميل الوكيل", + reconnecting: "جارٍ إعادة الاتصال...", + archivingTitle: "وكيل الارشيف...", + archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.", + }, + unavailable: { + selectedHost: "المضيف المختار", + unknownHost: "لا يمكن فتح هذا الوكيل لأنه لم يتم تكوين{{serverLabel}}على هذا الجهاز.", + addHost: "أضف المضيف في الإعدادات أو افتح وكيلًا على خادم تم تكوينه للمتابعة.", + preparingSession: "جارٍ تحضير جلسة{{serverLabel}}...", + connecting: "جارٍ الاتصال بـ{{serverLabel}}...", + showSoon: "سوف نعرض هذا الوكيل في لحظة.", + showWhenOnline: "سنعرض هذا الوكيل بمجرد اتصال المضيف بالإنترنت.", + reconnectingTo: "جارٍ إعادة الاتصال بـ{{serverLabel}}...", + showAgainWhenReachable: "سنعرض هذا الوكيل مرة أخرى بمجرد الوصول إلى المضيف.", + }, + archived: { + callout: "تمت أرشفة هذا الوكيل", + unarchive: "إلغاء الأرشفة", + }, + }, + sessions: { + title: "الجلسات", + empty: "لا توجد جلسات بعد", + actions: { + loadMore: "تحميل المزيد", + }, + }, + agentList: { + fallbackTitle: "جلسة جديدة", + dateSections: { + recent: "مؤخرًا", + today: "اليوم", + yesterday: "أمس", + thisWeek: "هذا الاسبوع", + thisMonth: "هذا الشهر", + older: "أقدم", + }, + status: { + initializing: "البدء", + idle: "عاطل", + running: "جري", + error: "خطأ", + closed: "مغلق", + }, + badges: { + archived: "مؤرشف", + pending: "{{count}}معلق", + attention: "انتباه", + }, + archiveSheet: { + hostOffline: "Host غير متصل", + runningAgent: "هذا الوكيل لا يزال قيد التشغيل. ستؤدي أرشفته إلى إيقاف الوكيل.", + archive: "أرشيف", + }, + }, + message: { + actions: { + copyCode: "نسخ الرمز", + copyTurn: "نسخ بدوره", + copyMessage: "انسخ الرسالة", + openFile: "افتح الملف", + copied: "منقول", + }, + attachments: { + dismissImage: "تجاهل الصورة", + closeImage: "إغلاق الصورة", + imageLoadFailed: "تعذر تحميل الصورة", + imageUnavailable: "الصورة غير متاحة", + imagePreviewUnavailable: "معاينة الصورة غير متاحة.", + imagePreviewLoadFailed: "غير قادر على تحميل معاينة الصورة.", + reviewOne: "مراجعة · تعليق واحد", + reviewMany: "مراجعة · تعليقات{{count}}", + textAttachment: "مرفق النص", + }, + speak: { + header: "تكلم", + }, + activity: { + details: "تفاصيل", + }, + dictation: { + start: "بدء الإملاء الصوتي", + cancel: "إلغاء الإملاء", + retry: "أعد محاولة الإملاء", + insert: "إدراج النسخ", + insertAndSend: "أدخل النسخ وأرسل", + failed: "فشل الإملاء:{{error}}", + failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.", + }, + question: { + submit: "يُقدِّم", + next: "التالي", + answerPlaceholder: "اكتب إجابتك...", + otherPlaceholder: "آخر...", + }, + todo: { + title: "المهام", + empty: "لا توجد مهام حتى الآن.", + }, + compaction: { + loading: "الضغط...", + auto: "يتم ضغط السياق تلقائيًا", + manual: "تم ضغط السياق يدويًا", + withTokens: "تم ضغط السياق (رموز{{tokens}}K)", + completed: "تم ضغط السياق", + }, + }, + importSession: { + title: "جلسة الاستيراد", + filters: { + all: "الجميع", + }, + status: { + connectHost: "اتصل بمضيف لاستيراد الجلسات", + updateHost: "قم بتحديث المضيف لاستيراد الجلسات.", + noProviders: "لم يتم تمكين أي موفري خدمات قابلين للاستيراد.", + loading: "جارٍ تحميل الجلسات الأخيرة...", + failedAll: "تعذر تحميل الجلسات الأخيرة.", + failedProviders: "تعذر تحميل جلسات العمل لـ{{providers}}.", + failedImport: "تعذر استيراد الجلسة المحددة.", + }, + actions: { + refresh: "تحديث الجلسات", + }, + preview: { + untitledSession: "جلسة بلا عنوان", + noPrompt: "لا توجد معاينة سريعة", + }, + empty: { + noRecent: "لا توجد جلسات حديثة لاستيرادها.", + alreadyImported: "تم بالفعل استيراد كافة الجلسات الأخيرة.", + noProviderSessions: "لم يتم العثور على جلسات{{provider}}.", + }, + row: { + importing: "جارٍ الاستيراد...", + }, + }, + workspace: { + route: { + loading: "جارٍ تحميل مساحة العمل", + connecting: "الاتصال", + hostOffline: "{{hostName}}غير متواجد حالياً", + cannotReachHost: "لا يمكن الوصول إلى{{hostName}}", + hostStatus: "حالة Host:{{status}}", + missing: "لم يتم العثور على Workspace", + manageHost: "إدارة المضيف", + }, + hoverCard: { + scriptsAccessibility: "البرامج النصية Workspace", + }, + fileExplorer: { + sort: { + name: "اسم", + modified: "معدل", + size: "مقاس", + }, + context: { + size: "مقاس", + modified: "معدل", + copyPath: "نسخ المسار", + download: "تحميل", + }, + actions: { + back: "خلف", + retry: "أعد المحاولة", + refresh: "تحديث الملفات", + refreshing: "تحديث الملفات", + }, + empty: { + noFiles: "لا توجد ملفات", + }, + states: { + unavailable: "Workspace غير متوفر", + loading: "جارٍ تحميل الملفات...", + }, + errors: { + failedToListDirectory: "فشل في سرد ​​الدليل", + }, + }, + setup: { + descriptor: { + label: "يثبت", + completed: "اكتمل الإعداد", + failed: "فشل الإعداد", + workspace: "إعداد Workspace", + }, + status: { + running: "جري", + completed: "مكتمل", + failed: "فشل", + waiting: "في انتظار إخراج الإعداد", + }, + waiting: "جارٍ إعداد مساحة العمل...", + empty: { + noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.", + }, + accessibility: { + noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه", + log: "سجل إعداد Workspace", + }, + log: { + noOutput: "لا يوجد إخراج", + }, + }, + browser: { + unavailable: { + title: "المتصفح مخصص لسطح المكتب فقط", + subtitle: "افتح مساحة العمل هذه في Electron لاستخدام المتصفح المدمج.", + }, + session: "جلسة المتصفح{{browserId}}", + controls: { + back: "خلف", + forward: "إلى الأمام", + stopLoading: "توقف عن التحميل", + refresh: "ينعش", + browserUrl: "متصفح URL", + enterUrl: "أدخل URL", + openDevTools: "افتح أدوات تطوير المتصفح", + cancelSelector: "إلغاء محدد العنصر", + selectElement: "حدد العنصر", + }, + errors: { + failedToLoad: "فشل تحميل الصفحة", + invalidUrl: "متصفح غير صالح URL", + unsupportedProtocol: "متصفح محظور غير مدعوم URL:{{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Host غير متصل", + unableToSubscribe: "غير قادر على الاشتراك في المحطة", + }, + tabs: { + loading: "تحميل...", + loadingAgentTitle: "جارٍ تحميل عنوان الوكيل", + emptyPane: "لا توجد علامات تبويب في هذا الجزء.", + fallback: { + newAgent: "جديد Agent", + setup: "يثبت", + workspaceSetup: "إعداد Workspace", + terminal: "Terminal", + browser: "المتصفح", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "تبديل علامات التبويب (فتح{{count}})", + title: "علامة التبويب التبديل", + searchPlaceholder: "علامات تبويب البحث", + }, + menu: { + openFor: "فتح القائمة لـ{{label}}", + copyResumeCommand: "نسخ أمر السيرة الذاتية", + copyAgentId: "نسخ معرف الوكيل", + rename: "إعادة تسمية", + closeAbove: "إغلاق علامات التبويب أعلاه", + closeBelow: "إغلاق علامات التبويب أدناه", + closeLeft: "بالقرب من اليسار", + closeRight: "قريب من اليمين", + closeOthers: "أغلق علامات التبويب الأخرى", + reloadAgent: "إعادة تحميل الوكيل", + reloadAgentTooltip: "قم بإعادة تحميل الوكيل لتحديث المهارات أو MCPs أو حالة تسجيل الدخول.", + close: "يغلق", + renameTerminal: "إعادة تسمية المحطة", + renameAgent: "إعادة تسمية الوكيل", + }, + actions: { + newAgent: "علامة تبويب الوكيل الجديد", + newTerminal: "علامة تبويب طرفية جديدة", + preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", + preparingTerminalTooltip: "جارٍ تحضير المحطة...", + newBrowser: "علامة تبويب متصفح جديدة", + splitRight: "تقسيم الجزء الأيمن", + splitDown: "تقسيم الجزء لأسفل", + }, + explorer: { + open: "افتح المستكشف", + close: "إغلاق المستكشف", + toggle: "تبديل المستكشف", + changes: "التغييرات", + files: "ملفات", + }, + toasts: { + copyFailed: "فشل النسخ", + agentIdCopiedLabel: "AgentID", + resumeCommandCopiedLabel: "أمر الاستئناف", + resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة", + resumeCommandUnavailable: "أمر الاستئناف غير متوفر", + reloadingAgent: "وكيل إعادة التحميل...", + reloadedAgent: "وكيل إعادة تحميل", + failedToReloadAgent: "فشل في إعادة تحميل الوكيل", + }, + confirmations: { + close: "يغلق", + cancel: "يلغي", + archive: "أرشيف", + closeTerminalTitle: "إغلاق المحطة؟", + closeTerminalMessage: "سيتم إيقاف أي عملية جارية في هذه المحطة على الفور.", + archiveRunningAgentTitle: "وكيل تشغيل الأرشيف؟", + archiveRunningAgentMessage: + "هذا الوكيل لا يزال قيد التشغيل. ستؤدي أرشفته إلى إيقاف الوكيل وإغلاق علامة التبويب.", + closeTabsLeftTitle: "هل تريد إغلاق علامات التبويب على اليسار؟", + closeTabsRightTitle: "هل تريد إغلاق علامات التبويب على اليمين؟", + closeOtherTabsTitle: "هل تريد إغلاق علامات التبويب الأخرى؟", + bulk: { + all: "سيؤدي هذا إلى أرشفة وكيل (وكلاء){{agents}}، وإغلاق محطة (محطات){{terminals}}، وإغلاق علامة (علامات) تبويب{{tabs}}. سيتم إيقاف أي عملية جارية في محطة مغلقة على الفور.", + agentsAndTerminals: + "سيؤدي هذا إلى أرشفة وكيل (وكلاء){{agents}}وإغلاق محطة (محطات){{terminals}}. سيتم إيقاف أي عملية جارية في محطة مغلقة على الفور.", + terminalsAndTabs: + "سيؤدي هذا إلى إغلاق محطة (محطات){{terminals}}وإغلاق علامة تبويب (علامات تبويب){{tabs}}. سيتم إيقاف أي عملية جارية في محطة مغلقة على الفور.", + agentsAndTabs: + "سيؤدي هذا إلى أرشفة وكيل (وكلاء){{agents}}وإغلاق علامة (علامات) تبويب{{tabs}}.", + terminals: + "سيؤدي هذا إلى إغلاق محطة (محطات){{terminals}}. سيتم إيقاف أي عملية جارية في محطة مغلقة على الفور.", + tabs: "سيؤدي هذا إلى إغلاق علامة التبويب (علامات التبويب){{tabs}}.", + agents: "سيؤدي هذا إلى أرشفة وكيل (وكلاء){{agents}}.", + }, + }, + }, + header: { + actions: { + workspaceActions: "إجراءات Workspace", + newAgent: "وكيل جديد", + newTerminal: "محطة جديدة", + newBrowser: "علامة تبويب متصفح جديدة", + importSession: "جلسة الاستيراد", + copyPath: "نسخ مسار مساحة العمل", + copyBranchName: "انسخ اسم الفرع", + showSetup: "إظهار الإعداد", + }, + toasts: { + workspacePathUnavailable: "مسار Workspace غير متاح بعد", + branchNameUnavailable: "اسم الفرع غير متوفر", + terminalQueued: "تحضير مساحة العمل، وفتح الوحدة الطرفية عندما تكون جاهزة...", + workspacePathCopiedLabel: "مسار Workspace", + branchNameCopiedLabel: "اسم الفرع", + }, + }, + scripts: { + title: "البرامج النصية", + actions: { + run: "يجري", + view: "منظر", + }, + accessibility: { + trigger: "البرامج النصية Workspace", + openAt: "افتح{{scriptName}}في{{label}}", + viewTerminal: "عرض محطة{{scriptName}}", + runScript: "قم بتشغيل البرنامج النصي{{scriptName}}", + script: "البرنامج النصي{{scriptName}}", + }, + states: { + exitCode: "الخروج من{{code}}", + startFailed: "فشل بدء تشغيل{{scriptName}}", + }, + }, + git: { + actions: { + moreOptions: "المزيد من الخيارات", + moreActions: "المزيد من الإجراءات", + commit: { + label: "يقترف", + pending: "ارتكاب...", + success: "ملتزم", + }, + pull: { + label: "يحذب", + pending: "سحب...", + success: "انسحبت", + }, + push: { + label: "يدفع", + pending: "دفع...", + success: "دفعت", + }, + pullAndPush: { + label: "سحب ودفع", + pending: "سحب و دفع...", + success: "سحبت ودفعت", + }, + viewPr: "عرض PR", + createPr: { + label: "إنشاء PR", + pending: "إنشاء PR...", + success: "تم إنشاء PR", + }, + mergeBranch: { + label: "دمج محليا", + pending: "جار الدمج...", + success: "تم الدمج", + }, + mergeFromBase: { + label: "التحديث من{{baseRef}}", + pending: "جارٍ التحديث...", + success: "تم التحديث", + }, + archive: { + label: "أرشفة شجرة العمل", + pending: "أرشفة...", + success: "مؤرشف", + }, + mergePr: { + squash: "الاسكواش والاندماج", + merge: "إنشاء التزام الدمج", + rebase: "إعادة الأساس والدمج", + pending: "دمج PR...", + success: "تم دمج PR", + }, + autoMerge: { + enableSquash: "تمكين الدمج التلقائي مع الاسكواش", + enableMerge: "تمكين الدمج التلقائي مع التزام الدمج", + enableRebase: "تمكين الدمج التلقائي مع rebase", + enabled: "تم تمكين الدمج التلقائي", + enabling: "تمكين الدمج التلقائي...", + disabling: "تعطيل الدمج التلقائي...", + disabled: "تم تعطيل الدمج التلقائي", + }, + unavailable: { + viewPrNoGithub: "عرض PR غير متاح الآن لأن GitHub غير متصل", + pullNoRemote: "السحب غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد", + pullDirty: "السحب غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً", + pullUpToDate: "السحب غير متاح لأن هذا الفرع محدث بالفعل", + pushNoRemote: "الدفع غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد", + pushBehind: "الدفع غير متاح حتى الآن نظرًا لوجود تغييرات أحدث يجب إدخالها أولاً", + pushNothing: "خدمة الدفع غير متاحة لأنه لا يوجد شيء جديد لإرساله", + pullAndPushNoRemote: + "السحب والدفع غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد", + pullAndPushDirty: + "لا يتوفر السحب والدفع أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً", + pullAndPushInSync: "السحب والدفع غير متاح لأن هذا الفرع متزامن بالفعل", + createPrNoGithub: "إنشاء PR غير متاح حاليًا لأن GitHub غير متصل", + createPrNoCommits: "إنشاء PR غير متاح لأن هذا الفرع ليس لديه أي التزامات جديدة حتى الآن", + mergeNoBase: "الدمج غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي", + mergeDirty: + "الدمج غير متاح عندما تكون لديك تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً", + mergeNothing: "الدمج غير متاح لأن هذا الفرع لا يحتوي على أي شيء جديد لدمجه حتى الآن", + updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي", + updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً", + updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}", + archiveNotWorktree: + "الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo", + mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل", + mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن", + mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة", + mergePrMerged: "دمج PR غير متاح لأن طلب السحب مدمج بالفعل", + mergePrClosed: "دمج PR غير متاح لأن طلب السحب مغلق", + mergePrConflicts: "دمج PR غير متاح لأن طلب السحب به تعارضات", + mergePrQueue: "دمج PR غير متاح هنا لأن هذا المستودع يستخدم قائمة انتظار دمج", + mergePrNotReady: "دمج PR غير متاح حتى يبلغ GitHub أن طلب السحب جاهز للدمج", + autoMergeCannotDisable: "تم تمكين الدمج التلقائي، ولكن لا يمكن لهذا الحساب تعطيله", + }, + toasts: { + failedCommit: "فشل في الالتزام", + failedPull: "فشل في السحب", + failedPush: "فشل في الدفع", + failedPullAndPush: "فشل في السحب والدفع", + failedCreatePr: "فشل في إنشاء PR", + failedMergePr: "فشل دمج PR", + failedEnableAutoMerge: "فشل في تمكين الدمج التلقائي", + failedDisableAutoMerge: "فشل في تعطيل الدمج التلقائي", + baseRefUnavailable: "المرجع الأساسي غير متاح", + failedMerge: "فشل الدمج", + failedMergeFromBase: "فشل الدمج من القاعدة", + worktreePathUnavailable: "مسار شجرة العمل غير متوفر", + failedArchive: "فشل في أرشفة شجرة العمل", + }, + archiveWarning: { + title: 'الأرشيف "{{worktreeName}}"؟', + confirm: "أرشيف", + cancel: "يلغي", + uncommittedChanges: "تغييرات غير ملتزم بها", + uncommittedChangesWithDiff: "التغييرات غير الملتزم بها ({{diffStat}})", + addedLine: "تمت إضافة خط{{count}}", + addedLines: "تمت إضافة خطوط{{count}}", + deletedLine: "تم حذف الخط{{count}}", + deletedLines: "الخطوط المحذوفة{{count}}", + unpushedCommit: "التزام{{count}}غير المدفوعة", + unpushedCommits: "التزامات{{count}}غير المدفوعة", + }, + }, + diff: { + binaryFile: "ملف ثنائي", + tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", + unified: "الفرق الموحدة", + split: "فرق جنبًا إلى جنب", + hideWhitespace: "إخفاء المسافة البيضاء", + scrollLongLines: "قم بتمرير الخطوط الطويلة", + wrapLongLines: "لف الخطوط الطويلة", + collapseAll: "طي كافة الملفات", + expandAll: "قم بتوسيع كافة الملفات", + refreshing: "منعش", + refresh: "ينعش", + refreshState: "تحديث بوابة وحالة GitHub", + failedRefresh: "فشل تحديث حالة git.", + emptyHiddenWhitespace: "لا توجد تغييرات مرئية بعد إخفاء المسافة البيضاء", + emptyUncommitted: "لا توجد تغييرات غير ملتزم بها", + emptyAgainstBase: "لا توجد تغييرات مقابل{{baseRef}}", + checkingRepository: "فحص المستودع...", + notRepository: "ليس مستودع جيت", + diffMode: "وضع الفرق", + uncommitted: "غير ملتزم", + committed: "ملتزم", + branchUnknown: "مجهول", + base: "قاعدة", + newFile: "جديد", + deletedFile: "تم الحذف", + }, + openInEditor: { + open: "يفتح", + chooseEditor: "اختر المحرر", + openIn: "افتح مساحة العمل في{{target}}", + openFileIn: "Open {{fileName}} in {{target}}", + failedOpen: "فشل في فتح مساحة العمل", + }, + pr: { + sections: { + checks: "الشيكات", + reviews: "التعليقات", + }, + accessibility: { + pullRequest: "سحب الطلب #{{number}}", + }, + states: { + draft: "مسودة", + merged: "تم الدمج", + closed: "مغلق", + open: "يفتح", + }, + activity: { + commented: "علق", + approved: "موافقة", + requestedChanges: "التغييرات المطلوبة", + reviewed: "تمت المراجعة", + }, + time: { + justNow: "الآن", + }, + errors: { + statusLoadFailed: "غير قادر على تحميل حالة طلب السحب", + activityLoadFailed: "غير قادر على تحميل نشاط طلب السحب", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "لا مضيف", + switchTitle: "تبديل المضيف", + searchPlaceholder: "بحث عن المضيفين...", + }, + actions: { + addProject: "إضافة مشروع", + home: "بيت", + settings: "إعدادات", + closeSidebar: "إغلاق الشريط الجانبي", + }, + sections: { + sessions: "الجلسات", + }, + worktreeSetup: { + title: "إعداد البرامج النصية لشجرة العمل", + description: + "أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.", + openProjectSettings: "افتح إعدادات المشروع", + }, + project: { + actions: { + menu: "إجراءات المشروع", + openSettings: "افتح إعدادات المشروع", + openNewWindow: "Open in new window", + openNewWindowFailed: "Couldn't open a new window", + remove: "إزالة المشروع", + removing: "جارٍ الإزالة...", + }, + confirmations: { + removeTitle: "هل تريد إزالة المشروع؟", + removeMessage: + 'هل تريد إزالة "{{projectName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.', + removeConfirm: "يزيل", + cancel: "يلغي", + }, + toasts: { + hostDisconnected: "Host غير متصل", + removeFailed: "فشل في إزالة بعض مساحات العمل", + }, + empty: { + title: "لا توجد مشاريع حتى الآن", + description: "أضف مشروعًا للبدء", + }, + }, + workspace: { + status: { + scriptsAvailable: "البرامج النصية المتاحة", + creating: "جارٍ الإنشاء...", + }, + actions: { + menu: "إجراءات Workspace", + newWorkspace: "مساحة عمل جديدة", + createWorkspaceFor: "قم بإنشاء مساحة عمل جديدة لـ{{projectName}}", + copyPath: "نسخ المسار", + copyBranchName: "انسخ اسم الفرع", + rename: "إعادة تسمية مساحة العمل", + archive: "أرشيف", + archiveWorktree: "أرشفة شجرة العمل", + hideFromSidebar: "إخفاء من الشريط الجانبي", + archiving: "أرشفة...", + hiding: "إخفاء...", + }, + confirmations: { + hideTitle: "إخفاء مساحة العمل؟", + hideMessage: + 'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.', + hideConfirm: "يخفي", + cancel: "يلغي", + }, + rename: { + title: "إعادة تسمية مساحة العمل", + submit: "إعادة تسمية", + invalidBranchName: "اسم الفرع غير صالح", + }, + toasts: { + workspacePathUnavailable: "مسار Workspace غير متوفر", + pathCopied: "تم نسخ المسار", + branchNameCopied: "تم نسخ اسم الفرع", + hostDisconnected: "Host غير متصل", + hideFailed: "فشل في إخفاء مساحة العمل", + archiveFailed: "فشل في أرشفة شجرة العمل", + }, + }, + }, + newWorkspace: { + title: "مساحة عمل جديدة", + create: "يخلق", + errors: { + hostDisconnected: "Host غير متصل", + createWorktreeFailed: "فشل في إنشاء شجرة العمل", + composerStateRequired: "حالة الملحن مطلوبة", + selectModel: "اختر نموذجا", + }, + refPicker: { + startingRef: "بدء المرجع", + chooseStart: "اختر من أين تبدأ", + checkoutHint: "تحقق من PR#{{number}}؟", + checkoutPr: "تحقق من PR#{{number}}", + dismissCheckoutHint: "تجاهل تلميح الخروج PR#{{number}}", + intoBase: "إلى{{baseRef}}", + searching: "جارٍ البحث...", + noMatchingRefs: "لا توجد مراجع مطابقة.", + searchPlaceholder: "بحث الفروع والعلاقات العامة", + title: "ابدأ من", + }, + }, + desktop: { + quitting: { + title: "جارٍ إنهاء Paseo...", + detail: "إيقاف البرنامج الخفي المحلي.", + }, + daemon: { + title: "Daemon", + status: { + title: "حالة", + builtInOnly: "يتم عرض البرنامج الخفي لسطح المكتب المدمج فقط هنا", + running: "جري", + notRunning: "لا يعمل", + pid: "PID{{pid}}", + }, + management: { + title: "إدارة البرنامج الخفي المدمج", + hint: "اسمح لـ Paseo ببدء تشغيل البرنامج الخفي المدمج وإيقافه", + pauseTitle: "وقفة المدمج في البرنامج الخفي", + pauseMessage: + "سيؤدي هذا إلى إيقاف البرنامج الخفي المدمج على الفور. سيتم إيقاف تشغيل الوكلاء والمحطات الطرفية المتصلة بالبرنامج الخفي المدمج.", + pauseAndStop: "وقفة وتوقف", + registrationFailed: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + pausedStopFailed: + "تم إيقاف إدارة البرنامج الخفي المضمنة مؤقتًا، لكن لم يتمكن Paseo من إيقاف البرنامج الخفي.", + updateFailed: "غير قادر على تحديث إدارة البرنامج الخفي المضمنة.", + }, + keepRunning: { + title: "استمر في تشغيل البرنامج الخفي بعد الإقلاع عن التدخين", + hint: "يستمر تشغيل Daemon عند إنهاء Paseo", + }, + logs: { + title: "ملف السجل", + modalTitle: "سجلات Daemon", + unavailable: "مسار السجل غير متاح", + empty: "(ملف السجل فارغ)", + copied: "تم نسخ مسار السجل.", + copyFailed: "غير قادر على نسخ مسار السجل.", + open: "فتح السجلات", + copyPath: "نسخ المسار", + }, + fullStatus: { + title: "الوضع الكامل", + modalTitle: "حالة Daemon", + hint: "يقوم بتشغيل`paseo daemon status`ويظهر الإخراج", + view: "عرض الحالة", + copied: "تم نسخ الحالة إلى الحافظة.", + fetchFailed: "فشل جلب حالة البرنامج الخفي:{{message}}", + }, + advancedSettings: "الإعدادات المتقدمة", + openAdvancedSettings: "افتح إعدادات البرنامج الخفي المتقدمة", + versionMismatch: + "إصدارا التطبيق والبرنامج الخفي غير متطابقين. قم بتحديث كلاهما إلى نفس الإصدار للحصول على أفضل تجربة.", + loadFailed: "غير قادر على تحميل حالة البرنامج الخفي لسطح المكتب.", + }, + updates: { + status: { + checking: "جارٍ التحقق من وجود تحديثات للتطبيق...", + installing: "جارٍ تثبيت تحديث التطبيق...", + upToDate: "التطبيق محدث.", + upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", + pending: "سنخبرك عندما يصبح التحديث جاهزًا.", + availableWithVersion: "التحديث جاهز:{{version}}", + available: "تحديث التطبيق جاهز للتثبيت.", + installed: "تم تثبيت تحديث التطبيق. إعادة التشغيل مطلوبة.", + failed: "فشل في تحديث التطبيق.", + idle: "لم يتم التحقق من حالة التحديث بعد.", + }, + installError: "غير قادر على تثبيت تحديث تطبيق سطح المكتب.", + callout: { + installingTitle: "تثبيت التحديث", + failedTitle: "فشل التحديث", + availableTitle: "التحديث متاح", + genericError: "حدث خطأ ما.", + whatsNew: "ما هو الجديد", + installingAction: "جارٍ التثبيت...", + installAndRestart: "التثبيت وإعادة التشغيل", + installingDescription: "التثبيت وإعادة التشغيل...", + versionReady: "{{version}}جاهز للتثبيت.", + newVersionReady: "إصدار جديد جاهز للتثبيت.", + restartWarning: "ستؤدي ترقية التطبيق إلى إيقاف تشغيل الوكلاء وإغلاق الجلسات الطرفية.", + }, + }, + settings: { + loadFailed: "غير قادر على تحميل إعدادات سطح المكتب.", + saveFailed: "غير قادر على حفظ إعدادات سطح المكتب.", + }, + rosetta: { + title: "قم بتنزيل نسخة Apple Silicon", + runningIntel: "أنت تقوم بتشغيل إصدار Intel من Paseo ضمن Rosetta على Apple Silicon.", + highCpu: + "يؤدي هذا إلى ارتفاع استخدام وحدة المعالجة المركزية. قم بتنزيل إصدار Apple Silicon لإصلاحه.", + download: "تحميل", + }, + permissions: { + notifications: { + allowed: "يسمح نظام التشغيل بالإشعارات.", + denied: "تم رفض الإخطارات في إعدادات النظام.", + notGranted: "لم يتم منح الإخطارات بعد.", + webOnly: "حالة إشعار سطح المكتب متاحة فقط في وقت تشغيل الويب.", + supported: "يتم دعم إشعارات سطح المكتب.", + unsupported: "إشعارات سطح المكتب غير مدعومة على هذا النظام الأساسي.", + apiUnavailable: "واجهة برمجة تطبيقات إشعارات الويب غير متاحة في هذه البيئة.", + requestsWebOnly: "طلبات إعلام سطح المكتب متاحة فقط في وقت تشغيل الويب.", + requestUnavailable: "واجهة برمجة تطبيقات إشعارات الويب requestPermission() غير متاحة.", + requestFailed: "فشل طلب إذن الإعلام:{{message}}", + unexpectedState: "حالة إذن الإعلام غير المتوقعة:{{state}}", + }, + microphone: { + webOnly: "حالة ميكروفون سطح المكتب متاحة فقط في وقت تشغيل الويب.", + navigatorUnavailable: "Navigator غير متوفر في هذه البيئة.", + granted: "تم منح الوصول إلى الميكروفون.", + denied: "تم رفض الوصول إلى الميكروفون في إعدادات النظام.", + notGranted: "لم يتم منح إذن الميكروفون بعد.", + unexpectedState: "حالة إذن الميكروفون غير متوقعة:{{state}}", + statusApiUnavailable: + "واجهة برمجة التطبيقات لحالة الميكروفون غير متاحة في وقت التشغيل هذا. استخدم الطلب للتحقق من الوصول.", + queryFailed: "فشل الاستعلام عن حالة الميكروفون:{{message}}", + captureUnavailable: "التقاط الميكروفون غير متوفر في هذه البيئة.", + permissionApiUnavailable: + "واجهة برمجة تطبيقات حالة الإذن غير متاحة. استخدم الطلب للتحقق من الوصول.", + requestsWebOnly: "طلبات ميكروفون سطح المكتب متاحة فقط في وقت تشغيل الويب.", + captureApiUnavailable: "واجهة برمجة تطبيقات التقاط الميكروفون غير متاحة في هذه البيئة.", + requestDenied: "تم رفض إذن الميكروفون من قبل المستخدم أو النظام.", + noDevice: "لم يتم العثور على جهاز ميكروفون.", + requestFailed: "فشل طلب إذن الميكروفون:{{message}}", + }, + empty: { + notifications: "لم يتم التحقق من حالة الإخطار بعد.", + microphone: "لم يتم التحقق من حالة الميكروفون بعد.", + }, + testNotification: { + title: "اختبار الإخطار Paseo", + body: "إذا كان بإمكانك رؤية ذلك، فهذا يعني أن إشعارات سطح المكتب تعمل.", + notDelivered: "لم يتم تسليم الإخطار. تحقق من إعدادات النظام > الإشعارات.", + failed: "فشل في إرسال الإخطار.", + }, + }, + integrations: { + cli: { + statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.", + installFailed: "غير قادر على تثبيت PaseoCLI.", + }, + skills: { + statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.", + installFailed: "غير قادر على تثبيت مهارات التنسيق.", + updateFailed: "غير قادر على تحديث مهارات التنسيق.", + uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.", + }, + }, + }, + startup: { + errorTitle: "حدث خطأ ما", + errorDescription: + "فشل الخادم المحلي في البدء. إذا استمر حدوث ذلك، فيرجى الإبلاغ عن المشكلة على GitHub وتضمين السجلات أدناه.", + logs: { + loading: "جارٍ تحميل سجلات البرنامج الخفي...", + unavailable: "لا توجد سجلات خفية متاحة.", + loadFailed: "غير قادر على تحميل سجلات البرنامج الخفي:{{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "أضف مشروعًا", + description: "افتح مجلدًا على جهازك", + }, + importSession: { + title: "جلسة الاستيراد", + description: "أحضر جلسات CLI الخارجية الأخيرة", + }, + setupProviders: { + title: "موفري الإعداد", + description: "قم بتكوين Claude Code و Codex والمزيد", + }, + pairDevice: { + title: "إقران الجهاز", + description: "قم بتوصيل هاتفك بهذا البرنامج الخفي", + }, + }, + }, + projectPicker: { + placeholder: "اكتب مسار الدليل...", + opening: "افتتاح المشروع...", + empty: "ابدأ بكتابة المسار", + }, + branchSwitcher: { + currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", + placeholder: "تبديل الفرع...", + searchPlaceholder: "تصفية الفروع...", + empty: "لم يتم العثور على فروع.", + title: "فرع التبديل", + uncommittedTitle: "تغييرات غير ملتزم بها", + uncommittedMessage: "لديك تغييرات غير ملتزم بها. خبأهم قبل تبديل الفروع؟", + stashAndSwitch: "خبأ والتبديل", + failedToStash: "فشل في تخزين التغييرات", + failedToSwitch: "فشل في تبديل الفرع", + restoreStashTitle: "هل تريد استعادة التغييرات المخبأة؟", + restoreStashMessage: "قام هذا الفرع بتخزين التغييرات من جلسة سابقة. هل ترغب في استعادتها؟", + restore: "يعيد", + later: "لاحقاً", + stashRestored: "تمت استعادة التغييرات المخفية", + }, + agentAutocomplete: { + searchingWorkspace: "جارٍ البحث في مساحة العمل...", + loadingCommands: "جارٍ تحميل الأوامر...", + noFiles: "لم يتم العثور على ملفات أو أدلة", + noCommands: "لم يتم العثور على أي أوامر", + failedToLoad: "فشل التحميل", + }, + loadOlderHistory: { + failed: "تعذر تحميل السجل الأقدم", + }, + imageAttachmentPicker: { + permissionTitle: "الإذن مطلوب", + permissionMessage: "يرجى السماح بالوصول إلى مكتبة الصور الخاصة بك لإرفاق الصور.", + errorTitle: "خطأ", + failedToSelect: "فشل في تحديد الصورة", + dialogTitle: "إرفاق الصور", + dialogFilterName: "الصور", + }, + workspaceSetup: { + title: "إنشاء مساحة عمل", + errors: { + failedCreateWorktree: "فشل في إنشاء شجرة العمل", + failedOpenProject: "فشل في فتح المشروع", + selectModel: "اختر نموذجا", + hostDisconnected: "Host غير متصل", + pendingRequired: "لا يوجد إعداد معلق لمساحة العمل", + composerStateRequired: "مطلوب حالة مؤلف إعداد Workspace", + }, + }, + onboarding: { + title: "مرحبا بكم في Paseo", + subtitle: "قم بتوصيل جهاز الكمبيوتر الخاص بك للبدء", + actions: { + settings: "إعدادات", + }, + }, + modelSelector: { + title: "حدد المزود", + selectModel: "حدد النموذج", + selectedModel: "اختر الموديل ({{model}})", + loading: "تحميل...", + loadingShort: "تحميل", + loadingSelector: "جارٍ تحميل محدد النموذج...", + error: "خطأ", + defaultModel: "تقصير", + favorites: "المفضلة", + favoriteModel: "النموذج المفضل", + unfavoriteModel: "نموذج غير مفضل", + modelCount: "موديل{{count}}", + modelCountPlural: "نماذج{{count}}", + retry: "أعد المحاولة", + retrying: "جارٍ إعادة المحاولة...", + noMatches: "لا توجد نماذج تطابق بحثك", + searchPlaceholder: "نماذج البحث...", + openProviderSettings: "افتح إعدادات{{provider}}", + }, + providerCatalog: { + title: "إضافة مزود", + search: "مقدمي البحث", + noProviders: "لم يتم العثور على مقدمي الخدمات", + actions: { + add: "يضيف", + adding: "إضافة", + installed: "تم التثبيت", + cancel: "يلغي", + installInstructions: "تعليمات التثبيت", + installInstructionsFor: "تعليمات تثبيت{{provider}}", + }, + errors: { + unableToInstall: "غير قادر على تثبيت الموفر", + }, + }, + providerSelection: { + defaultModel: "تقصير", + selectModel: "حدد النموذج", + loading: "تحميل...", + error: "خطأ", + unavailable: "غير متاح", + unknownError: "خطأ غير معروف", + readiness: { + initialPromptRequired: "مطلوب موجه الأولي", + noProviders: "لا يوجد موفري خدمة متاحين على المضيف المحدد", + modelDefaultsLoading: "لا يزال يتم تحميل الإعدادات الافتراضية للنموذج", + noModelAvailable: "لا يوجد نموذج متاح للموفر المحدد", + workspaceDirectoryNotFound: "لم يتم العثور على دليل Workspace", + hostDisconnected: "Host غير متصل", + }, + }, + pairing: { + connectionMethods: { + title: "إضافة اتصال", + direct: { + title: "اتصال مباشر", + description: "الشبكة المحلية أو VPN.", + }, + scanQr: { + title: "مسح رمز QR", + description: "اتصال التتابع المشفر.", + }, + pasteLink: { + title: "الصق رابط الاقتران", + description: "اتصال التتابع المشفر.", + }, + }, + direct: { + title: "اتصال مباشر", + helper: "أدخل عنوان خادم Paseo.", + fields: { + host: "Host", + port: "ميناء", + password: "كلمة المرور", + optional: "خياري", + useSsl: "استخدم طبقة المقابس الآمنة", + connectionUri: "معرف URI للاتصال", + }, + advanced: { + label: "متقدم", + show: "عرض متقدم", + hide: "إخفاء المتقدمة", + }, + passwordVisibility: { + show: "إظهار كلمة المرور", + hide: "إخفاء كلمة المرور", + }, + actions: { + cancel: "يلغي", + connect: "يتصل", + connecting: "جارٍ الاتصال...", + }, + errors: { + hostRequired: "مطلوب Host", + invalidPort: "يجب أن يكون المنفذ بين 1 و65535", + invalidConnection: "اتصال غير صالح", + failedTitle: "فشل الاتصال", + failedToConnect: "فشلنا في الاتصال بـ{{endpoint}}.", + noAdditionalDetails: "{{detail}}(لم يتم تقديم تفاصيل إضافية)", + timedOut: "انتهت مهلة الاتصال. تحقق من المضيف /port وشبكتك.", + refused: "رفض اتصال. هل الخادم يعمل على هذا العنوان؟", + hostNotFound: "لم يتم العثور على Host. تحقق من اسم المضيف وحاول مرة أخرى.", + hostUnreachable: "Host غير قابل للوصول. تحقق من شبكتك وجدار الحماية.", + tlsError: + "خطأ TLS. تستخدم الاتصالات المباشرة SSL فقط عندما يكون فاصل TLS أمام البرنامج الخفي.", + unableToConnect: + "غير قادر على الاتصال. تحقق من المضيف /port ومن إمكانية الوصول إلى البرنامج الخفي.", + details: "التفاصيل:{{detail}}", + }, + }, + link: { + title: "الصق رابط الاقتران", + helper: "الصق رابط الاقتران من الخادم الخاص بك.", + label: "رابط الاقتران", + errors: { + required: "الصق رابط الاقتران (.../#offer=...)", + missingOffer: "يجب أن يتضمن الرابط #offer=...", + emptyOffer: "حمولة العرض فارغة", + invalid: "رابط الاقتران غير صالح", + unableToPair: "غير قادر على إقران المضيف", + }, + alert: { + failedTitle: "فشل الاقتران", + }, + actions: { + cancel: "يلغي", + pair: "زوج", + pairing: "الاقتران...", + }, + }, + scan: { + title: "مسح QR", + webUnavailableTitle: "غير متوفر على شبكة الإنترنت", + webUnavailableBody: 'فحص QR غير مدعوم في بناء الويب. استخدم "لصق الرابط" بدلاً من ذلك.', + backToSettings: "العودة إلى الإعدادات", + cameraPermissionTitle: "إذن الكاميرا", + cameraPermissionBody: "اسمح بالوصول إلى الكاميرا لمسح رمز الاقتران QR من البرنامج الخفي لديك.", + grantPermission: "منح الإذن", + pairing: "الاقتران...", + unableToPair: "غير قادر على إقران المضيف", + errorTitle: "خطأ", + }, + device: { + loadingOffer: "جارٍ تحميل عرض الإقران...", + failedToLoadOffer: "فشل تحميل عرض الاقتران.", + relayDisabled: "لم يتم تمكين التتابع. تمكين التتابع لإقران جهاز.", + unavailable: "عرض الاقتران غير متاح.", + hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.", + qrUnavailable: "رمز QR غير متاح.", + retry: "أعد المحاولة", + copy: "ينسخ", + copied: "منقول", + }, + }, + realtimeVoice: { + actions: { + mute: "كتم صوت الوقت الحقيقي", + unmute: "إلغاء كتم صوت الوقت الحقيقي", + stop: "إيقاف الصوت في الوقت الحقيقي ومقاطعة الدوران", + }, + }, + rewind: { + tooltip: "الترجيع إلى هذه الرسالة", + warning: "لا يمكن التراجع عن هذا الإجراء", + actions: { + conversation: "ترجيع المحادثة", + files: "ترجيع الملفات", + both: "ترجيع المحادثة والملفات", + }, + errors: { + failed: "فشل في إرجاع الوكيل", + }, + }, + diffViewer: { + empty: "لا توجد تغييرات للعرض", + }, + serviceUrl: { + title: "افتح الخدمة URL", + message: "افتح{{url}}؟", + inPaseo: "في Paseo", + externalBrowser: "متصفح خارجي", + dontAskAgain: "لا تسأل مرة أخرى", + }, + downloads: { + requestTokenFailed: "فشل طلب رمز التنزيل.", + hostUnavailable: "مضيف التنزيل غير متاح.", + cancelled: "تم إلغاء التنزيل.", + failed: "فشل تنزيل الملف.", + shareFile: "مشاركة الملف", + shareFileNamed: "مشاركة{{fileName}}", + }, + menu: { + backdrop: "خلفية القائمة", + }, + subagents: { + archiveAction: "أرشيف{{label}}", + archiveTooltip: "أرشفة الوكيل الفرعي", + }, + panels: { + draft: { + newAgent: "جديد Agent", + creatingAgent: "وكيل الخلق", + }, + file: { + executionDirectoryMissing: "لم يتم العثور على دليل تنفيذ Workspace.", + loading: "جارٍ تحميل الملف...", + noPreview: "لا تتوفر معاينة", + binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", + failedToLoad: "فشل تحميل الملف", + failedToLoadPreview: "فشل تحميل معاينة الملف", + }, + }, + toolCallDetails: { + error: "خطأ", + empty: "لا توجد تفاصيل إضافية متاحة", + subAgentActivity: "نشاط الوكيل الفرعي", + input: "مدخل", + output: "الإخراج", + }, + renameModal: { + rename: "إعادة تسمية", + saving: "توفير...", + }, + sidebarCallout: { + dismiss: "رفض", + }, + contextWindow: { + title: "نافذة السياق", + used: "تم استخدام{{percentage}}%", + tokens: "رموز{{used}}/{{max}}", + sessionCost: "تكلفة الجلسة{{cost}}", + accessibility: "تم استخدام نافذة السياق{{percentage}}%", + }, + review: { + comment: { + add: "إضافة تعليق المراجعة", + edit: "تحرير تعليق المراجعة", + delete: "حذف تعليق المراجعة", + label: "مراجعة التعليق", + placeholder: "اترك تعليقا", + cancel: "يلغي", + cancelAccessibility: "إلغاء تعليق المراجعة", + save: "تعليق", + saveAccessibility: "حفظ تعليق المراجعة", + }, + }, + settings: { + title: "إعدادات", + loading: "جارٍ تحميل الإعدادات...", + groups: { + app: "برنامج", + host: "Host", + }, + hostPicker: { + switchHost: "تبديل المضيف", + local: "محلي", + }, + backToWorkspace: "خلف", + addHost: "أضف مضيفًا", + projects: "المشاريع", + projectList: { + hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}", + editProject: "تحرير{{projectName}}", + }, + groupInfo: "حول{{title}}", + sections: { + general: "عام", + daemon: "Daemon", + appearance: "مظهر", + shortcuts: "الاختصارات", + integrations: "التكامل", + permissions: "الأذونات", + diagnostics: "التشخيص", + about: "عن", + }, + hostSections: { + connections: "اتصالات", + agents: "Agents", + workspaces: "Workspaces", + providers: "مقدمي الخدمات", + host: "Host", + }, + general: { + title: "عام", + defaultSend: { + label: "إرسال افتراضي", + description: "ماذا يحدث عند الضغط على Enter أثناء تشغيل الوكيل", + options: { + interrupt: "مقاطعة", + queue: "طابور", + }, + }, + serviceUrls: { + label: "عناوين URL للخدمة", + description: "مكان فتح عناوين URL من تشغيل البرامج النصية", + options: { + ask: "بسأل", + inApp: "في Paseo", + external: "متصفح خارجي", + }, + }, + terminalScrollback: { + label: "التمرير Terminal", + description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج", + accessibilityLabel: "خطوط التمرير Terminal", + }, + language: { + label: "لغة", + description: "لغة التطبيق", + options: { + system: "نظام", + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + zhCN: "中文", + }, + }, + }, + diagnostics: { + title: "التشخيص", + testAudio: "اختبار الصوت", + playTest: "لعب الاختبار", + playing: "جارٍ اللعب...", + playbackFailed: "فشل التشغيل:{{message}}", + }, + about: { + title: "عن", + appVersion: "نسخة التطبيق", + thisDevice: "هذا الجهاز", + connectedHosts: "المضيفين المتصلين", + offline: "غير متصل", + versionDiffers: "الإصدار يختلف عن هذا الجهاز", + releaseChannel: { + label: "الافراج عن القناة", + description: "قم بالتبديل إلى Beta للحصول على التحديثات عاجلاً والمساعدة في تشكيلها", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "تحديثات التطبيق", + readyToInstall: "جاهز للتثبيت:{{version}}", + installTitle: "تثبيت تحديث سطح المكتب", + installMessage: "يؤدي هذا إلى تحديث Paseo على هذا الكمبيوتر", + installConfirm: "تثبيت التحديث", + update: "تحديث", + updateTo: "التحديث إلى{{version}}", + installing: "جارٍ التثبيت...", + check: "يفحص", + checking: "جارٍ التحقق...", + alertTitle: "خطأ", + alertMessage: "غير قادر على فتح مربع حوار تأكيد التحديث.", + }, + }, + appearance: { + theme: { + title: "سمة", + accessibilityLabel: "الموضوع:{{value}}", + options: { + light: "ضوء", + dark: "مظلم", + zinc: "الزنك", + midnight: "منتصف الليل", + claude: "كلود", + ghostty: "شبحي", + auto: "نظام", + }, + }, + fonts: { + title: "الخطوط", + systemDefault: "الافتراضي للنظام", + interfaceFont: "خط الواجهة", + interfaceFontHint: "تستخدم عبر التطبيق. اتركه فارغًا للإعداد الافتراضي للنظام", + interfaceFontAccessibility: "عائلة خطوط الواجهة", + interfaceSize: "حجم الواجهة", + interfaceSizeAccessibility: "حجم الخط في الواجهة", + codeFont: "خط الكود", + codeFontHint: + "تستخدم في الكود والاختلافات والمخرجات الطرفية. اتركه فارغًا للإعداد الافتراضي للنظام", + codeFontAccessibility: "عائلة خطوط الكود", + codeSize: "حجم الكود", + codeSizeAccessibility: "حجم خط الكود", + }, + syntax: { + title: "بناء الجملة", + highlightTheme: "تسليط الضوء على الموضوع", + highlightThemeHint: "ألوان التعليمات البرمجية، مستقلة عن سمة التطبيق", + highlightThemeAccessibility: "تسليط الضوء على الموضوع:{{value}}", + previewAccessibility: "معاينة مباشرة لموضوع بناء الجملة وخط التعليمات البرمجية", + }, + }, + shortcuts: { + dialogTitle: "الاختصارات", + unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب", + capturePrompt: "اضغط على الاختصار...", + actions: { + done: "منتهي", + cancel: "يلغي", + rebind: "إعادة ربط", + reset: "إعادة ضبط", + resetAll: "إعادة ضبط الكل", + }, + sections: { + navigation: "ملاحة", + tabsPanes: "علامات التبويب والأجزاء", + projects: "المشاريع", + panels: "لوحات", + agentInput: "إدخال Agent", + }, + help: { + openProject: "مشروع مفتوح", + newWorktree: "شجرة عمل جديدة", + archiveWorktree: "أرشفة شجرة العمل", + newTab: "علامة تبويب جديدة", + closeCurrentTab: "إغلاق علامة التبويب الحالية", + jumpToWorkspace: "انتقل إلى مساحة العمل", + jumpToTab: "انتقل إلى علامة التبويب", + previousWorkspace: "مساحة العمل السابقة", + nextWorkspace: "مساحة العمل التالية", + previousTab: "علامة التبويب السابقة", + nextTab: "علامة التبويب التالية", + splitPaneRight: "تقسيم الجزء الأيمن", + splitPaneDown: "تقسيم الجزء لأسفل", + focusPaneLeft: "جزء التركيز على اليسار", + focusPaneRight: "جزء التركيز إلى اليمين", + focusPaneUp: "جزء التركيز لأعلى", + focusPaneDown: "جزء التركيز لأسفل", + moveTabLeft: "نقل علامة التبويب إلى اليسار", + moveTabRight: "نقل علامة التبويب إلى اليمين", + moveTabUp: "حرك علامة التبويب لأعلى", + moveTabDown: "حرك علامة التبويب لأسفل", + closePane: "إغلاق الجزء", + newTerminal: "محطة جديدة", + toggleCommandCenter: "تبديل مركز القيادة", + showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح", + toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر", + toggleRightSidebar: "تبديل الشريط الجانبي الأيمن", + toggleBothSidebars: "تبديل كلا الشريطين الجانبيين", + toggleSettings: "تبديل الإعدادات", + toggleFocusMode: "تبديل وضع التركيز", + cycleTheme: "موضوع الدورة", + focusMessageInput: "التركيز على إدخال الرسالة", + toggleVoiceMode: "تبديل الوضع الصوتي", + startStopDictation: "بدء إملاء /stop", + interruptAgent: "عامل المقاطعة", + sendMessage: "أرسل رسالة", + queueMessage: "رسالة قائمة الانتظار", + muteUnmuteVoiceMode: "كتم وضع الصوت /unmute", + }, + helpNotes: { + showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.", + }, + }, + integrations: { + title: "التكامل", + docs: { + cli: "مستندات CLI", + skills: "وثائق المهارات", + openCli: "افتح وثائق CLI", + openSkills: "فتح وثائق المهارات", + }, + commandLine: { + title: "سطر الأوامر", + description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك", + }, + skills: { + title: "مهارات التنسيق", + description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI", + updateAvailable: "التحديث متاح", + updateTitle: "تحديث مهارات Paseo ؟", + updateFallback: "مزامنة المهارات المجمعة لجهازك.", + uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟", + uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.", + }, + actions: { + install: "ثَبَّتَ", + installing: "جارٍ التثبيت...", + installed: "تم التثبيت", + update: "تحديث", + working: "عمل...", + uninstall: "إلغاء التثبيت", + }, + operations: { + add: "أضف مهارة", + update: "تحديث المهارة", + delete: "حذف المهارة", + }, + }, + permissions: { + title: "الأذونات", + notifications: "إشعارات", + microphone: "ميكروفون", + refresh: "ينعش", + refreshing: "منعش...", + refreshAccessibility: "تحديث أذونات سطح المكتب", + test: "امتحان", + actions: { + granted: "ممنوح", + request: "طلب", + requesting: "جارٍ الطلب...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "لم يتم العثور على Host", + badges: { + relay: "تتابع", + local: "محلي", + }, + connections: { + title: "اتصالات", + removeTitle: "إزالة الاتصال", + removeMessage: "إزالة{{name}}؟ لا يمكن التراجع عن هذا.", + removeAction: "يزيل", + removeErrorTitle: "خطأ", + removeErrorMessage: "غير قادر على إزالة الاتصال", + timeout: "نفذ الوقت", + }, + pairDevices: { + title: "إقران الأجهزة", + rowTitle: "إقران جهاز", + rowHint: "امسح رمز QR ضوئيًا أو انسخ رابطًا لتوصيل هاتفك بهذا المضيف", + }, + orchestration: { + title: "التنسيق", + unavailable: "اتصل بهذا المضيف لإدارة التنسيق", + enableTools: { + title: "تمكين أدوات Paseo", + hint: "سيتمكن الوكلاء من إدارة أشجار العمل والوكلاء والجداول الزمنية", + accessibilityLabel: "حقن أدوات Paseo", + }, + systemPrompt: { + title: "موجه النظام", + hint: "إضافة موجه النظام إلى كافة الوكلاء", + sheetTitle: "إلحاق موجه النظام", + accessibilityLabel: "إلحاق موجه النظام", + placeholder: "اجعل الردود موجزة دائمًا.", + }, + }, + agents: { + unavailable: "Connect to this host to manage agents", + }, + workspaces: { + unavailable: "Connect to this host to manage workspaces", + }, + daemon: { + rename: { + editLabel: "تحرير التسمية", + title: "إعادة تسمية المضيف", + placeholder: "بلدي Host", + }, + restart: { + title: "إعادة تشغيل البرنامج الخفي", + hint: "إعادة تشغيل عملية البرنامج الخفي. سيتم إعادة الاتصال بالتطبيق تلقائيًا", + confirmTitle: "أعد تشغيل{{name}}", + confirmMessage: + "سيؤدي هذا إلى إعادة تشغيل البرنامج الخفي. سيستمر العملاء الذين يعملون عليه؛ سيتم إعادة الاتصال بالتطبيق تلقائيًا.", + restarting: "جارٍ إعادة التشغيل...", + unableToReconnectTitle: "غير قادر على إعادة الاتصال", + unableToReconnectMessage: "لم يعد{{name}}متصلاً بالإنترنت. يرجى التحقق من إعادة تشغيله.", + unavailableTitle: "Host غير متوفر", + unavailableMessage: + "هذا المضيف غير متصل. انتظر حتى يصبح متصلاً بالإنترنت قبل إعادة التشغيل.", + offlineTitle: "Host غير متصل", + offlineMessage: + "هذا المضيف غير متصل. يقوم Paseo بإعادة الاتصال تلقائيًا - انتظر حتى يتم الاتصال بالإنترنت مرة أخرى قبل إعادة التشغيل.", + requestFailedTitle: "خطأ", + requestFailedMessage: + "فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.", + dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", + }, + dangerZone: "منطقة الخطر", + remove: { + title: "إزالة المضيف", + localTitle: "Remove localhost connection", + hint: "إزالة هذا المضيف واتصالاته المحفوظة من هذا الجهاز", + localHint: "Removes localhost from this device and stops the built-in daemon", + localConfirmTitle: "Remove localhost connection and stop daemon?", + confirmMessage: "إزالة{{name}}؟ سيؤدي هذا إلى حذف اتصالاته المحفوظة.", + localConfirmMessage: + "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + errorTitle: "خطأ", + errorMessage: "غير قادر على إزالة المضيف", + localErrorMessage: "Unable to remove localhost connection", + }, + }, + }, + providers: { + title: "مقدمي الخدمات", + addProvider: "إضافة مزود", + providerDetails: "تفاصيل مزود{{name}}", + enableProvider: "تمكين{{name}}", + unavailable: "اتصل بهذا المضيف لرؤية مقدمي الخدمة", + loading: "تحميل...", + addErrorTitle: "Unable to add provider", + updateErrorTitle: "غير قادر على تحديث الموفر", + statuses: { + disabled: "عاجز", + loading: "تحميل", + error: "خطأ", + available: "متاح", + notInstalled: "غير مثبت", + }, + models: { + one: "1 نموذج", + many: "نماذج{{count}}", + addModel: "إضافة نموذج", + addCustomTitle: "إضافة نموذج مخصص", + modelId: "الموديل ID", + modelIdPlaceholder: "على سبيل المثال أوبيناي /gpt-5", + add: "يضيف", + adding: "جارٍ الإضافة...", + failedToSave: "فشل حفظ النموذج", + removeModel: "إزالة{{id}}", + searchPlaceholder: "نماذج البحث", + loading: "جارٍ تحميل النماذج...", + retry: "أعد المحاولة", + retrying: "جارٍ إعادة المحاولة...", + noSearchMatches: "لا توجد نماذج تطابق بحثك", + noneDetected: "لم يتم اكتشاف أي نماذج", + discovered: "اكتشف", + custom: "نماذج مخصصة", + updated: "تم تحديث{{time}}", + }, + diagnostic: { + title: "التشخيص", + button: "التشخيص", + refresh: "ينعش", + refreshing: "منعش...", + refreshAccessibility: "تحديث التشخيص", + refreshingAccessibility: "تحديث التشخيص", + running: "تشغيل التشخيص...", + none: "لا يوجد تشخيص متاح", + failedToFetch: "فشل جلب التشخيص", + unknownError: "خطأ غير معروف", + }, + }, + project: { + noEditableTarget: "ليس لدينا نسخة قابلة للتحرير من هذا المشروع على أي مضيف متصل.", + backToProjects: "العودة إلى المشاريع", + switchHost: "تبديل المضيف", + rename: { + renamedToast: "تمت إعادة تسمية المشروع", + errorFallback: "تعذرت إعادة تسمية المشروع", + renameLabel: "إعادة تسمية المشروع", + resetLabel: "إعادة تعيين اسم المشروع إلى الافتراضي", + projectNameLabel: "اسم المشروع", + saveLabel: "احفظ اسم المشروع", + cancelLabel: "إلغاء إعادة التسمية", + reset: "إعادة ضبط", + }, + readFailures: { + invalidTitle: "تعذر تحليل paseo.json", + invalidDescription: "قم بإصلاح الملف على القرص، ثم أعد تحميله.", + missingTitle: "هذا المضيف ليس لديه هذا المشروع", + missingWithHosts: "قم بالتبديل إلى مضيف آخر أعلاه، أو أعد التحميل.", + missingSingleHost: "المضيف المحدد ليس لديه سجل لهذا المشروع.", + transportTitle: "تعذر تحميل paseo.json", + transportFallback: "المضيف لم يستجب.", + failedTitle: "تعذر تحميل paseo.json", + failedDescription: "أعد التحميل للمحاولة مرة أخرى.", + }, + worktree: { + title: "خطافات دورة حياة شجرة العمل", + info: "الأوامر التي يتم تشغيلها عند إنشاء شجرة عمل أو هدمها لهذا المشروع", + docs: "المستندات", + docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر", + setup: "يثبت", + setupAccessibility: "أوامر إعداد شجرة العمل", + teardown: "هدم", + teardownAccessibility: "أوامر هدم شجرة العمل", + }, + scripts: { + title: "البرامج النصية", + info: "خدمات طويلة الأمد وأوامر لمرة واحدة يمكنك إطلاقها من أي وكيل في هذا المشروع", + empty: "لا توجد نصوص حتى الآن.", + untitled: "نص بدون عنوان", + port: "منفذ{{port}}", + menuAccessibility: "فتح قائمة البرنامج النصي", + removeTitle: "هل تريد إزالة البرنامج النصي؟", + removeMessage: "إزالة{{name}}؟", + removeFallbackName: "هذا البرنامج النصي", + name: "اسم", + command: "يأمر", + nameAccessibility: "اسم البرنامج النصي", + commandAccessibility: "أمر البرنامج النصي", + nameRequired: "الاسم مطلوب", + commandRequired: "الأمر مطلوب", + newScript: "نص جديد", + editScript: "تحرير{{name}}", + runAsService: "تشغيل كخدمة", + serviceHint: "يشرف Paseo على العملية ويعين منفذًا عبر $PASEO_PORT", + actions: { + add: "إضافة البرنامج النصي", + edit: "يحرر", + remove: "يزيل", + }, + }, + metadata: { + title: "توليد البيانات الوصفية", + info: "تعليمات خاصة بالمشروع يتم إدخالها في الذكاء الاصطناعي الذي يستخدمه Paseo لإنشاء بيانات التعريف - استخدمها لفرض اصطلاحات فريقك مثل تسمية الفرع أو نمط الالتزام أو تنسيق PR", + agentTitle: "عناوين Agent", + agentTitlePlaceholder: "اجعل العناوين ضرورية وأقل من 40 حرفًا", + branchName: "اسماء الفروع", + branchNamePlaceholder: "بادئة الفروع بـ fet/ أو Fix/, mb/ للفروع الشخصية", + commitMessage: "ارتكاب الرسائل", + commitMessagePlaceholder: "استخدم الالتزامات التقليدية مع النطاق", + pullRequest: "سحب الطلبات", + pullRequestPlaceholder: "ابدأ بملخص من فقرة واحدة، مع تضمين قسم لخطة الاختبار", + }, + writeFailures: { + staleTitle: "تم تغيير التكوين على القرص", + staleDescription: "أعد التحميل لجلب أحدث ملف paseo.json قبل الحفظ.", + failedTitle: "تعذر حفظ paseo.json", + failedDescription: "حاول مرة أخرى، أو أعد تحميل الإصدار الأحدث من القرص.", + }, + actions: { + reload: "إعادة تحميل", + tryAgain: "حاول ثانية", + save: "يحفظ", + saved: "تم حفظ المشروع", + saving: "توفير...", + cancel: "يلغي", + }, + }, + }, +}; diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts new file mode 100644 index 000000000..bcbf724a0 --- /dev/null +++ b/packages/app/src/i18n/resources/en.ts @@ -0,0 +1,1816 @@ +export const en = { + common: { + back: "Back", + loading: "Loading...", + actions: { + back: "Back", + cancel: "Cancel", + close: "Close", + copy: "Copy", + dismiss: "Dismiss", + retry: "Retry", + search: "Search", + select: "Select", + }, + placeholders: { + search: "Search...", + }, + empty: { + noResults: "No results found", + noOptionsMatchSearch: "No options match your search.", + }, + states: { + loading: "Loading...", + starting: "Starting...", + copied: "Copied", + copiedLabel: "Copied {{label}}", + downloadComplete: "Download complete", + downloadFailed: "Download failed", + }, + errors: { + error: "Error", + unableToSave: "Unable to save", + nameRequired: "Name is required", + daemonUnavailable: "Daemon unavailable", + daemonClientUnavailable: "Daemon client unavailable", + daemonClientDisconnected: "Daemon client is disconnected", + noFileFound: "No file found for {{token}}", + unexpectedDictationError: "An unexpected error occurred while handling dictation.", + }, + connectionStatus: { + online: "Online", + connecting: "Connecting", + offline: "Offline", + error: "Error", + idle: "Idle", + }, + }, + shell: { + menu: { + toggleSidebar: "Toggle sidebar", + open: "Open menu", + close: "Close menu", + }, + commandCenter: { + placeholder: "Type a command or search agents...", + noMatches: "No matches", + actions: "Actions", + agents: "Agents", + newAgent: "New agent", + openProject: "Open project", + home: "Home", + }, + }, + composer: { + placeholders: { + desktop: "Message the agent, tag @files, or use /commands and /skills", + mobile: "Message, @files, /commands", + fallback: "Message...", + }, + input: { + accessibilityLabel: "Message agent...", + focusHint: "{{shortcut}} to focus", + addAttachment: "Add attachment", + interruptAgent: "Interrupt agent", + queueMessage: "Queue message", + sendAndInterrupt: "Send and interrupt", + sendMessage: "Send message", + queue: "Queue", + send: "Send", + }, + cancel: { + cancelingAgent: "Canceling agent", + stopAgent: "Stop agent", + interrupt: "Interrupt", + }, + voice: { + enableVoiceMode: "Enable Voice mode", + voiceMode: "Voice mode", + unmuteVoiceMode: "Unmute Voice mode", + muteVoiceMode: "Mute Voice mode", + stopDictation: "Stop dictation", + startDictation: "Start dictation", + unmuteVoice: "Unmute voice", + muteVoice: "Mute voice", + dictation: "Dictation", + interruptBeforeVoice: "Interrupt the agent before starting voice mode", + }, + attachments: { + addImage: "Add image", + addIssueOrPr: "Add issue or PR", + dropImagesHere: "Drop images here", + editQueuedMessage: "Edit queued message", + sendQueuedMessageNow: "Send queued message now", + openImage: "Open image attachment", + removeImage: "Remove image attachment", + openGithub: "Open {{kind}} #{{number}}", + removeGithub: "Remove {{kind}} #{{number}}", + browserElement: "Element · {{tag}}", + openBrowserElement: "Open browser element attachment", + removeBrowserElement: "Remove browser element attachment", + openReview: "Open review attachment", + removeReview: "Remove review attachment", + }, + errors: { + failedToSend: "Failed to send message", + failedToCreateAgent: "Failed to create agent", + noHostSelected: "No host selected", + initialPromptRequired: "Initial prompt is required", + alreadyLoading: "Already loading", + }, + clientCommands: { + archiveAgent: "Archive the current agent", + freshDraft: "Archive this agent and start a fresh draft", + }, + github: { + searching: "Searching...", + noResults: "No results found.", + searchPlaceholder: "Search issues and PRs...", + title: "Attach issue or PR", + }, + }, + agentControls: { + provider: { + fallback: "Provider", + select: "Select agent provider", + }, + thinking: { + title: "Thinking", + unknown: "Unknown", + extraHigh: "Extra high", + select: "Select thinking option", + selectWithValue: "Select thinking option ({{value}})", + }, + model: { + unknown: "Unknown model", + }, + features: { + title: "Features", + open: "Open agent features", + on: "On", + off: "Off", + }, + mode: { + title: "Mode", + searchPlaceholder: "Search modes...", + selectWithValue: "Select agent mode ({{value}})", + }, + hints: { + thinking: "Thinking mode", + model: "Change model", + mode: "Change permission mode", + }, + }, + agentStream: { + empty: "Start chatting with this agent...", + scrollToBottom: "Scroll to bottom", + permission: { + plan: "Plan", + required: "Permission Required", + deny: "Deny", + accept: "Accept", + implement: "Implement", + question: "How would you like to proceed?", + proposedPlan: "Proposed plan", + }, + }, + agentPanel: { + states: { + notFound: "Agent not found", + failedToLoad: "Failed to load agent", + reconnecting: "Reconnecting...", + archivingTitle: "Archiving agent...", + archivingSubtitle: "Please wait while we archive this agent.", + }, + unavailable: { + selectedHost: "Selected host", + unknownHost: + "Cannot open this agent because {{serverLabel}} is not configured on this device.", + addHost: "Add the host in Settings or open an agent on a configured server to continue.", + preparingSession: "Preparing {{serverLabel}} session...", + connecting: "Connecting to {{serverLabel}}...", + showSoon: "We will show this agent in a moment.", + showWhenOnline: "We will show this agent once the host is online.", + reconnectingTo: "Reconnecting to {{serverLabel}}...", + showAgainWhenReachable: "We will show this agent again as soon as the host is reachable.", + }, + archived: { + callout: "This agent is archived", + unarchive: "Unarchive", + }, + }, + sessions: { + title: "Sessions", + empty: "No sessions yet", + actions: { + loadMore: "Load more", + }, + }, + agentList: { + fallbackTitle: "New session", + dateSections: { + recent: "Recent", + today: "Today", + yesterday: "Yesterday", + thisWeek: "This week", + thisMonth: "This month", + older: "Older", + }, + status: { + initializing: "Starting", + idle: "Idle", + running: "Running", + error: "Error", + closed: "Closed", + }, + badges: { + archived: "Archived", + pending: "{{count}} pending", + attention: "Attention", + }, + archiveSheet: { + hostOffline: "Host offline", + runningAgent: "This agent is still running. Archiving it will stop the agent.", + archive: "Archive", + }, + }, + message: { + actions: { + copyCode: "Copy code", + copyTurn: "Copy turn", + copyMessage: "Copy message", + openFile: "Open file", + copied: "Copied", + }, + attachments: { + dismissImage: "Dismiss image", + closeImage: "Close image", + imageLoadFailed: "Couldn't load image", + imageUnavailable: "Image unavailable", + imagePreviewUnavailable: "Image preview unavailable.", + imagePreviewLoadFailed: "Unable to load image preview.", + reviewOne: "Review · 1 comment", + reviewMany: "Review · {{count}} comments", + textAttachment: "Text attachment", + }, + speak: { + header: "Spoke", + }, + activity: { + details: "Details", + }, + dictation: { + start: "Start voice dictation", + cancel: "Cancel dictation", + retry: "Retry dictation", + insert: "Insert transcription", + insertAndSend: "Insert transcription and send", + failed: "Dictation failed: {{error}}", + failedRetry: "Dictation failed. Tap retry.", + }, + question: { + submit: "Submit", + next: "Next", + answerPlaceholder: "Type your answer...", + otherPlaceholder: "Other...", + }, + todo: { + title: "Tasks", + empty: "No tasks yet.", + }, + compaction: { + loading: "Compacting...", + auto: "Context automatically compacted", + manual: "Context manually compacted", + withTokens: "Context compacted ({{tokens}}K tokens)", + completed: "Context compacted", + }, + }, + importSession: { + title: "Import session", + filters: { + all: "All", + }, + status: { + connectHost: "Connect to a host to import sessions", + updateHost: "Update the host to import sessions.", + noProviders: "No importable providers are enabled.", + loading: "Loading recent sessions...", + failedAll: "Could not load recent sessions.", + failedProviders: "Could not load sessions for {{providers}}.", + failedImport: "Could not import selected session.", + }, + actions: { + refresh: "Refresh sessions", + }, + preview: { + untitledSession: "Untitled session", + noPrompt: "No prompt preview", + }, + empty: { + noRecent: "No recent sessions to import.", + alreadyImported: "All recent sessions are already imported.", + noProviderSessions: "No {{provider}} sessions found.", + }, + row: { + importing: "Importing...", + }, + }, + workspace: { + route: { + loading: "Loading workspace", + connecting: "Connecting", + hostOffline: "{{hostName}} is offline", + cannotReachHost: "Cannot reach {{hostName}}", + hostStatus: "Host status: {{status}}", + missing: "Workspace not found", + manageHost: "Manage host", + }, + hoverCard: { + scriptsAccessibility: "Workspace scripts", + }, + fileExplorer: { + sort: { + name: "Name", + modified: "Modified", + size: "Size", + }, + context: { + size: "Size", + modified: "Modified", + copyPath: "Copy path", + download: "Download", + }, + actions: { + back: "Back", + retry: "Retry", + refresh: "Refresh files", + refreshing: "Refreshing files", + }, + empty: { + noFiles: "No files", + }, + states: { + unavailable: "Workspace is unavailable", + loading: "Loading files...", + }, + errors: { + failedToListDirectory: "Failed to list directory", + }, + }, + setup: { + descriptor: { + label: "Setup", + completed: "Setup completed", + failed: "Setup failed", + workspace: "Workspace setup", + }, + status: { + running: "Running", + completed: "Completed", + failed: "Failed", + waiting: "Waiting for setup output", + }, + waiting: "Setting up workspace...", + empty: { + noCommands: "No setup commands ran for this workspace.", + }, + accessibility: { + noCommands: "No setup commands ran for this workspace", + log: "Workspace setup log", + }, + log: { + noOutput: "No output", + }, + }, + browser: { + unavailable: { + title: "Browser is desktop-only", + subtitle: "Open this workspace in Electron to use the built-in browser.", + }, + session: "Browser session {{browserId}}", + controls: { + back: "Back", + forward: "Forward", + stopLoading: "Stop loading", + refresh: "Refresh", + browserUrl: "Browser URL", + enterUrl: "Enter URL", + openDevTools: "Open browser dev tools", + cancelSelector: "Cancel element selector", + selectElement: "Select element", + }, + errors: { + failedToLoad: "Failed to load page", + invalidUrl: "Invalid browser URL", + unsupportedProtocol: "Blocked unsupported browser URL: {{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Host is not connected", + unableToSubscribe: "Unable to subscribe to terminal", + }, + tabs: { + loading: "Loading...", + loadingAgentTitle: "Loading agent title", + emptyPane: "No tabs in this pane.", + fallback: { + newAgent: "New Agent", + setup: "Setup", + workspaceSetup: "Workspace setup", + terminal: "Terminal", + browser: "Browser", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "Switch tabs ({{count}} open)", + title: "Switch tab", + searchPlaceholder: "Search tabs", + }, + menu: { + openFor: "Open menu for {{label}}", + copyResumeCommand: "Copy resume command", + copyAgentId: "Copy agent id", + rename: "Rename", + closeAbove: "Close tabs above", + closeBelow: "Close tabs below", + closeLeft: "Close to the left", + closeRight: "Close to the right", + closeOthers: "Close other tabs", + reloadAgent: "Reload agent", + reloadAgentTooltip: "Reload agent to update skills, MCPs or login status.", + close: "Close", + renameTerminal: "Rename terminal", + renameAgent: "Rename agent", + }, + actions: { + newAgent: "New agent tab", + newTerminal: "New terminal tab", + preparingTerminal: "Preparing terminal tab", + preparingTerminalTooltip: "Preparing terminal...", + newBrowser: "New browser tab", + splitRight: "Split pane right", + splitDown: "Split pane down", + }, + explorer: { + open: "Open explorer", + close: "Close explorer", + toggle: "Toggle explorer", + changes: "Changes", + files: "Files", + }, + toasts: { + copyFailed: "Copy failed", + agentIdCopiedLabel: "Agent ID", + resumeCommandCopiedLabel: "resume command", + resumeIdUnavailable: "Resume ID not available", + resumeCommandUnavailable: "Resume command not available", + reloadingAgent: "Reloading agent...", + reloadedAgent: "Reloaded agent", + failedToReloadAgent: "Failed to reload agent", + }, + confirmations: { + close: "Close", + cancel: "Cancel", + archive: "Archive", + closeTerminalTitle: "Close terminal?", + closeTerminalMessage: "Any running process in this terminal will be stopped immediately.", + archiveRunningAgentTitle: "Archive running agent?", + archiveRunningAgentMessage: + "This agent is still running. Archiving it will stop the agent and close the tab.", + closeTabsLeftTitle: "Close tabs to the left?", + closeTabsRightTitle: "Close tabs to the right?", + closeOtherTabsTitle: "Close other tabs?", + bulk: { + all: "This will archive {{agents}} agent(s), close {{terminals}} terminal(s), and close {{tabs}} tab(s). Any running process in a closed terminal will be stopped immediately.", + agentsAndTerminals: + "This will archive {{agents}} agent(s) and close {{terminals}} terminal(s). Any running process in a closed terminal will be stopped immediately.", + terminalsAndTabs: + "This will close {{terminals}} terminal(s) and close {{tabs}} tab(s). Any running process in a closed terminal will be stopped immediately.", + agentsAndTabs: "This will archive {{agents}} agent(s) and close {{tabs}} tab(s).", + terminals: + "This will close {{terminals}} terminal(s). Any running process in a closed terminal will be stopped immediately.", + tabs: "This will close {{tabs}} tab(s).", + agents: "This will archive {{agents}} agent(s).", + }, + }, + }, + header: { + actions: { + workspaceActions: "Workspace actions", + newAgent: "New agent", + newTerminal: "New terminal", + newBrowser: "New browser tab", + importSession: "Import session", + copyPath: "Copy workspace path", + copyBranchName: "Copy branch name", + showSetup: "Show setup", + }, + toasts: { + workspacePathUnavailable: "Workspace path is not available yet", + branchNameUnavailable: "Branch name not available", + terminalQueued: "Preparing workspace, opening terminal when ready...", + workspacePathCopiedLabel: "Workspace path", + branchNameCopiedLabel: "Branch name", + }, + }, + scripts: { + title: "Scripts", + actions: { + run: "Run", + view: "View", + }, + accessibility: { + trigger: "Workspace scripts", + openAt: "Open {{scriptName}} at {{label}}", + viewTerminal: "View {{scriptName}} terminal", + runScript: "Run {{scriptName}} script", + script: "{{scriptName}} script", + }, + states: { + exitCode: "exit {{code}}", + startFailed: "Failed to start {{scriptName}}", + }, + }, + git: { + actions: { + moreOptions: "More options", + moreActions: "More actions", + commit: { + label: "Commit", + pending: "Committing...", + success: "Committed", + }, + pull: { + label: "Pull", + pending: "Pulling...", + success: "Pulled", + }, + push: { + label: "Push", + pending: "Pushing...", + success: "Pushed", + }, + pullAndPush: { + label: "Pull and push", + pending: "Pulling and pushing...", + success: "Pulled and pushed", + }, + viewPr: "View PR", + createPr: { + label: "Create PR", + pending: "Creating PR...", + success: "PR Created", + }, + mergeBranch: { + label: "Merge locally", + pending: "Merging...", + success: "Merged", + }, + mergeFromBase: { + label: "Update from {{baseRef}}", + pending: "Updating...", + success: "Updated", + }, + archive: { + label: "Archive worktree", + pending: "Archiving...", + success: "Archived", + }, + mergePr: { + squash: "Squash and merge", + merge: "Create a merge commit", + rebase: "Rebase and merge", + pending: "Merging PR...", + success: "PR merged", + }, + autoMerge: { + enableSquash: "Enable auto-merge with squash", + enableMerge: "Enable auto-merge with merge commit", + enableRebase: "Enable auto-merge with rebase", + enabled: "Auto-merge enabled", + enabling: "Enabling auto-merge...", + disabling: "Disabling auto-merge...", + disabled: "Auto-merge disabled", + }, + unavailable: { + viewPrNoGithub: "View PR isn't available right now because GitHub isn't connected", + pullNoRemote: + "Pull isn't available here because this branch is not connected to a remote yet", + pullDirty: + "Pull isn't available while you have local changes so commit or stash them first", + pullUpToDate: "Pull isn't available because this branch is already up to date", + pushNoRemote: + "Push isn't available here because this branch is not connected to a remote yet", + pushBehind: "Push isn't available yet because there are newer changes to bring in first", + pushNothing: "Push isn't available because there is nothing new to send", + pullAndPushNoRemote: + "Pull and push isn't available here because this branch is not connected to a remote yet", + pullAndPushDirty: + "Pull and push isn't available while you have local changes so commit or stash them first", + pullAndPushInSync: "Pull and push isn't available because this branch is already in sync", + createPrNoGithub: "Create PR isn't available right now because GitHub isn't connected", + createPrNoCommits: + "Create PR isn't available because this branch doesn't have any new commits yet", + mergeNoBase: "Merge isn't available because we couldn't determine the base branch", + mergeDirty: + "Merge isn't available while you have local changes so commit or stash them first", + mergeNothing: + "Merge isn't available because this branch doesn't have anything new to merge yet", + updateNoBase: "Update isn't available because we couldn't determine the base branch", + updateDirty: + "Update isn't available while you have local changes so commit or stash them first", + updateCurrent: + "Update isn't available because this branch is already up to date with {{baseRef}}", + archiveNotWorktree: + "Archive isn't available here because this workspace was not created as a Paseo worktree", + mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected", + mergePrMissing: "Merge PR isn't available because there isn't a pull request yet", + mergePrDraft: "Merge PR isn't available because the pull request is still a draft", + mergePrMerged: "Merge PR isn't available because the pull request is already merged", + mergePrClosed: "Merge PR isn't available because the pull request is closed", + mergePrConflicts: "Merge PR isn't available because the pull request has conflicts", + mergePrQueue: "Merge PR isn't available here because this repository uses a merge queue", + mergePrNotReady: + "Merge PR isn't available until GitHub reports the pull request is ready to merge", + autoMergeCannotDisable: "Auto-merge is enabled, but this account can't disable it", + }, + toasts: { + failedCommit: "Failed to commit", + failedPull: "Failed to pull", + failedPush: "Failed to push", + failedPullAndPush: "Failed to pull and push", + failedCreatePr: "Failed to create PR", + failedMergePr: "Failed to merge PR", + failedEnableAutoMerge: "Failed to enable auto-merge", + failedDisableAutoMerge: "Failed to disable auto-merge", + baseRefUnavailable: "Base ref unavailable", + failedMerge: "Failed to merge", + failedMergeFromBase: "Failed to merge from base", + worktreePathUnavailable: "Worktree path unavailable", + failedArchive: "Failed to archive worktree", + }, + archiveWarning: { + title: 'Archive "{{worktreeName}}"?', + confirm: "Archive", + cancel: "Cancel", + uncommittedChanges: "Uncommitted changes", + uncommittedChangesWithDiff: "Uncommitted changes ({{diffStat}})", + addedLine: "{{count}} added line", + addedLines: "{{count}} added lines", + deletedLine: "{{count}} deleted line", + deletedLines: "{{count}} deleted lines", + unpushedCommit: "{{count}} unpushed commit", + unpushedCommits: "{{count}} unpushed commits", + }, + }, + diff: { + binaryFile: "Binary file", + tooLarge: "Diff too large to display", + unified: "Unified diff", + split: "Side-by-side diff", + hideWhitespace: "Hide whitespace", + scrollLongLines: "Scroll long lines", + wrapLongLines: "Wrap long lines", + collapseAll: "Collapse all files", + expandAll: "Expand all files", + refreshing: "Refreshing", + refresh: "Refresh", + refreshState: "Refresh git and GitHub state", + failedRefresh: "Failed to refresh git state.", + emptyHiddenWhitespace: "No visible changes after hiding whitespace", + emptyUncommitted: "No uncommitted changes", + emptyAgainstBase: "No changes vs {{baseRef}}", + checkingRepository: "Checking repository...", + notRepository: "Not a git repository", + diffMode: "Diff mode", + uncommitted: "Uncommitted", + committed: "Committed", + branchUnknown: "Unknown", + base: "base", + newFile: "New", + deletedFile: "Deleted", + }, + openInEditor: { + open: "Open", + chooseEditor: "Choose editor", + openIn: "Open workspace in {{target}}", + openFileIn: "Open {{fileName}} in {{target}}", + failedOpen: "Failed to open workspace", + }, + pr: { + sections: { + checks: "Checks", + reviews: "Reviews", + }, + accessibility: { + pullRequest: "Pull request #{{number}}", + }, + states: { + draft: "Draft", + merged: "Merged", + closed: "Closed", + open: "Open", + }, + activity: { + commented: "Commented", + approved: "Approved", + requestedChanges: "Requested changes", + reviewed: "Reviewed", + }, + time: { + justNow: "just now", + }, + errors: { + statusLoadFailed: "Unable to load pull request status", + activityLoadFailed: "Unable to load pull request activity", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "No host", + switchTitle: "Switch host", + searchPlaceholder: "Search hosts...", + }, + actions: { + addProject: "Add project", + home: "Home", + settings: "Settings", + closeSidebar: "Close sidebar", + }, + sections: { + sessions: "Sessions", + }, + worktreeSetup: { + title: "Set up worktree scripts", + description: + "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", + openProjectSettings: "Open project settings", + }, + project: { + actions: { + menu: "Project actions", + openSettings: "Open project settings", + openNewWindow: "Open in new window", + openNewWindowFailed: "Couldn't open a new window", + remove: "Remove project", + removing: "Removing...", + }, + confirmations: { + removeTitle: "Remove project?", + removeMessage: + 'Remove "{{projectName}}" from the sidebar?\n\nFiles on disk will not be changed.', + removeConfirm: "Remove", + cancel: "Cancel", + }, + toasts: { + hostDisconnected: "Host is not connected", + removeFailed: "Failed to remove some workspaces", + }, + empty: { + title: "No projects yet", + description: "Add a project to get started", + }, + }, + workspace: { + status: { + scriptsAvailable: "Scripts available", + creating: "Creating...", + }, + actions: { + menu: "Workspace actions", + newWorkspace: "New workspace", + createWorkspaceFor: "Create a new workspace for {{projectName}}", + copyPath: "Copy path", + copyBranchName: "Copy branch name", + rename: "Rename workspace", + archive: "Archive", + archiveWorktree: "Archive worktree", + hideFromSidebar: "Hide from sidebar", + archiving: "Archiving...", + hiding: "Hiding...", + }, + confirmations: { + hideTitle: "Hide workspace?", + hideMessage: + 'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.', + hideConfirm: "Hide", + cancel: "Cancel", + }, + rename: { + title: "Rename workspace", + submit: "Rename", + invalidBranchName: "Invalid branch name", + }, + toasts: { + workspacePathUnavailable: "Workspace path not available", + pathCopied: "Path copied", + branchNameCopied: "Branch name copied", + hostDisconnected: "Host is not connected", + hideFailed: "Failed to hide workspace", + archiveFailed: "Failed to archive worktree", + }, + }, + }, + newWorkspace: { + title: "New workspace", + create: "Create", + errors: { + hostDisconnected: "Host is not connected", + createWorktreeFailed: "Failed to create worktree", + composerStateRequired: "Composer state is required", + selectModel: "Select a model", + }, + refPicker: { + startingRef: "Starting ref", + chooseStart: "Choose where to start from", + checkoutHint: "Check out PR #{{number}}?", + checkoutPr: "Check out PR #{{number}}", + dismissCheckoutHint: "Dismiss PR #{{number}} checkout hint", + intoBase: "into {{baseRef}}", + searching: "Searching...", + noMatchingRefs: "No matching refs.", + searchPlaceholder: "Search branches and PRs", + title: "Start from", + }, + }, + desktop: { + quitting: { + title: "Quitting Paseo...", + detail: "Stopping the local daemon.", + }, + daemon: { + title: "Daemon", + status: { + title: "Status", + builtInOnly: "Only the built-in desktop daemon is shown here", + running: "running", + notRunning: "not running", + pid: "PID {{pid}}", + }, + management: { + title: "Manage built-in daemon", + hint: "Let Paseo start and stop the built-in daemon", + pauseTitle: "Pause built-in daemon", + pauseMessage: + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + pauseAndStop: "Pause and stop", + registrationFailed: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + pausedStopFailed: + "Built-in daemon management was paused, but Paseo could not stop the daemon.", + updateFailed: "Unable to update built-in daemon management.", + }, + keepRunning: { + title: "Keep daemon running after quit", + hint: "Daemon keeps running when you quit Paseo", + }, + logs: { + title: "Log file", + modalTitle: "Daemon logs", + unavailable: "Log path unavailable", + empty: "(log file is empty)", + copied: "Log path copied.", + copyFailed: "Unable to copy log path.", + open: "Open logs", + copyPath: "Copy path", + }, + fullStatus: { + title: "Full status", + modalTitle: "Daemon status", + hint: "Runs `paseo daemon status` and shows the output", + view: "View status", + copied: "Status copied to clipboard.", + fetchFailed: "Failed to fetch daemon status: {{message}}", + }, + advancedSettings: "Advanced settings", + openAdvancedSettings: "Open advanced daemon settings", + versionMismatch: + "App and daemon versions don't match. Update both to the same version for the best experience.", + loadFailed: "Unable to load desktop daemon status.", + }, + updates: { + status: { + checking: "Checking for app updates...", + installing: "Installing app update...", + upToDate: "App is up to date.", + upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", + pending: "We'll let you know when the update is ready.", + availableWithVersion: "Update ready: {{version}}", + available: "An app update is ready to install.", + installed: "App update installed. Restart required.", + failed: "Failed to update app.", + idle: "Update status has not been checked yet.", + }, + installError: "Unable to install the desktop app update.", + callout: { + installingTitle: "Installing update", + failedTitle: "Update failed", + availableTitle: "Update available", + genericError: "Something went wrong.", + whatsNew: "What's new", + installingAction: "Installing...", + installAndRestart: "Install & restart", + installingDescription: "Installing and restarting...", + versionReady: "{{version}} is ready to install.", + newVersionReady: "A new version is ready to install.", + restartWarning: "Upgrading the app will stop running agents and close terminal sessions.", + }, + }, + settings: { + loadFailed: "Unable to load desktop settings.", + saveFailed: "Unable to save desktop settings.", + }, + rosetta: { + title: "Download the Apple Silicon build", + runningIntel: "You're running the Intel build of Paseo under Rosetta on Apple Silicon.", + highCpu: "This causes high CPU usage. Download the Apple Silicon build to fix it.", + download: "Download", + }, + permissions: { + notifications: { + allowed: "Notifications are allowed by the OS.", + denied: "Notifications are denied in system settings.", + notGranted: "Notifications have not been granted yet.", + webOnly: "Desktop notification status is only available on web runtime.", + supported: "Desktop notifications are supported.", + unsupported: "Desktop notifications are not supported on this platform.", + apiUnavailable: "Web Notification API is unavailable in this environment.", + requestsWebOnly: "Desktop notification requests are only available on web runtime.", + requestUnavailable: "Web Notification API requestPermission() is unavailable.", + requestFailed: "Failed to request notification permission: {{message}}", + unexpectedState: "Unexpected notification permission state: {{state}}", + }, + microphone: { + webOnly: "Desktop microphone status is only available on web runtime.", + navigatorUnavailable: "Navigator is unavailable in this environment.", + granted: "Microphone access is granted.", + denied: "Microphone access is denied in system settings.", + notGranted: "Microphone permission has not been granted yet.", + unexpectedState: "Unexpected microphone permission state: {{state}}", + statusApiUnavailable: + "Microphone status API is unavailable in this runtime. Use Request to check access.", + queryFailed: "Failed to query microphone status: {{message}}", + captureUnavailable: "Microphone capture is unavailable in this environment.", + permissionApiUnavailable: + "Permission status API is unavailable. Use Request to check access.", + requestsWebOnly: "Desktop microphone requests are only available on web runtime.", + captureApiUnavailable: "Microphone capture API is unavailable in this environment.", + requestDenied: "Microphone permission was denied by the user or system.", + noDevice: "No microphone device was found.", + requestFailed: "Failed to request microphone permission: {{message}}", + }, + empty: { + notifications: "Notification status has not been checked yet.", + microphone: "Microphone status has not been checked yet.", + }, + testNotification: { + title: "Paseo notification test", + body: "If you can see this, desktop notifications work.", + notDelivered: "Notification was not delivered. Check System Settings > Notifications.", + failed: "Failed to send notification.", + }, + }, + integrations: { + cli: { + statusFailed: "Unable to check CLI install status.", + installFailed: "Unable to install the Paseo CLI.", + }, + skills: { + statusFailed: "Unable to check orchestration skills status.", + installFailed: "Unable to install orchestration skills.", + updateFailed: "Unable to update orchestration skills.", + uninstallFailed: "Unable to uninstall orchestration skills.", + }, + }, + }, + startup: { + errorTitle: "Something went wrong", + errorDescription: + "The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.", + logs: { + loading: "Loading daemon logs...", + unavailable: "No daemon logs available.", + loadFailed: "Unable to load daemon logs: {{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "Add a project", + description: "Open a folder on your machine", + }, + importSession: { + title: "Import session", + description: "Bring in recent external CLI sessions", + }, + setupProviders: { + title: "Setup providers", + description: "Configure Claude Code, Codex, and more", + }, + pairDevice: { + title: "Pair device", + description: "Connect your phone to this daemon", + }, + }, + }, + projectPicker: { + placeholder: "Type a directory path...", + opening: "Opening project...", + empty: "Start typing a path", + }, + branchSwitcher: { + currentBranch: "Current branch: {{branchName}}. Press to switch branch.", + placeholder: "Switch branch...", + searchPlaceholder: "Filter branches...", + empty: "No branches found.", + title: "Switch branch", + uncommittedTitle: "Uncommitted changes", + uncommittedMessage: "You have uncommitted changes. Stash them before switching branches?", + stashAndSwitch: "Stash & Switch", + failedToStash: "Failed to stash changes", + failedToSwitch: "Failed to switch branch", + restoreStashTitle: "Restore stashed changes?", + restoreStashMessage: + "This branch has stashed changes from a previous session. Would you like to restore them?", + restore: "Restore", + later: "Later", + stashRestored: "Stashed changes restored", + }, + agentAutocomplete: { + searchingWorkspace: "Searching workspace...", + loadingCommands: "Loading commands...", + noFiles: "No files or directories found", + noCommands: "No commands found", + failedToLoad: "Failed to load", + }, + loadOlderHistory: { + failed: "Couldn't load older history", + }, + imageAttachmentPicker: { + permissionTitle: "Permission required", + permissionMessage: "Please allow access to your photo library to attach images.", + errorTitle: "Error", + failedToSelect: "Failed to select image", + dialogTitle: "Attach images", + dialogFilterName: "Images", + }, + workspaceSetup: { + title: "Create workspace", + errors: { + failedCreateWorktree: "Failed to create worktree", + failedOpenProject: "Failed to open project", + selectModel: "Select a model", + hostDisconnected: "Host is not connected", + pendingRequired: "No workspace setup is pending", + composerStateRequired: "Workspace setup composer state is required", + }, + }, + onboarding: { + title: "Welcome to Paseo", + subtitle: "Connect your computer to get started", + actions: { + settings: "Settings", + }, + }, + modelSelector: { + title: "Select provider", + selectModel: "Select model", + selectedModel: "Select model ({{model}})", + loading: "Loading...", + loadingShort: "Loading", + loadingSelector: "Loading model selector...", + error: "Error", + defaultModel: "Default", + favorites: "Favorites", + favoriteModel: "Favorite model", + unfavoriteModel: "Unfavorite model", + modelCount: "{{count}} model", + modelCountPlural: "{{count}} models", + retry: "Retry", + retrying: "Retrying...", + noMatches: "No models match your search", + searchPlaceholder: "Search models...", + openProviderSettings: "Open {{provider}} settings", + }, + providerCatalog: { + title: "Add provider", + search: "Search providers", + noProviders: "No providers found", + actions: { + add: "Add", + adding: "Adding", + installed: "Installed", + cancel: "Cancel", + installInstructions: "Install instructions", + installInstructionsFor: "{{provider}} install instructions", + }, + errors: { + unableToInstall: "Unable to install provider", + }, + }, + providerSelection: { + defaultModel: "Default", + selectModel: "Select model", + loading: "Loading...", + error: "Error", + unavailable: "Unavailable", + unknownError: "Unknown error", + readiness: { + initialPromptRequired: "Initial prompt is required", + noProviders: "No available providers on the selected host", + modelDefaultsLoading: "Model defaults are still loading", + noModelAvailable: "No model is available for the selected provider", + workspaceDirectoryNotFound: "Workspace directory not found", + hostDisconnected: "Host is not connected", + }, + }, + pairing: { + connectionMethods: { + title: "Add connection", + direct: { + title: "Direct connection", + description: "Local network or VPN.", + }, + scanQr: { + title: "Scan QR code", + description: "Encrypted relay connection.", + }, + pasteLink: { + title: "Paste pairing link", + description: "Encrypted relay connection.", + }, + }, + direct: { + title: "Direct connection", + helper: "Enter the address of a Paseo server.", + fields: { + host: "Host", + port: "Port", + password: "Password", + optional: "Optional", + useSsl: "Use SSL", + connectionUri: "Connection URI", + }, + advanced: { + label: "Advanced", + show: "Show advanced", + hide: "Hide advanced", + }, + passwordVisibility: { + show: "Show password", + hide: "Hide password", + }, + actions: { + cancel: "Cancel", + connect: "Connect", + connecting: "Connecting...", + }, + errors: { + hostRequired: "Host is required", + invalidPort: "Port must be between 1 and 65535", + invalidConnection: "Invalid connection", + failedTitle: "Connection failed", + failedToConnect: "We failed to connect to {{endpoint}}.", + noAdditionalDetails: "{{detail}} (no additional details provided)", + timedOut: "Connection timed out. Check the host/port and your network.", + refused: "Connection refused. Is the server running at this address?", + hostNotFound: "Host not found. Check the hostname and try again.", + hostUnreachable: "Host is unreachable. Check your network and firewall.", + tlsError: + "TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.", + unableToConnect: "Unable to connect. Check the host/port and that the daemon is reachable.", + details: "Details: {{detail}}", + }, + }, + link: { + title: "Paste pairing link", + helper: "Paste the pairing link from your server.", + label: "Pairing link", + errors: { + required: "Paste a pairing link (.../#offer=...)", + missingOffer: "Link must include #offer=...", + emptyOffer: "Offer payload is empty", + invalid: "Invalid pairing link", + unableToPair: "Unable to pair host", + }, + alert: { + failedTitle: "Pairing failed", + }, + actions: { + cancel: "Cancel", + pair: "Pair", + pairing: "Pairing...", + }, + }, + scan: { + title: "Scan QR", + webUnavailableTitle: "Not available on web", + webUnavailableBody: + 'QR scanning is not supported in the web build. Use "Paste link" instead.', + backToSettings: "Back to Settings", + cameraPermissionTitle: "Camera permission", + cameraPermissionBody: "Allow camera access to scan the pairing QR code from your daemon.", + grantPermission: "Grant permission", + pairing: "Pairing...", + unableToPair: "Unable to pair host", + errorTitle: "Error", + }, + device: { + loadingOffer: "Loading pairing offer...", + failedToLoadOffer: "Failed to load pairing offer.", + relayDisabled: "Relay is not enabled. Enable relay to pair a device.", + unavailable: "Pairing offer unavailable.", + hint: "Scan this QR code with Paseo on your phone, or copy the link below.", + qrUnavailable: "QR code unavailable.", + retry: "Retry", + copy: "Copy", + copied: "Copied", + }, + }, + realtimeVoice: { + actions: { + mute: "Mute realtime voice", + unmute: "Unmute realtime voice", + stop: "Stop realtime voice and interrupt turn", + }, + }, + rewind: { + tooltip: "Rewind to this message", + warning: "This action cannot be undone", + actions: { + conversation: "Rewind conversation", + files: "Rewind files", + both: "Rewind conversation and files", + }, + errors: { + failed: "Failed to rewind agent", + }, + }, + diffViewer: { + empty: "No changes to display", + }, + serviceUrl: { + title: "Open service URL", + message: "Open {{url}}?", + inPaseo: "In Paseo", + externalBrowser: "External browser", + dontAskAgain: "Don't ask again", + }, + downloads: { + requestTokenFailed: "Failed to request download token.", + hostUnavailable: "Download host is unavailable.", + cancelled: "Download was cancelled.", + failed: "Failed to download file.", + shareFile: "Share file", + shareFileNamed: "Share {{fileName}}", + }, + menu: { + backdrop: "Menu backdrop", + }, + subagents: { + archiveAction: "Archive {{label}}", + archiveTooltip: "Archive subagent", + }, + panels: { + draft: { + newAgent: "New Agent", + creatingAgent: "Creating agent", + }, + file: { + executionDirectoryMissing: "Workspace execution directory not found.", + loading: "Loading file...", + noPreview: "No preview available", + binaryPreviewUnavailable: "Binary preview unavailable", + failedToLoad: "Failed to load file", + failedToLoadPreview: "Failed to load file preview", + }, + }, + toolCallDetails: { + error: "Error", + empty: "No additional details available", + subAgentActivity: "Sub-agent activity", + input: "Input", + output: "Output", + }, + renameModal: { + rename: "Rename", + saving: "Saving...", + }, + sidebarCallout: { + dismiss: "Dismiss", + }, + contextWindow: { + title: "Context window", + used: "{{percentage}}% used", + tokens: "{{used}} / {{max}} tokens", + sessionCost: "Session cost {{cost}}", + accessibility: "Context window {{percentage}}% used", + }, + review: { + comment: { + add: "Add review comment", + edit: "Edit review comment", + delete: "Delete review comment", + label: "Review comment", + placeholder: "Leave a comment", + cancel: "Cancel", + cancelAccessibility: "Cancel review comment", + save: "Comment", + saveAccessibility: "Save review comment", + }, + }, + settings: { + title: "Settings", + loading: "Loading settings...", + groups: { + app: "App", + host: "Host", + }, + hostPicker: { + switchHost: "Switch host", + local: "Local", + }, + backToWorkspace: "Back", + addHost: "Add host", + projects: "Projects", + projectList: { + hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}", + editProject: "Edit {{projectName}}", + }, + groupInfo: "About {{title}}", + sections: { + general: "General", + daemon: "Daemon", + appearance: "Appearance", + shortcuts: "Shortcuts", + integrations: "Integrations", + permissions: "Permissions", + diagnostics: "Diagnostics", + about: "About", + }, + hostSections: { + connections: "Connections", + agents: "Agents", + workspaces: "Workspaces", + providers: "Providers", + host: "Host", + }, + general: { + title: "General", + defaultSend: { + label: "Default send", + description: "What happens when you press Enter while the agent is running", + options: { + interrupt: "Interrupt", + queue: "Queue", + }, + }, + serviceUrls: { + label: "Service URLs", + description: "Where to open URLs from running scripts", + options: { + ask: "Ask", + inApp: "In Paseo", + external: "External browser", + }, + }, + terminalScrollback: { + label: "Terminal scrollback", + description: "Lines kept in the built-in terminal buffer", + accessibilityLabel: "Terminal scrollback lines", + }, + language: { + label: "Language", + description: "App language", + options: { + system: "System", + ar: "Arabic", + en: "English", + es: "Spanish", + fr: "French", + ru: "Russian", + zhCN: "Simplified Chinese", + }, + }, + }, + diagnostics: { + title: "Diagnostics", + testAudio: "Test audio", + playTest: "Play test", + playing: "Playing...", + playbackFailed: "Playback failed: {{message}}", + }, + about: { + title: "About", + appVersion: "App version", + thisDevice: "This device", + connectedHosts: "Connected hosts", + offline: "Offline", + versionDiffers: "Version differs from this device", + releaseChannel: { + label: "Release channel", + description: "Switch to Beta to get updates sooner and help shape them", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "App updates", + readyToInstall: "Ready to install: {{version}}", + installTitle: "Install desktop update", + installMessage: "This updates Paseo on this computer", + installConfirm: "Install update", + update: "Update", + updateTo: "Update to {{version}}", + installing: "Installing...", + check: "Check", + checking: "Checking...", + alertTitle: "Error", + alertMessage: "Unable to open the update confirmation dialog.", + }, + }, + appearance: { + theme: { + title: "Theme", + accessibilityLabel: "Theme: {{value}}", + options: { + light: "Light", + dark: "Dark", + zinc: "Zinc", + midnight: "Midnight", + claude: "Claude", + ghostty: "Ghostty", + auto: "System", + }, + }, + fonts: { + title: "Fonts", + systemDefault: "System default", + interfaceFont: "Interface font", + interfaceFontHint: "Used across the app. Leave empty for the system default", + interfaceFontAccessibility: "Interface font family", + interfaceSize: "Interface size", + interfaceSizeAccessibility: "Interface font size", + codeFont: "Code font", + codeFontHint: + "Used in code, diffs, and the terminal output. Leave empty for the system default", + codeFontAccessibility: "Code font family", + codeSize: "Code size", + codeSizeAccessibility: "Code font size", + }, + syntax: { + title: "Syntax", + highlightTheme: "Highlight theme", + highlightThemeHint: "Colors for code, independent of the app theme", + highlightThemeAccessibility: "Highlight theme: {{value}}", + previewAccessibility: "Live preview of the syntax theme and code font", + }, + }, + shortcuts: { + dialogTitle: "Shortcuts", + unavailableOnMobile: "Keyboard shortcuts are only available on desktop", + capturePrompt: "Press shortcut...", + actions: { + done: "Done", + cancel: "Cancel", + rebind: "Rebind", + reset: "Reset", + resetAll: "Reset all", + }, + sections: { + navigation: "Navigation", + tabsPanes: "Tabs & Panes", + projects: "Projects", + panels: "Panels", + agentInput: "Agent Input", + }, + help: { + openProject: "Open project", + newWorktree: "New worktree", + archiveWorktree: "Archive worktree", + newTab: "New tab", + closeCurrentTab: "Close current tab", + jumpToWorkspace: "Jump to workspace", + jumpToTab: "Jump to tab", + previousWorkspace: "Previous workspace", + nextWorkspace: "Next workspace", + previousTab: "Previous tab", + nextTab: "Next tab", + splitPaneRight: "Split pane right", + splitPaneDown: "Split pane down", + focusPaneLeft: "Focus pane left", + focusPaneRight: "Focus pane right", + focusPaneUp: "Focus pane up", + focusPaneDown: "Focus pane down", + moveTabLeft: "Move tab left", + moveTabRight: "Move tab right", + moveTabUp: "Move tab up", + moveTabDown: "Move tab down", + closePane: "Close pane", + newTerminal: "New terminal", + toggleCommandCenter: "Toggle command center", + showKeyboardShortcuts: "Show keyboard shortcuts", + toggleLeftSidebar: "Toggle left sidebar", + toggleRightSidebar: "Toggle right sidebar", + toggleBothSidebars: "Toggle both sidebars", + toggleSettings: "Toggle settings", + toggleFocusMode: "Toggle focus mode", + cycleTheme: "Cycle theme", + focusMessageInput: "Focus message input", + toggleVoiceMode: "Toggle voice mode", + startStopDictation: "Start/stop dictation", + interruptAgent: "Interrupt agent", + sendMessage: "Send message", + queueMessage: "Queue message", + muteUnmuteVoiceMode: "Mute/unmute voice mode", + }, + helpNotes: { + showKeyboardShortcuts: "Available when focus is not in a text field or terminal.", + }, + }, + integrations: { + title: "Integrations", + docs: { + cli: "CLI docs", + skills: "Skills docs", + openCli: "Open CLI documentation", + openSkills: "Open skills documentation", + }, + commandLine: { + title: "Command line", + description: "Control and script agents from your terminal", + }, + skills: { + title: "Orchestration skills", + description: "Teach your agents to orchestrate through the CLI", + updateAvailable: "Update available", + updateTitle: "Update Paseo skills?", + updateFallback: "Sync bundled skills to your machine.", + uninstallTitle: "Uninstall Paseo skills?", + uninstallMessage: + "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.", + }, + actions: { + install: "Install", + installing: "Installing...", + installed: "Installed", + update: "Update", + working: "Working...", + uninstall: "Uninstall", + }, + operations: { + add: "Add skill", + update: "Update skill", + delete: "Delete skill", + }, + }, + permissions: { + title: "Permissions", + notifications: "Notifications", + microphone: "Microphone", + refresh: "Refresh", + refreshing: "Refreshing...", + refreshAccessibility: "Refresh desktop permissions", + test: "Test", + actions: { + granted: "Granted", + request: "Request", + requesting: "Requesting...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "Host not found", + badges: { + relay: "Relay", + local: "Local", + }, + connections: { + title: "Connections", + removeTitle: "Remove connection", + removeMessage: "Remove {{name}}? This cannot be undone.", + removeAction: "Remove", + removeErrorTitle: "Error", + removeErrorMessage: "Unable to remove connection", + timeout: "Timeout", + }, + pairDevices: { + title: "Pair devices", + rowTitle: "Pair a device", + rowHint: "Scan a QR code or copy a link to connect your phone to this host", + }, + orchestration: { + title: "Orchestration", + unavailable: "Connect to this host to manage orchestration", + enableTools: { + title: "Enable Paseo tools", + hint: "Agents will be able to manage worktrees, agents and schedules", + accessibilityLabel: "Inject Paseo tools", + }, + systemPrompt: { + title: "System prompt", + hint: "Adds a system prompt to all agents", + sheetTitle: "Append system prompt", + accessibilityLabel: "Append system prompt", + placeholder: "Always keep replies concise.", + }, + }, + agents: { + unavailable: "Connect to this host to manage agents", + }, + workspaces: { + unavailable: "Connect to this host to manage workspaces", + }, + daemon: { + rename: { + editLabel: "Edit label", + title: "Rename host", + placeholder: "My Host", + }, + restart: { + title: "Restart daemon", + hint: "Restarts the daemon process. The app will reconnect automatically", + confirmTitle: "Restart {{name}}", + confirmMessage: + "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.", + restarting: "Restarting...", + unableToReconnectTitle: "Unable to reconnect", + unableToReconnectMessage: + "{{name}} did not come back online. Please verify it restarted.", + unavailableTitle: "Host unavailable", + unavailableMessage: + "This host is not connected. Wait for it to come online before restarting.", + offlineTitle: "Host offline", + offlineMessage: + "This host is offline. Paseo reconnects automatically-wait until it's back online before restarting.", + requestFailedTitle: "Error", + requestFailedMessage: + "Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.", + dialogFailedMessage: "Unable to open the restart confirmation dialog.", + }, + dangerZone: "Danger zone", + remove: { + title: "Remove host", + localTitle: "Remove localhost connection", + hint: "Removes this host and its saved connections from this device", + localHint: "Removes localhost from this device and stops the built-in daemon", + localConfirmTitle: "Remove localhost connection and stop daemon?", + confirmMessage: "Remove {{name}}? This will delete its saved connections.", + localConfirmMessage: + "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + errorTitle: "Error", + errorMessage: "Unable to remove host", + localErrorMessage: "Unable to remove localhost connection", + }, + }, + }, + providers: { + title: "Providers", + addProvider: "Add provider", + providerDetails: "{{name}} provider details", + enableProvider: "Enable {{name}}", + unavailable: "Connect to this host to see providers", + loading: "Loading...", + addErrorTitle: "Unable to add provider", + updateErrorTitle: "Unable to update provider", + statuses: { + disabled: "Disabled", + loading: "Loading", + error: "Error", + available: "Available", + notInstalled: "Not installed", + }, + models: { + one: "1 model", + many: "{{count}} models", + addModel: "Add model", + addCustomTitle: "Add custom model", + modelId: "Model ID", + modelIdPlaceholder: "e.g. openai/gpt-5", + add: "Add", + adding: "Adding...", + failedToSave: "Failed to save model", + removeModel: "Remove {{id}}", + searchPlaceholder: "Search models", + loading: "Loading models...", + retry: "Retry", + retrying: "Retrying...", + noSearchMatches: "No models match your search", + noneDetected: "No models detected", + discovered: "Discovered", + custom: "Custom models", + updated: "Updated {{time}}", + }, + diagnostic: { + title: "Diagnostic", + button: "Diagnostic", + refresh: "Refresh", + refreshing: "Refreshing...", + refreshAccessibility: "Refresh diagnostic", + refreshingAccessibility: "Refreshing diagnostic", + running: "Running diagnostic...", + none: "No diagnostic available", + failedToFetch: "Failed to fetch diagnostic", + unknownError: "Unknown error", + }, + }, + project: { + noEditableTarget: "We don't have an editable copy of this project on any connected host.", + backToProjects: "Back to projects", + switchHost: "Switch host", + rename: { + renamedToast: "Project renamed", + errorFallback: "Couldn't rename project", + renameLabel: "Rename project", + resetLabel: "Reset project name to default", + projectNameLabel: "Project name", + saveLabel: "Save project name", + cancelLabel: "Cancel renaming", + reset: "Reset", + }, + readFailures: { + invalidTitle: "paseo.json couldn't be parsed", + invalidDescription: "Fix the file on disk, then reload.", + missingTitle: "This host doesn't have this project", + missingWithHosts: "Switch to another host above, or reload.", + missingSingleHost: "The selected host has no record of this project.", + transportTitle: "Couldn't load paseo.json", + transportFallback: "The host didn't respond.", + failedTitle: "Couldn't load paseo.json", + failedDescription: "Reload to try again.", + }, + worktree: { + title: "Worktree lifecycle hooks", + info: "Commands that run when a worktree is created or torn down for this project", + docs: "Docs", + docsTooltip: + "See docs for more details and the environment variables available to these commands", + setup: "Setup", + setupAccessibility: "Worktree setup commands", + teardown: "Teardown", + teardownAccessibility: "Worktree teardown commands", + }, + scripts: { + title: "Scripts", + info: "Long-running services and one-off commands you can launch from any agent in this project", + empty: "No scripts yet.", + untitled: "Untitled script", + port: "port {{port}}", + menuAccessibility: "Open script menu", + removeTitle: "Remove script?", + removeMessage: "Remove {{name}}?", + removeFallbackName: "this script", + name: "Name", + command: "Command", + nameAccessibility: "Script name", + commandAccessibility: "Script command", + nameRequired: "Name is required", + commandRequired: "Command is required", + newScript: "New script", + editScript: "Edit {{name}}", + runAsService: "Run as a service", + serviceHint: "Paseo supervises the process and assigns a port via $PASEO_PORT", + actions: { + add: "Add script", + edit: "Edit", + remove: "Remove", + }, + }, + metadata: { + title: "Metadata generation", + info: "Project-specific instructions injected into the AI prompts Paseo uses to generate metadata - use them to enforce your team's conventions like branch naming, commit style, or PR format", + agentTitle: "Agent titles", + agentTitlePlaceholder: "Keep titles imperative and under 40 characters", + branchName: "Branch names", + branchNamePlaceholder: "Prefix branches with feat/ or fix/, mb/ for personal branches", + commitMessage: "Commit messages", + commitMessagePlaceholder: "Use Conventional Commits with a scope", + pullRequest: "Pull requests", + pullRequestPlaceholder: "Lead with a one-paragraph summary, include a Test plan section", + }, + writeFailures: { + staleTitle: "Config changed on disk", + staleDescription: "Reload to fetch the latest paseo.json before saving.", + failedTitle: "Couldn't save paseo.json", + failedDescription: "Try again, or reload the latest version from disk.", + }, + actions: { + reload: "Reload", + tryAgain: "Try again", + save: "Save", + saved: "Project saved", + saving: "Saving...", + cancel: "Cancel", + }, + }, + }, +} as const; + +type WidenStringLeaves = { + [K in keyof T]: T[K] extends string ? string : WidenStringLeaves; +}; + +export type TranslationResources = WidenStringLeaves; diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts new file mode 100644 index 000000000..175a67a30 --- /dev/null +++ b/packages/app/src/i18n/resources/es.ts @@ -0,0 +1,1842 @@ +import type { TranslationResources } from "./en"; + +export const es: TranslationResources = { + common: { + back: "Atrás", + loading: "Cargando...", + actions: { + back: "Atrás", + cancel: "Cancelar", + close: "Cerca", + copy: "Copiar", + dismiss: "Despedir", + retry: "Rever", + search: "Buscar", + select: "Seleccionar", + }, + placeholders: { + search: "Buscar...", + }, + empty: { + noResults: "No se encontraron resultados", + noOptionsMatchSearch: "Ninguna opción coincide con su búsqueda.", + }, + states: { + loading: "Cargando...", + starting: "A partir de...", + copied: "Copiado", + copiedLabel: "Copiado{{label}}", + downloadComplete: "Descarga completa", + downloadFailed: "Descarga fallida", + }, + errors: { + error: "Error", + unableToSave: "No se puede guardar", + nameRequired: "El nombre es obligatorio", + daemonUnavailable: "Daemonno disponible", + daemonClientUnavailable: "ClienteDaemonno disponible", + daemonClientDisconnected: "El clienteDaemonestá desconectado", + noFileFound: "No se encontró ningún archivo para{{token}}", + unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.", + }, + connectionStatus: { + online: "En línea", + connecting: "Conectando", + offline: "Desconectado", + error: "Error", + idle: "Inactivo", + }, + }, + shell: { + menu: { + toggleSidebar: "Alternar barra lateral", + open: "abrir menú", + close: "Cerrar menú", + }, + commandCenter: { + placeholder: "Escriba un comando o busque agentes...", + noMatches: "No hay coincidencias", + actions: "Comportamiento", + agents: "Agentes", + newAgent: "Nuevo agente", + openProject: "Abrir proyecto", + home: "Hogar", + }, + }, + composer: { + placeholders: { + desktop: "Envíe un mensaje al agente, etiquete@fileso use/commandsy/skills", + mobile: "Mensaje,@files,/commands", + fallback: "Mensaje...", + }, + input: { + accessibilityLabel: "Agente de mensajes...", + focusHint: "{{shortcut}}para enfocar", + addAttachment: "Agregar archivo adjunto", + interruptAgent: "agente de interrupción", + queueMessage: "mensaje de cola", + sendAndInterrupt: "Enviar e interrumpir", + sendMessage: "enviar mensaje", + queue: "Cola", + send: "Enviar", + }, + cancel: { + cancelingAgent: "Agente de cancelación", + stopAgent: "detener agente", + interrupt: "Interrumpir", + }, + voice: { + enableVoiceMode: "Habilitar el modo de voz", + voiceMode: "Modo de voz", + unmuteVoiceMode: "Activar el modo de voz", + muteVoiceMode: "Modo de voz silenciosa", + stopDictation: "detener el dictado", + startDictation: "Iniciar dictado", + unmuteVoice: "Activar voz", + muteVoice: "voz muda", + dictation: "Dictado", + interruptBeforeVoice: "Interrumpir al agente antes de iniciar el modo de voz.", + }, + attachments: { + addImage: "Agregar imagen", + addIssueOrPr: "Agregar problema oPR", + dropImagesHere: "Suelta imágenes aquí", + editQueuedMessage: "Editar mensaje en cola", + sendQueuedMessageNow: "Enviar mensaje en cola ahora", + openImage: "Abrir imagen adjunta", + removeImage: "Quitar imagen adjunta", + openGithub: "Abrir{{kind}}#{{number}}", + removeGithub: "Quitar{{kind}}#{{number}}", + browserElement: "Elemento ·{{tag}}", + openBrowserElement: "Abrir archivo adjunto de elemento del navegador", + removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador", + openReview: "Abrir archivo adjunto de reseña", + removeReview: "Eliminar archivo adjunto de reseña", + }, + errors: { + failedToSend: "No se pudo enviar el mensaje", + failedToCreateAgent: "No se pudo crear el agente", + noHostSelected: "Ningún anfitrión seleccionado", + initialPromptRequired: "Se requiere aviso inicial", + alreadyLoading: "Ya cargando", + }, + clientCommands: { + archiveAgent: "Archivar el agente actual", + freshDraft: "Archive este agente y comience un nuevo borrador", + }, + github: { + searching: "Búsqueda...", + noResults: "No se encontraron resultados.", + searchPlaceholder: "Problemas de búsqueda y relaciones públicas...", + title: "Adjuntar problema oPR", + }, + }, + agentControls: { + provider: { + fallback: "Proveedor", + select: "Seleccionar proveedor de agente", + }, + thinking: { + title: "Pensamiento", + unknown: "Desconocido", + extraHigh: "extra alto", + select: "Seleccione la opción de pensamiento", + selectWithValue: "Seleccione la opción de pensamiento ({{value}})", + }, + model: { + unknown: "Modelo desconocido", + }, + features: { + title: "Características", + open: "Funciones de agente abierto", + on: "En", + off: "Apagado", + }, + mode: { + title: "Modo", + searchPlaceholder: "Modos de búsqueda...", + selectWithValue: "Seleccione el modo de agente ({{value}})", + }, + hints: { + thinking: "Modo de pensamiento", + model: "Cambiar modelo", + mode: "Cambiar modo de permiso", + }, + }, + agentStream: { + empty: "Comience a chatear con este agente...", + scrollToBottom: "Desplazarse hacia abajo", + permission: { + plan: "Plan", + required: "Permiso requerido", + deny: "Denegar", + accept: "Aceptar", + implement: "Implementar", + question: "¿Cómo le gustaría proceder?", + proposedPlan: "Plan propuesto", + }, + }, + agentPanel: { + states: { + notFound: "Agentno encontrado", + failedToLoad: "No se pudo cargar el agente", + reconnecting: "Reconectando...", + archivingTitle: "Agente de archivo...", + archivingSubtitle: "Espere mientras archivamos este agente.", + }, + unavailable: { + selectedHost: "Anfitrión seleccionado", + unknownHost: + "No se puede abrir este agente porque{{serverLabel}}no está configurado en este dispositivo.", + addHost: + "Agregue el host en Configuración o abra un agente en un servidor configurado para continuar.", + preparingSession: "Preparando sesión{{serverLabel}}...", + connecting: "Conectando a{{serverLabel}}...", + showSoon: "Le mostraremos a este agente en un momento.", + showWhenOnline: "Le mostraremos a este agente una vez que el anfitrión esté en línea.", + reconnectingTo: "Reconectándose a{{serverLabel}}...", + showAgainWhenReachable: + "Le mostraremos a este agente nuevamente tan pronto como podamos comunicarnos con el anfitrión.", + }, + archived: { + callout: "Este agente está archivado.", + unarchive: "Desarchivar", + }, + }, + sessions: { + title: "Sesiones", + empty: "Aún no hay sesiones", + actions: { + loadMore: "Cargar más", + }, + }, + agentList: { + fallbackTitle: "Nueva sesión", + dateSections: { + recent: "Reciente", + today: "Hoy", + yesterday: "Ayer", + thisWeek: "Esta semana", + thisMonth: "este mes", + older: "Más viejo", + }, + status: { + initializing: "A partir de", + idle: "Inactivo", + running: "Correr", + error: "Error", + closed: "Cerrado", + }, + badges: { + archived: "Archivado", + pending: "{{count}}pendiente", + attention: "Atención", + }, + archiveSheet: { + hostOffline: "Hostfuera de línea", + runningAgent: "Este agente todavía está ejecutándose. Archivarlo detendrá al agente.", + archive: "Archivo", + }, + }, + message: { + actions: { + copyCode: "Copiar código", + copyTurn: "Copiar turno", + copyMessage: "Copiar mensaje", + openFile: "Abrir archivo", + copied: "Copiado", + }, + attachments: { + dismissImage: "Descartar imagen", + closeImage: "Cerrar imagen", + imageLoadFailed: "No se pudo cargar la imagen", + imageUnavailable: "Imagen no disponible", + imagePreviewUnavailable: "Vista previa de la imagen no disponible.", + imagePreviewLoadFailed: "No se puede cargar la vista previa de la imagen.", + reviewOne: "Reseña · 1 comentario", + reviewMany: "Revisión · Comentarios{{count}}", + textAttachment: "Adjunto de texto", + }, + speak: { + header: "Habló", + }, + activity: { + details: "Detalles", + }, + dictation: { + start: "Iniciar dictado de voz", + cancel: "Cancelar dictado", + retry: "Reintentar el dictado", + insert: "Insertar transcripción", + insertAndSend: "Insertar transcripción y enviar", + failed: "Fallo en el dictado:{{error}}", + failedRetry: "El dictado falló. Toca reintentar.", + }, + question: { + submit: "Entregar", + next: "Siguiente", + answerPlaceholder: "Escribe tu respuesta...", + otherPlaceholder: "Otro...", + }, + todo: { + title: "Tareas", + empty: "Aún no hay tareas.", + }, + compaction: { + loading: "Compactando...", + auto: "Contexto compactado automáticamente", + manual: "Contexto compactado manualmente", + withTokens: "Contexto compactado (tokens{{tokens}}K)", + completed: "Contexto compactado", + }, + }, + importSession: { + title: "Importar sesión", + filters: { + all: "Todo", + }, + status: { + connectHost: "Conéctese a un host para importar sesiones", + updateHost: "Actualice el host para importar sesiones.", + noProviders: "No hay proveedores importables habilitados.", + loading: "Cargando sesiones recientes...", + failedAll: "No se pudieron cargar las sesiones recientes.", + failedProviders: "No se pudieron cargar sesiones para{{providers}}.", + failedImport: "No se pudo importar la sesión seleccionada.", + }, + actions: { + refresh: "Actualizar sesiones", + }, + preview: { + untitledSession: "Sesión sin título", + noPrompt: "Sin vista previa inmediata", + }, + empty: { + noRecent: "No hay sesiones recientes para importar.", + alreadyImported: "Todas las sesiones recientes ya están importadas.", + noProviderSessions: "No se encontraron sesiones{{provider}}.", + }, + row: { + importing: "Importador...", + }, + }, + workspace: { + route: { + loading: "Cargando espacio de trabajo", + connecting: "Conectando", + hostOffline: "{{hostName}}está desconectado", + cannotReachHost: "No se puede alcanzar{{hostName}}", + hostStatus: "Estado deHost:{{status}}", + missing: "Workspaceno encontrado", + manageHost: "Administrar host", + }, + hoverCard: { + scriptsAccessibility: "GuionesWorkspace", + }, + fileExplorer: { + sort: { + name: "Nombre", + modified: "Modificado", + size: "Tamaño", + }, + context: { + size: "Tamaño", + modified: "Modificado", + copyPath: "Copiar ruta", + download: "Descargar", + }, + actions: { + back: "Atrás", + retry: "Rever", + refresh: "Actualizar archivos", + refreshing: "Actualizar archivos", + }, + empty: { + noFiles: "Sin archivos", + }, + states: { + unavailable: "Workspaceno está disponible", + loading: "Cargando archivos...", + }, + errors: { + failedToListDirectory: "No se pudo listar el directorio", + }, + }, + setup: { + descriptor: { + label: "Configuración", + completed: "Configuración completada", + failed: "Error de configuración", + workspace: "Configuración deWorkspace", + }, + status: { + running: "Correr", + completed: "Terminado", + failed: "Fallido", + waiting: "Esperando el resultado de la configuración", + }, + waiting: "Configurando el espacio de trabajo...", + empty: { + noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.", + }, + accessibility: { + noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo", + log: "Registro de configuración deWorkspace", + }, + log: { + noOutput: "Sin salida", + }, + }, + browser: { + unavailable: { + title: "El navegador es solo para escritorio", + subtitle: "Abra este espacio de trabajo en Electron para usar el navegador integrado.", + }, + session: "Sesión de navegador{{browserId}}", + controls: { + back: "Atrás", + forward: "Adelante", + stopLoading: "dejar de cargar", + refresh: "Refrescar", + browserUrl: "NavegadorURL", + enterUrl: "IngreseURL", + openDevTools: "Abrir herramientas de desarrollo del navegador", + cancelSelector: "Cancelar selector de elementos", + selectElement: "Seleccionar elemento", + }, + errors: { + failedToLoad: "No se pudo cargar la página", + invalidUrl: "Navegador no válidoURL", + unsupportedProtocol: "Navegador bloqueado no compatibleURL:{{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Hostno está conectado", + unableToSubscribe: "No se puede suscribir al terminal", + }, + tabs: { + loading: "Cargando...", + loadingAgentTitle: "Título del agente de carga", + emptyPane: "No hay pestañas en este panel.", + fallback: { + newAgent: "NuevoAgent", + setup: "Configuración", + workspaceSetup: "Configuración deWorkspace", + terminal: "Terminal", + browser: "Navegador", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "Cambiar pestañas ({{count}}abierto)", + title: "Cambiar pestaña", + searchPlaceholder: "Pestañas de búsqueda", + }, + menu: { + openFor: "Menú abierto para{{label}}", + copyResumeCommand: "Copiar comando de reanudación", + copyAgentId: "Copiar ID del agente", + rename: "Rebautizar", + closeAbove: "Cerrar pestañas arriba", + closeBelow: "Cerrar pestañas a continuación", + closeLeft: "Cerca de la izquierda", + closeRight: "Cerca de la derecha", + closeOthers: "Cerrar otras pestañas", + reloadAgent: "Recargar agente", + reloadAgentTooltip: + "Vuelva a cargar el agente para actualizar habilidades, MCP o estado de inicio de sesión.", + close: "Cerca", + renameTerminal: "Cambiar nombre de terminal", + renameAgent: "Cambiar nombre del agente", + }, + actions: { + newAgent: "Nueva pestaña de agente", + newTerminal: "Nueva pestaña de terminal", + preparingTerminal: "Preparando la pestaña del terminal", + preparingTerminalTooltip: "Preparando terminal...", + newBrowser: "Nueva pestaña del navegador", + splitRight: "Panel dividido a la derecha", + splitDown: "Dividir panel hacia abajo", + }, + explorer: { + open: "Explorador abierto", + close: "Cerrar explorador", + toggle: "Alternar explorador", + changes: "Cambios", + files: "Archivos", + }, + toasts: { + copyFailed: "Copia fallida", + agentIdCopiedLabel: "AgentID", + resumeCommandCopiedLabel: "reanudar el comando", + resumeIdUnavailable: "ReanudarIDno disponible", + resumeCommandUnavailable: "Comando de reanudación no disponible", + reloadingAgent: "Agente de recarga...", + reloadedAgent: "Agente recargado", + failedToReloadAgent: "No se pudo recargar el agente", + }, + confirmations: { + close: "Cerca", + cancel: "Cancelar", + archive: "Archivo", + closeTerminalTitle: "¿Cerrar terminal?", + closeTerminalMessage: + "Cualquier proceso en ejecución en esta terminal se detendrá inmediatamente.", + archiveRunningAgentTitle: "¿Agente de ejecución de archivos?", + archiveRunningAgentMessage: + "Este agente todavía está ejecutándose. Archivarlo detendrá al agente y cerrará la pestaña.", + closeTabsLeftTitle: "¿Cerrar pestañas a la izquierda?", + closeTabsRightTitle: "¿Cerrar pestañas a la derecha?", + closeOtherTabsTitle: "¿Cerrar otras pestañas?", + bulk: { + all: "Esto archivará los agentes{{agents}}, cerrará los terminales{{terminals}}y cerrará las pestañas{{tabs}}. Cualquier proceso en ejecución en una terminal cerrada se detendrá inmediatamente.", + agentsAndTerminals: + "Esto archivará los agentes{{agents}}y cerrará los terminales{{terminals}}. Cualquier proceso en ejecución en una terminal cerrada se detendrá inmediatamente.", + terminalsAndTabs: + "Esto cerrará los terminales{{terminals}}y cerrará las pestañas{{tabs}}. Cualquier proceso en ejecución en una terminal cerrada se detendrá inmediatamente.", + agentsAndTabs: "Esto archivará los agentes{{agents}}y cerrará las pestañas{{tabs}}.", + terminals: + "Esto cerrará los terminales{{terminals}}. Cualquier proceso en ejecución en una terminal cerrada se detendrá inmediatamente.", + tabs: "Esto cerrará las pestañas{{tabs}}.", + agents: "Esto archivará los agentes{{agents}}.", + }, + }, + }, + header: { + actions: { + workspaceActions: "AccionesWorkspace", + newAgent: "Nuevo agente", + newTerminal: "Nueva terminal", + newBrowser: "Nueva pestaña del navegador", + importSession: "Importar sesión", + copyPath: "Copiar ruta del espacio de trabajo", + copyBranchName: "Copiar nombre de sucursal", + showSetup: "Mostrar configuración", + }, + toasts: { + workspacePathUnavailable: "La rutaWorkspaceaún no está disponible", + branchNameUnavailable: "Nombre de la sucursal no disponible", + terminalQueued: + "Preparando el espacio de trabajo, abriendo la terminal cuando esté listo...", + workspacePathCopiedLabel: "RutaWorkspace", + branchNameCopiedLabel: "Nombre de la sucursal", + }, + }, + scripts: { + title: "Guiones", + actions: { + run: "Correr", + view: "Vista", + }, + accessibility: { + trigger: "GuionesWorkspace", + openAt: "Abrir{{scriptName}}en{{label}}", + viewTerminal: "Ver terminal{{scriptName}}", + runScript: "Ejecute el script{{scriptName}}", + script: "Guión{{scriptName}}", + }, + states: { + exitCode: "salir de{{code}}", + startFailed: "No se pudo iniciar{{scriptName}}", + }, + }, + git: { + actions: { + moreOptions: "Más opciones", + moreActions: "Más acciones", + commit: { + label: "Comprometerse", + pending: "Comprometiéndose...", + success: "Comprometido", + }, + pull: { + label: "Jalar", + pending: "Tracción...", + success: "tirado", + }, + push: { + label: "Empujar", + pending: "Emprendedor...", + success: "Empujado", + }, + pullAndPush: { + label: "Tirar y empujar", + pending: "Tirando y empujando...", + success: "Tirado y empujado", + }, + viewPr: "VerPR", + createPr: { + label: "CrearPR", + pending: "CreandoPR...", + success: "PRcreado", + }, + mergeBranch: { + label: "Fusionar localmente", + pending: "Fusionando...", + success: "Fusionado", + }, + mergeFromBase: { + label: "Actualización desde{{baseRef}}", + pending: "Actualizando...", + success: "Actualizado", + }, + archive: { + label: "Árbol de trabajo de archivo", + pending: "Archivando...", + success: "Archivado", + }, + mergePr: { + squash: "Aplastar y fusionar", + merge: "Crear una confirmación de fusión", + rebase: "Rebase y fusionar", + pending: "FusionandoPR...", + success: "PRfusionado", + }, + autoMerge: { + enableSquash: "Habilitar la fusión automática con squash", + enableMerge: "Habilitar la fusión automática con confirmación de fusión", + enableRebase: "Habilitar la fusión automática con rebase", + enabled: "Combinación automática habilitada", + enabling: "Habilitando la fusión automática...", + disabling: "Desactivando la fusión automática...", + disabled: "Fusión automática deshabilitada", + }, + unavailable: { + viewPrNoGithub: "VerPRno está disponible en este momento porqueGitHubno está conectado", + pullNoRemote: + "Pull no está disponible aquí porque esta rama aún no está conectada a un control remoto", + pullDirty: + "La extracción no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero", + pullUpToDate: "La extracción no está disponible porque esta rama ya está actualizada", + pushNoRemote: + "Push no está disponible aquí porque esta rama aún no está conectada a un control remoto", + pushBehind: + "Push aún no está disponible porque hay cambios más nuevos que implementar primero", + pushNothing: "Push no está disponible porque no hay nada nuevo que enviar", + pullAndPushNoRemote: + "Tirar y empujar no está disponible aquí porque esta rama aún no está conectada a un control remoto", + pullAndPushDirty: + "Tirar y empujar no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero", + pullAndPushInSync: + "Pull and push no está disponible porque esta rama ya está sincronizada", + createPrNoGithub: + "CrearPRno está disponible en este momento porqueGitHubno está conectado", + createPrNoCommits: + "CrearPRno está disponible porque esta rama aún no tiene nuevas confirmaciones", + mergeNoBase: + "La combinación no está disponible porque no pudimos determinar la rama base", + mergeDirty: + "La combinación no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero", + mergeNothing: + "La combinación no está disponible porque esta rama aún no tiene nada nuevo que fusionar", + updateNoBase: + "La actualización no está disponible porque no pudimos determinar la rama base", + updateDirty: + "La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero", + updateCurrent: + "La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}", + archiveNotWorktree: + "El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo", + mergePrNoGithub: + "FusionarPRno está disponible en este momento porqueGitHubno está conectado", + mergePrMissing: + "FusionarPRno está disponible porque aún no hay una solicitud de extracción", + mergePrDraft: + "FusionarPRno está disponible porque la solicitud de extracción aún es un borrador", + mergePrMerged: + "FusionarPRno está disponible porque la solicitud de extracción ya está fusionada", + mergePrClosed: + "FusionarPRno está disponible porque la solicitud de extracción está cerrada", + mergePrConflicts: + "FusionarPRno está disponible porque la solicitud de extracción tiene conflictos", + mergePrQueue: + "FusionarPRno está disponible aquí porque este repositorio utiliza una cola de fusión", + mergePrNotReady: + "FusionarPRno está disponible hasta queGitHubinforme que la solicitud de extracción está lista para fusionarse", + autoMergeCannotDisable: + "La combinación automática está habilitada, pero esta cuenta no puede deshabilitarla", + }, + toasts: { + failedCommit: "No se pudo comprometer", + failedPull: "No se pudo tirar", + failedPush: "No se pudo empujar", + failedPullAndPush: "No se pudo tirar y empujar", + failedCreatePr: "No se pudo crearPR", + failedMergePr: "No se pudo fusionarPR", + failedEnableAutoMerge: "No se pudo habilitar la combinación automática", + failedDisableAutoMerge: "No se pudo deshabilitar la combinación automática", + baseRefUnavailable: "Referencia base no disponible", + failedMerge: "No se pudo fusionar", + failedMergeFromBase: "No se pudo fusionar desde la base", + worktreePathUnavailable: "Ruta del árbol de trabajo no disponible", + failedArchive: "No se pudo archivar el árbol de trabajo", + }, + archiveWarning: { + title: '¿Archivo "{{worktreeName}}"?', + confirm: "Archivo", + cancel: "Cancelar", + uncommittedChanges: "Cambios no confirmados", + uncommittedChangesWithDiff: "Cambios no confirmados ({{diffStat}})", + addedLine: "Línea añadida{{count}}", + addedLines: "{{count}}agregó líneas", + deletedLine: "Línea eliminada de{{count}}", + deletedLines: "{{count}}líneas eliminadas", + unpushedCommit: "Confirmación no enviada de{{count}}", + unpushedCommits: "Confirmaciones no enviadas de{{count}}", + }, + }, + diff: { + binaryFile: "archivo binario", + tooLarge: "La diferencia es demasiado grande para mostrarse", + unified: "Diferencia unificada", + split: "Diferencia de lado a lado", + hideWhitespace: "Ocultar espacios en blanco", + scrollLongLines: "Desplazarse por largas filas", + wrapLongLines: "Envolver largas filas", + collapseAll: "Contraer todos los archivos", + expandAll: "Expandir todos los archivos", + refreshing: "Refrescante", + refresh: "Refrescar", + refreshState: "Actualizar el estado de git yGitHub", + failedRefresh: "No se pudo actualizar el estado de git.", + emptyHiddenWhitespace: "No hay cambios visibles después de ocultar espacios en blanco", + emptyUncommitted: "Sin cambios no confirmados", + emptyAgainstBase: "Sin cambios frente a{{baseRef}}", + checkingRepository: "Comprobando repositorio...", + notRepository: "No es un repositorio de git", + diffMode: "modo diferencial", + uncommitted: "No comprometido", + committed: "Comprometido", + branchUnknown: "Desconocido", + base: "base", + newFile: "Nuevo", + deletedFile: "Eliminado", + }, + openInEditor: { + open: "Abierto", + chooseEditor: "Elige editor", + openIn: "Abrir espacio de trabajo en{{target}}", + openFileIn: "Open {{fileName}} in {{target}}", + failedOpen: "No se pudo abrir el espacio de trabajo", + }, + pr: { + sections: { + checks: "cheques", + reviews: "Reseñas", + }, + accessibility: { + pullRequest: "Solicitud de extracción n.°{{number}}", + }, + states: { + draft: "Borrador", + merged: "Fusionado", + closed: "Cerrado", + open: "Abierto", + }, + activity: { + commented: "Comentado", + approved: "Aprobado", + requestedChanges: "Cambios solicitados", + reviewed: "Revisado", + }, + time: { + justNow: "En este momento", + }, + errors: { + statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción", + activityLoadFailed: "No se puede cargar la actividad de solicitud de extracción", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "Sin anfitrión", + switchTitle: "Cambiar de anfitrión", + searchPlaceholder: "Buscar hosts...", + }, + actions: { + addProject: "Agregar proyecto", + home: "Hogar", + settings: "Ajustes", + closeSidebar: "Cerrar barra lateral", + }, + sections: { + sessions: "Sesiones", + }, + worktreeSetup: { + title: "Configurar secuencias de comandos del árbol de trabajo", + description: + "Agregue comandos de configuración para que los nuevos árboles de trabajo puedan instalar dependencias y prepararse automáticamente.", + openProjectSettings: "Abrir la configuración del proyecto", + }, + project: { + actions: { + menu: "Acciones del proyecto", + openSettings: "Abrir la configuración del proyecto", + openNewWindow: "Open in new window", + openNewWindowFailed: "Couldn't open a new window", + remove: "Eliminar proyecto", + removing: "Eliminando...", + }, + confirmations: { + removeTitle: "¿Quitar proyecto?", + removeMessage: + '¿Quitar "{{projectName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.', + removeConfirm: "Eliminar", + cancel: "Cancelar", + }, + toasts: { + hostDisconnected: "Hostno está conectado", + removeFailed: "No se pudieron eliminar algunos espacios de trabajo", + }, + empty: { + title: "Aún no hay proyectos", + description: "Añade un proyecto para empezar", + }, + }, + workspace: { + status: { + scriptsAvailable: "Guiones disponibles", + creating: "Creando...", + }, + actions: { + menu: "AccionesWorkspace", + newWorkspace: "Nuevo espacio de trabajo", + createWorkspaceFor: "Crea un nuevo espacio de trabajo para{{projectName}}", + copyPath: "Copiar ruta", + copyBranchName: "Copiar nombre de sucursal", + rename: "Cambiar nombre del espacio de trabajo", + archive: "Archivo", + archiveWorktree: "Árbol de trabajo de archivo", + hideFromSidebar: "Ocultar de la barra lateral", + archiving: "Archivando...", + hiding: "Ocultación...", + }, + confirmations: { + hideTitle: "¿Ocultar espacio de trabajo?", + hideMessage: + '¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.', + hideConfirm: "Esconder", + cancel: "Cancelar", + }, + rename: { + title: "Cambiar nombre del espacio de trabajo", + submit: "Rebautizar", + invalidBranchName: "Nombre de sucursal no válido", + }, + toasts: { + workspacePathUnavailable: "RutaWorkspaceno disponible", + pathCopied: "Ruta copiada", + branchNameCopied: "Nombre de la sucursal copiado", + hostDisconnected: "Hostno está conectado", + hideFailed: "No se pudo ocultar el espacio de trabajo", + archiveFailed: "No se pudo archivar el árbol de trabajo", + }, + }, + }, + newWorkspace: { + title: "Nuevo espacio de trabajo", + create: "Crear", + errors: { + hostDisconnected: "Hostno está conectado", + createWorktreeFailed: "No se pudo crear el árbol de trabajo", + composerStateRequired: "Se requiere el estado del compositor", + selectModel: "Selecciona un modelo", + }, + refPicker: { + startingRef: "Árbitro inicial", + chooseStart: "Elige por dónde empezar", + checkoutHint: "¿MiraPR#{{number}}?", + checkoutPr: "Echa un vistazo aPR#{{number}}", + dismissCheckoutHint: "Descartar la sugerencia de pago dePR#{{number}}", + intoBase: "en{{baseRef}}", + searching: "Búsqueda...", + noMatchingRefs: "No hay árbitros coincidentes.", + searchPlaceholder: "Buscar sucursales y relaciones públicas", + title: "Empezar desde", + }, + }, + desktop: { + quitting: { + title: "Saliendo dePaseo...", + detail: "Deteniendo el demonio local.", + }, + daemon: { + title: "Daemon", + status: { + title: "Estado", + builtInOnly: "Aquí solo se muestra el demonio de escritorio integrado.", + running: "correr", + notRunning: "no corriendo", + pid: "PID{{pid}}", + }, + management: { + title: "Administrar demonio incorporado", + hint: "Deje quePaseoinicie y detenga el demonio incorporado", + pauseTitle: "Pausar el demonio incorporado", + pauseMessage: + "Esto detendrá el demonio incorporado inmediatamente. Se detendrán los agentes en ejecución y los terminales conectados al demonio integrado.", + pauseAndStop: "Pausa y para", + registrationFailed: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + pausedStopFailed: + "La gestión del demonio integrado se pausó, peroPaseono pudo detener el demonio.", + updateFailed: "No se puede actualizar la gestión de demonios integrada.", + }, + keepRunning: { + title: "Mantener el demonio en ejecución después de salir", + hint: "Daemonsigue ejecutándose cuando sales dePaseo", + }, + logs: { + title: "Archivo de registro", + modalTitle: "RegistrosDaemon", + unavailable: "Ruta de registro no disponible", + empty: "(el archivo de registro está vacío)", + copied: "Ruta de registro copiada.", + copyFailed: "No se puede copiar la ruta del registro.", + open: "Abrir registros", + copyPath: "Copiar ruta", + }, + fullStatus: { + title: "Estado completo", + modalTitle: "EstadoDaemon", + hint: "Ejecuta`paseo daemon status`y muestra la salida.", + view: "Ver estado", + copied: "Estado copiado al portapapeles.", + fetchFailed: "No se pudo recuperar el estado del demonio:{{message}}", + }, + advancedSettings: "Configuraciones avanzadas", + openAdvancedSettings: "Abrir configuración avanzada del demonio", + versionMismatch: + "Las versiones de la aplicación y del demonio no coinciden. Actualice ambos a la misma versión para obtener la mejor experiencia.", + loadFailed: "No se puede cargar el estado del demonio del escritorio.", + }, + updates: { + status: { + checking: "Buscando actualizaciones de la aplicación...", + installing: "Instalando actualización de la aplicación...", + upToDate: "La aplicación está actualizada.", + upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", + pending: "Le avisaremos cuando la actualización esté lista.", + availableWithVersion: "Actualización lista:{{version}}", + available: "Una actualización de la aplicación está lista para instalarse.", + installed: "Actualización de la aplicación instalada. Se requiere reinicio.", + failed: "No se pudo actualizar la aplicación.", + idle: "El estado de la actualización aún no se ha comprobado.", + }, + installError: "No se puede instalar la actualización de la aplicación de escritorio.", + callout: { + installingTitle: "Instalando actualización", + failedTitle: "La actualización falló", + availableTitle: "Actualización disponible", + genericError: "Algo salió mal.", + whatsNew: "Qué hay de nuevo", + installingAction: "Instalando...", + installAndRestart: "Instalar y reiniciar", + installingDescription: "Instalando y reiniciando...", + versionReady: "{{version}}está listo para instalar.", + newVersionReady: "Una nueva versión está lista para instalar.", + restartWarning: + "La actualización de la aplicación dejará de ejecutar agentes y cerrará sesiones de terminal.", + }, + }, + settings: { + loadFailed: "No se puede cargar la configuración del escritorio.", + saveFailed: "No se puede guardar la configuración del escritorio.", + }, + rosetta: { + title: "Descargue la compilaciónApple Silicon", + runningIntel: "Estás ejecutando la compilaciónInteldePaseoenRosettaenApple Silicon.", + highCpu: + "Esto provoca un uso elevado de la CPU. Descargue la compilaciónApple Siliconpara solucionarlo.", + download: "Descargar", + }, + permissions: { + notifications: { + allowed: "El sistema operativo permite las notificaciones.", + denied: "Las notificaciones se niegan en la configuración del sistema.", + notGranted: "Las notificaciones aún no han sido concedidas.", + webOnly: + "El estado de notificación de escritorio solo está disponible en tiempo de ejecución web.", + supported: "Se admiten notificaciones de escritorio.", + unsupported: "Las notificaciones de escritorio no son compatibles con esta plataforma.", + apiUnavailable: "La API de notificación web no está disponible en este entorno.", + requestsWebOnly: + "Las solicitudes de notificación de escritorio solo están disponibles en tiempo de ejecución web.", + requestUnavailable: "RequestPermission() de la API de notificación web no está disponible.", + requestFailed: "No se pudo solicitar permiso de notificación:{{message}}", + unexpectedState: "Estado de permiso de notificación inesperado:{{state}}", + }, + microphone: { + webOnly: + "El estado del micrófono de escritorio solo está disponible en tiempo de ejecución web.", + navigatorUnavailable: "Navigator no está disponible en este entorno.", + granted: "Se concede acceso al micrófono.", + denied: "El acceso al micrófono está denegado en la configuración del sistema.", + notGranted: "Aún no se ha concedido el permiso para el micrófono.", + unexpectedState: "Estado de permiso de micrófono inesperado:{{state}}", + statusApiUnavailable: + "La API de estado del micrófono no está disponible en este tiempo de ejecución. Utilice Solicitud para comprobar el acceso.", + queryFailed: "No se pudo consultar el estado del micrófono:{{message}}", + captureUnavailable: "La captura de micrófono no está disponible en este entorno.", + permissionApiUnavailable: + "La API de estado de permiso no está disponible. Utilice Solicitud para comprobar el acceso.", + requestsWebOnly: + "Las solicitudes de micrófono de escritorio solo están disponibles en tiempo de ejecución web.", + captureApiUnavailable: "La API de captura de micrófono no está disponible en este entorno.", + requestDenied: "El usuario o el sistema denegaron el permiso del micrófono.", + noDevice: "No se encontró ningún dispositivo de micrófono.", + requestFailed: "No se pudo solicitar el permiso del micrófono:{{message}}", + }, + empty: { + notifications: "El estado de la notificación aún no se ha verificado.", + microphone: "El estado del micrófono aún no se ha comprobado.", + }, + testNotification: { + title: "Prueba de notificaciónPaseo", + body: "Si puede ver esto, las notificaciones de escritorio funcionan.", + notDelivered: + "La notificación no fue entregada. Verifique Configuración del sistema> Notificaciones.", + failed: "No se pudo enviar la notificación.", + }, + }, + integrations: { + cli: { + statusFailed: "No se puede verificar el estado de instalación deCLI.", + installFailed: "No se puede instalar elPaseoCLI.", + }, + skills: { + statusFailed: "No se puede comprobar el estado de las habilidades de orquestación.", + installFailed: "No se pueden instalar habilidades de orquestación.", + updateFailed: "No se pueden actualizar las habilidades de orquestación.", + uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.", + }, + }, + }, + startup: { + errorTitle: "algo salió mal", + errorDescription: + "El servidor local no pudo iniciarse. Si esto continúa sucediendo, informe el problema enGitHube incluya los registros a continuación.", + logs: { + loading: "Cargando registros de demonio...", + unavailable: "No hay registros de demonios disponibles.", + loadFailed: "No se pueden cargar registros de demonio:{{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "Agregar un proyecto", + description: "Abra una carpeta en su máquina", + }, + importSession: { + title: "Importar sesión", + description: "Incorporar sesionesCLIexternas recientes", + }, + setupProviders: { + title: "Proveedores de configuración", + description: "ConfigurarClaude Code,Codexy más", + }, + pairDevice: { + title: "Emparejar dispositivo", + description: "Conecta tu teléfono a este demonio", + }, + }, + }, + projectPicker: { + placeholder: "Escriba una ruta de directorio...", + opening: "Proyecto de apertura...", + empty: "Comience a escribir una ruta", + }, + branchSwitcher: { + currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", + placeholder: "Cambiar de rama...", + searchPlaceholder: "Filtrar ramas...", + empty: "No se encontraron sucursales.", + title: "Cambiar rama", + uncommittedTitle: "Cambios no confirmados", + uncommittedMessage: "Tienes cambios no confirmados. ¿Guardarlos antes de cambiar de sucursal?", + stashAndSwitch: "Guardar y cambiar", + failedToStash: "No se pudieron ocultar los cambios", + failedToSwitch: "No se pudo cambiar de sucursal", + restoreStashTitle: "¿Restaurar cambios ocultos?", + restoreStashMessage: + "Esta rama ha ocultado cambios de una sesión anterior. ¿Quieres restaurarlos?", + restore: "Restaurar", + later: "Más tarde", + stashRestored: "Se restauraron los cambios ocultos", + }, + agentAutocomplete: { + searchingWorkspace: "Buscando espacio de trabajo...", + loadingCommands: "Cargando comandos...", + noFiles: "No se encontraron archivos ni directorios", + noCommands: "No se encontraron comandos", + failedToLoad: "No se pudo cargar", + }, + loadOlderHistory: { + failed: "No se pudo cargar el historial anterior", + }, + imageAttachmentPicker: { + permissionTitle: "Permiso requerido", + permissionMessage: "Permita el acceso a su biblioteca de fotos para adjuntar imágenes.", + errorTitle: "Error", + failedToSelect: "No se pudo seleccionar la imagen", + dialogTitle: "Adjuntar imágenes", + dialogFilterName: "Imágenes", + }, + workspaceSetup: { + title: "Crear espacio de trabajo", + errors: { + failedCreateWorktree: "No se pudo crear el árbol de trabajo", + failedOpenProject: "No se pudo abrir el proyecto", + selectModel: "Selecciona un modelo", + hostDisconnected: "Hostno está conectado", + pendingRequired: "No hay ninguna configuración de espacio de trabajo pendiente", + composerStateRequired: "Se requiere el estado del compositor de configuraciónWorkspace", + }, + }, + onboarding: { + title: "Bienvenido aPaseo", + subtitle: "Conecte su computadora para comenzar", + actions: { + settings: "Ajustes", + }, + }, + modelSelector: { + title: "Seleccionar proveedor", + selectModel: "Seleccionar modelo", + selectedModel: "Seleccionar modelo ({{model}})", + loading: "Cargando...", + loadingShort: "Cargando", + loadingSelector: "Cargando selector de modelo...", + error: "Error", + defaultModel: "Por defecto", + favorites: "Favoritos", + favoriteModel: "modelo favorito", + unfavoriteModel: "Modelo no favorito", + modelCount: "modelo{{count}}", + modelCountPlural: "Modelos{{count}}", + retry: "Rever", + retrying: "Reintentando...", + noMatches: "Ningún modelo coincide con tu búsqueda", + searchPlaceholder: "Buscar modelos...", + openProviderSettings: "Abrir configuración de{{provider}}", + }, + providerCatalog: { + title: "Agregar proveedor", + search: "Proveedores de búsqueda", + noProviders: "No se encontraron proveedores", + actions: { + add: "Agregar", + adding: "Añadiendo", + installed: "Instalado", + cancel: "Cancelar", + installInstructions: "Instrucciones de instalación", + installInstructionsFor: "Instrucciones de instalación de{{provider}}", + }, + errors: { + unableToInstall: "No se puede instalar el proveedor", + }, + }, + providerSelection: { + defaultModel: "Por defecto", + selectModel: "Seleccionar modelo", + loading: "Cargando...", + error: "Error", + unavailable: "Indisponible", + unknownError: "Error desconocido", + readiness: { + initialPromptRequired: "Se requiere aviso inicial", + noProviders: "No hay proveedores disponibles en el host seleccionado", + modelDefaultsLoading: "Los valores predeterminados del modelo aún se están cargando", + noModelAvailable: "No hay ningún modelo disponible para el proveedor seleccionado", + workspaceDirectoryNotFound: "DirectorioWorkspaceno encontrado", + hostDisconnected: "Hostno está conectado", + }, + }, + pairing: { + connectionMethods: { + title: "Agregar conexión", + direct: { + title: "Conexión directa", + description: "Red local o VPN.", + }, + scanQr: { + title: "Escanea el códigoQR", + description: "Conexión de retransmisión cifrada.", + }, + pasteLink: { + title: "Pegar enlace de emparejamiento", + description: "Conexión de retransmisión cifrada.", + }, + }, + direct: { + title: "Conexión directa", + helper: "Ingrese la dirección de un servidorPaseo.", + fields: { + host: "Host", + port: "Puerto", + password: "Contraseña", + optional: "Opcional", + useSsl: "Usar SSL", + connectionUri: "URI de conexión", + }, + advanced: { + label: "Avanzado", + show: "Mostrar avanzado", + hide: "Ocultar avanzado", + }, + passwordVisibility: { + show: "Mostrar contraseña", + hide: "Ocultar contraseña", + }, + actions: { + cancel: "Cancelar", + connect: "Conectar", + connecting: "Conectando...", + }, + errors: { + hostRequired: "Se requiereHost", + invalidPort: "El puerto debe estar entre 1 y 65535", + invalidConnection: "Conexión no válida", + failedTitle: "La conexión falló", + failedToConnect: "No pudimos conectarnos a{{endpoint}}.", + noAdditionalDetails: "{{detail}}(no se proporcionan detalles adicionales)", + timedOut: "Se agotó el tiempo de conexión. Verifique el host/porty su red.", + refused: "Conexión rechazada. ¿El servidor se está ejecutando en esta dirección?", + hostNotFound: "Hostno encontrado. Verifique el nombre de host e inténtelo nuevamente.", + hostUnreachable: "Hostes inalcanzable. Verifique su red y firewall.", + tlsError: + "Error de TLS. Las conexiones directas utilizan SSL solo cuando hay un terminador TLS delante del demonio.", + unableToConnect: + "No se puede conectar. Verifique el host/porty que se pueda acceder al demonio.", + details: "Detalles:{{detail}}", + }, + }, + link: { + title: "Pegar enlace de emparejamiento", + helper: "Pegue el enlace de emparejamiento de su servidor.", + label: "Enlace de emparejamiento", + errors: { + required: "Pegue un enlace de emparejamiento (.../#offer=...)", + missingOffer: "El enlace debe incluir#offer=...", + emptyOffer: "La carga útil de la oferta está vacía", + invalid: "Enlace de emparejamiento no válido", + unableToPair: "No se puede emparejar el host", + }, + alert: { + failedTitle: "El emparejamiento falló", + }, + actions: { + cancel: "Cancelar", + pair: "Par", + pairing: "Emparejamiento...", + }, + }, + scan: { + title: "EscanearQR", + webUnavailableTitle: "No disponible en la web", + webUnavailableBody: + 'El escaneoQRno es compatible con la compilación web. Utilice "Pegar enlace" en su lugar.', + backToSettings: "Volver a configuración", + cameraPermissionTitle: "Permiso de cámara", + cameraPermissionBody: + "Permita el acceso a la cámara para escanear el códigoQRde emparejamiento de su demonio.", + grantPermission: "Conceder permiso", + pairing: "Emparejamiento...", + unableToPair: "No se puede emparejar el host", + errorTitle: "Error", + }, + device: { + loadingOffer: "Cargando oferta de maridaje...", + failedToLoadOffer: "No se pudo cargar la oferta de emparejamiento.", + relayDisabled: "El relé no está habilitado. Habilite el relé para emparejar un dispositivo.", + unavailable: "Oferta de maridaje no disponible.", + hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.", + qrUnavailable: "CódigoQRno disponible.", + retry: "Rever", + copy: "Copiar", + copied: "Copiado", + }, + }, + realtimeVoice: { + actions: { + mute: "Silenciar voz en tiempo real", + unmute: "Activar voz en tiempo real", + stop: "Detener la voz en tiempo real e interrumpir el turno.", + }, + }, + rewind: { + tooltip: "Rebobinar a este mensaje", + warning: "Esta acción no se puede deshacer.", + actions: { + conversation: "Rebobinar conversación", + files: "Rebobinar archivos", + both: "Rebobinar conversaciones y archivos", + }, + errors: { + failed: "No se pudo rebobinar el agente", + }, + }, + diffViewer: { + empty: "No hay cambios para mostrar", + }, + serviceUrl: { + title: "Servicio abiertoURL", + message: "¿Abrir{{url}}?", + inPaseo: "EnPaseo", + externalBrowser: "Navegador externo", + dontAskAgain: "no vuelvas a preguntar", + }, + downloads: { + requestTokenFailed: "No se pudo solicitar el token de descarga.", + hostUnavailable: "El host de descarga no está disponible.", + cancelled: "La descarga fue cancelada.", + failed: "No se pudo descargar el archivo.", + shareFile: "compartir archivo", + shareFileNamed: "Compartir{{fileName}}", + }, + menu: { + backdrop: "Fondo del menú", + }, + subagents: { + archiveAction: "Archivo{{label}}", + archiveTooltip: "Subagente de archivo", + }, + panels: { + draft: { + newAgent: "NuevoAgent", + creatingAgent: "Agente creador", + }, + file: { + executionDirectoryMissing: "No se encontró el directorio de ejecución deWorkspace.", + loading: "Cargando archivo...", + noPreview: "No hay vista previa disponible", + binaryPreviewUnavailable: "Vista previa binaria no disponible", + failedToLoad: "No se pudo cargar el archivo", + failedToLoadPreview: "No se pudo cargar la vista previa del archivo", + }, + }, + toolCallDetails: { + error: "Error", + empty: "No hay detalles adicionales disponibles", + subAgentActivity: "Actividad de subagente", + input: "Aporte", + output: "Producción", + }, + renameModal: { + rename: "Rebautizar", + saving: "Ahorro...", + }, + sidebarCallout: { + dismiss: "Despedir", + }, + contextWindow: { + title: "ventana contextual", + used: "{{percentage}}% utilizado", + tokens: "Fichas{{used}}/{{max}}", + sessionCost: "Costo de la sesión{{cost}}", + accessibility: "Ventana de contexto{{percentage}}% utilizada", + }, + review: { + comment: { + add: "Agregar comentario de revisión", + edit: "Editar comentario de revisión", + delete: "Eliminar comentario de revisión", + label: "Comentario de revisión", + placeholder: "Deja un comentario", + cancel: "Cancelar", + cancelAccessibility: "Cancelar comentario de revisión", + save: "Comentario", + saveAccessibility: "Guardar comentario de revisión", + }, + }, + settings: { + title: "Ajustes", + loading: "Cargando configuración...", + groups: { + app: "Aplicación", + host: "Host", + }, + hostPicker: { + switchHost: "Cambiar de anfitrión", + local: "Local", + }, + backToWorkspace: "Atrás", + addHost: "Agregar anfitrión", + projects: "Proyectos", + projectList: { + hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}", + editProject: "Editar{{projectName}}", + }, + groupInfo: "Acerca de{{title}}", + sections: { + general: "General", + daemon: "Daemon", + appearance: "Apariencia", + shortcuts: "Atajos", + integrations: "Integraciones", + permissions: "Permisos", + diagnostics: "Diagnóstico", + about: "Acerca de", + }, + hostSections: { + connections: "Conexiones", + agents: "Agents", + workspaces: "Workspaces", + providers: "Proveedores", + host: "Host", + }, + general: { + title: "General", + defaultSend: { + label: "Envío predeterminado", + description: "¿Qué sucede cuando presiona Enter mientras el agente se está ejecutando?", + options: { + interrupt: "Interrumpir", + queue: "Cola", + }, + }, + serviceUrls: { + label: "URL de servicio", + description: "Dónde abrir URL desde scripts en ejecución", + options: { + ask: "Preguntar", + inApp: "EnPaseo", + external: "Navegador externo", + }, + }, + terminalScrollback: { + label: "desplazamiento hacia atrásTerminal", + description: "Líneas mantenidas en el búfer de terminal incorporado", + accessibilityLabel: "Líneas de desplazamiento hacia atrásTerminal", + }, + language: { + label: "Idioma", + description: "Idioma de la aplicación", + options: { + system: "Sistema", + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + zhCN: "中文", + }, + }, + }, + diagnostics: { + title: "Diagnóstico", + testAudio: "audio de prueba", + playTest: "Prueba de juego", + playing: "Jugando...", + playbackFailed: "Error de reproducción:{{message}}", + }, + about: { + title: "Acerca de", + appVersion: "Versión de la aplicación", + thisDevice: "este dispositivo", + connectedHosts: "Anfitriones conectados", + offline: "Desconectado", + versionDiffers: "La versión difiere de este dispositivo.", + releaseChannel: { + label: "Canal de lanzamiento", + description: "Cambie aBetapara recibir actualizaciones antes y ayudar a darles forma", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "Actualizaciones de aplicaciones", + readyToInstall: "Listo para instalar:{{version}}", + installTitle: "Instalar actualización de escritorio", + installMessage: "Esto actualizaPaseoen esta computadora.", + installConfirm: "Instalar actualización", + update: "Actualizar", + updateTo: "Actualización a{{version}}", + installing: "Instalando...", + check: "Controlar", + checking: "De cheques...", + alertTitle: "Error", + alertMessage: "No se puede abrir el cuadro de diálogo de confirmación de actualización.", + }, + }, + appearance: { + theme: { + title: "Tema", + accessibilityLabel: "Tema:{{value}}", + options: { + light: "Luz", + dark: "Oscuro", + zinc: "Zinc", + midnight: "Medianoche", + claude: "claudio", + ghostty: "fantasmal", + auto: "Sistema", + }, + }, + fonts: { + title: "Fuentes", + systemDefault: "Valor predeterminado del sistema", + interfaceFont: "Fuente de interfaz", + interfaceFontHint: + "Utilizado en toda la aplicación. Déjelo vacío para el valor predeterminado del sistema.", + interfaceFontAccessibility: "Familia de fuentes de interfaz", + interfaceSize: "Tamaño de la interfaz", + interfaceSizeAccessibility: "Tamaño de fuente de la interfaz", + codeFont: "Fuente de código", + codeFontHint: + "Se utiliza en código, diferencias y salida del terminal. Déjelo vacío para el valor predeterminado del sistema.", + codeFontAccessibility: "Familia de fuentes de código", + codeSize: "Tamaño del código", + codeSizeAccessibility: "Tamaño de fuente del código", + }, + syntax: { + title: "Sintaxis", + highlightTheme: "Tema destacado", + highlightThemeHint: "Colores para el código, independientemente del tema de la aplicación.", + highlightThemeAccessibility: "Tema destacado:{{value}}", + previewAccessibility: "Vista previa en vivo del tema de sintaxis y fuente del código", + }, + }, + shortcuts: { + dialogTitle: "Atajos", + unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio", + capturePrompt: "Presione el acceso directo...", + actions: { + done: "Hecho", + cancel: "Cancelar", + rebind: "Reencuadernar", + reset: "Reiniciar", + resetAll: "Restablecer todo", + }, + sections: { + navigation: "Navegación", + tabsPanes: "Pestañas y paneles", + projects: "Proyectos", + panels: "Paneles", + agentInput: "EntradaAgent", + }, + help: { + openProject: "Abrir proyecto", + newWorktree: "Nuevo árbol de trabajo", + archiveWorktree: "Árbol de trabajo de archivo", + newTab: "Nueva pestaña", + closeCurrentTab: "Cerrar pestaña actual", + jumpToWorkspace: "Saltar al espacio de trabajo", + jumpToTab: "Saltar a la pestaña", + previousWorkspace: "Espacio de trabajo anterior", + nextWorkspace: "Siguiente espacio de trabajo", + previousTab: "Pestaña anterior", + nextTab: "Pestaña siguiente", + splitPaneRight: "Panel dividido a la derecha", + splitPaneDown: "Dividir panel hacia abajo", + focusPaneLeft: "Panel de enfoque a la izquierda", + focusPaneRight: "Panel de enfoque a la derecha", + focusPaneUp: "Panel de enfoque arriba", + focusPaneDown: "Panel de enfoque hacia abajo", + moveTabLeft: "Mover pestaña hacia la izquierda", + moveTabRight: "Mover pestaña a la derecha", + moveTabUp: "Mover pestaña hacia arriba", + moveTabDown: "Mover pestaña hacia abajo", + closePane: "Cerrar panel", + newTerminal: "Nueva terminal", + toggleCommandCenter: "Alternar centro de comando", + showKeyboardShortcuts: "Mostrar atajos de teclado", + toggleLeftSidebar: "Alternar barra lateral izquierda", + toggleRightSidebar: "Alternar barra lateral derecha", + toggleBothSidebars: "Alternar ambas barras laterales", + toggleSettings: "Alternar configuración", + toggleFocusMode: "Alternar modo de enfoque", + cycleTheme: "Tema del ciclo", + focusMessageInput: "Entrada de mensaje de enfoque", + toggleVoiceMode: "Alternar modo de voz", + startStopDictation: "Iniciar dictado/stop", + interruptAgent: "agente de interrupción", + sendMessage: "enviar mensaje", + queueMessage: "mensaje de cola", + muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute", + }, + helpNotes: { + showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.", + }, + }, + integrations: { + title: "Integraciones", + docs: { + cli: "DocumentosCLI", + skills: "Documentos de habilidades", + openCli: "Abrir la documentación deCLI", + openSkills: "Documentación de habilidades abiertas", + }, + commandLine: { + title: "línea de comando", + description: "Agentes de control y script desde tu terminal", + }, + skills: { + title: "Habilidades de orquestación", + description: "Enseñe a sus agentes a orquestar a través delCLI", + updateAvailable: "Actualización disponible", + updateTitle: "¿Actualizar las habilidades dePaseo?", + updateFallback: "Sincronice las habilidades incluidas con su máquina.", + uninstallTitle: "¿Desinstalar las habilidadesPaseo?", + uninstallMessage: + "Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.", + }, + actions: { + install: "Instalar", + installing: "Instalando...", + installed: "Instalado", + update: "Actualizar", + working: "Laboral...", + uninstall: "Desinstalar", + }, + operations: { + add: "Agregar habilidad", + update: "Actualizar habilidad", + delete: "Eliminar habilidad", + }, + }, + permissions: { + title: "Permisos", + notifications: "Notificaciones", + microphone: "Micrófono", + refresh: "Refrescar", + refreshing: "Refrescante...", + refreshAccessibility: "Actualizar permisos de escritorio", + test: "Prueba", + actions: { + granted: "Otorgada", + request: "Pedido", + requesting: "Solicitando...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "Hostno encontrado", + badges: { + relay: "Relé", + local: "Local", + }, + connections: { + title: "Conexiones", + removeTitle: "Quitar conexión", + removeMessage: "¿Quitar{{name}}? Esto no se puede deshacer.", + removeAction: "Eliminar", + removeErrorTitle: "Error", + removeErrorMessage: "No se puede eliminar la conexión", + timeout: "Se acabó el tiempo", + }, + pairDevices: { + title: "Emparejar dispositivos", + rowTitle: "Emparejar un dispositivo", + rowHint: "Escanee un códigoQRo copie un enlace para conectar su teléfono a este host", + }, + orchestration: { + title: "Orquestación", + unavailable: "Conéctese a este host para administrar la orquestación", + enableTools: { + title: "Habilitar herramientasPaseo", + hint: "Los agentes podrán gestionar árboles de trabajo, agentes y horarios.", + accessibilityLabel: "Inyectar herramientasPaseo", + }, + systemPrompt: { + title: "Aviso del sistema", + hint: "Agrega un mensaje del sistema a todos los agentes.", + sheetTitle: "Agregar mensaje del sistema", + accessibilityLabel: "Agregar mensaje del sistema", + placeholder: "Mantenga siempre respuestas concisas.", + }, + }, + agents: { + unavailable: "Connect to this host to manage agents", + }, + workspaces: { + unavailable: "Connect to this host to manage workspaces", + }, + daemon: { + rename: { + editLabel: "Editar etiqueta", + title: "Cambiar nombre de host", + placeholder: "MiHost", + }, + restart: { + title: "Reiniciar demonio", + hint: "Reinicia el proceso del demonio. La aplicación se volverá a conectar automáticamente", + confirmTitle: "Reiniciar{{name}}", + confirmMessage: + "Esto reiniciará el demonio. Los agentes que se ejecutan en él seguirán funcionando; la aplicación se volverá a conectar automáticamente.", + restarting: "Reiniciando...", + unableToReconnectTitle: "No se puede volver a conectar", + unableToReconnectMessage: + "{{name}}no volvió a conectarse. Por favor verifique que se haya reiniciado.", + unavailableTitle: "Hostno disponible", + unavailableMessage: + "Este host no está conectado. Espere a que se conecte antes de reiniciar.", + offlineTitle: "Hostfuera de línea", + offlineMessage: + "Este anfitrión está desconectado.Paseose vuelve a conectar automáticamente; espere hasta que vuelva a estar en línea antes de reiniciar.", + requestFailedTitle: "Error", + requestFailedMessage: + "No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.", + dialogFailedMessage: + "No se puede abrir el cuadro de diálogo de confirmación de reinicio.", + }, + dangerZone: "Zona de peligro", + remove: { + title: "Eliminar host", + localTitle: "Remove localhost connection", + hint: "Elimina este host y sus conexiones guardadas de este dispositivo", + localHint: "Removes localhost from this device and stops the built-in daemon", + localConfirmTitle: "Remove localhost connection and stop daemon?", + confirmMessage: "¿Quitar{{name}}? Esto eliminará sus conexiones guardadas.", + localConfirmMessage: + "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + errorTitle: "Error", + errorMessage: "No se puede eliminar el host", + localErrorMessage: "Unable to remove localhost connection", + }, + }, + }, + providers: { + title: "Proveedores", + addProvider: "Agregar proveedor", + providerDetails: "Detalles del proveedor{{name}}", + enableProvider: "Habilitar{{name}}", + unavailable: "Conéctese a este host para ver proveedores", + loading: "Cargando...", + addErrorTitle: "Unable to add provider", + updateErrorTitle: "No se puede actualizar el proveedor", + statuses: { + disabled: "Desactivado", + loading: "Cargando", + error: "Error", + available: "Disponible", + notInstalled: "No instalado", + }, + models: { + one: "1 modelo", + many: "Modelos{{count}}", + addModel: "Agregar modelo", + addCustomTitle: "Agregar modelo personalizado", + modelId: "ModeloID", + modelIdPlaceholder: "p.ej. openai/gpt-5", + add: "Agregar", + adding: "Añadiendo...", + failedToSave: "No se pudo guardar el modelo", + removeModel: "Quitar{{id}}", + searchPlaceholder: "Buscar modelos", + loading: "Cargando modelos...", + retry: "Rever", + retrying: "Reintentando...", + noSearchMatches: "Ningún modelo coincide con tu búsqueda", + noneDetected: "No se detectaron modelos", + discovered: "descubierto", + custom: "Modelos personalizados", + updated: "{{time}}actualizado", + }, + diagnostic: { + title: "Diagnóstico", + button: "Diagnóstico", + refresh: "Refrescar", + refreshing: "Refrescante...", + refreshAccessibility: "Actualizar diagnóstico", + refreshingAccessibility: "Diagnóstico refrescante", + running: "Ejecutando diagnóstico...", + none: "No hay diagnóstico disponible", + failedToFetch: "No se pudo recuperar el diagnóstico", + unknownError: "Error desconocido", + }, + }, + project: { + noEditableTarget: "No tenemos una copia editable de este proyecto en ningún host conectado.", + backToProjects: "Volver a proyectos", + switchHost: "Cambiar de anfitrión", + rename: { + renamedToast: "Proyecto renombrado", + errorFallback: "No se pudo cambiar el nombre del proyecto", + renameLabel: "Cambiar nombre del proyecto", + resetLabel: "Restablecer el nombre del proyecto al valor predeterminado", + projectNameLabel: "Nombre del proyecto", + saveLabel: "Guardar nombre del proyecto", + cancelLabel: "Cancelar cambio de nombre", + reset: "Reiniciar", + }, + readFailures: { + invalidTitle: "paseo.json no se pudo analizar", + invalidDescription: "Fije el archivo en el disco y luego vuelva a cargarlo.", + missingTitle: "Este anfitrión no tiene este proyecto", + missingWithHosts: "Cambie a otro host de arriba o vuelva a cargar.", + missingSingleHost: "El anfitrión seleccionado no tiene registro de este proyecto.", + transportTitle: "No se pudo cargar paseo.json", + transportFallback: "El anfitrión no respondió.", + failedTitle: "No se pudo cargar paseo.json", + failedDescription: "Vuelva a cargar para intentarlo de nuevo.", + }, + worktree: { + title: "Ganchos del ciclo de vida del árbol de trabajo", + info: "Comandos que se ejecutan cuando se crea o elimina un árbol de trabajo para este proyecto", + docs: "Documentos", + docsTooltip: + "Consulte los documentos para obtener más detalles y las variables de entorno disponibles para estos comandos.", + setup: "Configuración", + setupAccessibility: "Comandos de configuración del árbol de trabajo", + teardown: "Demoler", + teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", + }, + scripts: { + title: "Guiones", + info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto", + empty: "Aún no hay guiones.", + untitled: "Guión sin título", + port: "puerto{{port}}", + menuAccessibility: "Abrir menú de script", + removeTitle: "¿Quitar guión?", + removeMessage: "¿Quitar{{name}}?", + removeFallbackName: "este guión", + name: "Nombre", + command: "Dominio", + nameAccessibility: "Nombre del guión", + commandAccessibility: "Comando de guión", + nameRequired: "El nombre es obligatorio", + commandRequired: "Se requiere comando", + newScript: "Nuevo guión", + editScript: "Editar{{name}}", + runAsService: "Ejecutar como servicio", + serviceHint: "Paseosupervisa el proceso y asigna un puerto vía $PASEO_PORT", + actions: { + add: "Agregar guión", + edit: "Editar", + remove: "Eliminar", + }, + }, + metadata: { + title: "Generación de metadatos", + info: "Instrucciones específicas del proyecto inyectadas en los mensajes de IA quePaseoutiliza para generar metadatos; úselas para hacer cumplir las convenciones de su equipo, como la denominación de ramas, el estilo de confirmación o el formatoPR.", + agentTitle: "TítulosAgent", + agentTitlePlaceholder: "Mantenga los títulos imperativos y de menos de 40 caracteres", + branchName: "Nombres de sucursales", + branchNamePlaceholder: "Prefijo ramas con feat/ o fix/, mb/ para ramas personales", + commitMessage: "Confirmar mensajes", + commitMessagePlaceholder: "Utilice confirmaciones convencionales con un alcance", + pullRequest: "Solicitudes de extracción", + pullRequestPlaceholder: + "Liderar con un resumen de un párrafo, incluir una sección de plan de prueba", + }, + writeFailures: { + staleTitle: "Configuración cambiada en el disco", + staleDescription: "Vuelva a cargar para obtener el último paseo.json antes de guardar.", + failedTitle: "No se pudo guardar paseo.json", + failedDescription: "Inténtelo de nuevo o vuelva a cargar la última versión desde el disco.", + }, + actions: { + reload: "Recargar", + tryAgain: "Intentar otra vez", + save: "Ahorrar", + saved: "Proyecto guardado", + saving: "Ahorro...", + cancel: "Cancelar", + }, + }, + }, +}; diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts new file mode 100644 index 000000000..62c01f83f --- /dev/null +++ b/packages/app/src/i18n/resources/fr.ts @@ -0,0 +1,1850 @@ +import type { TranslationResources } from "./en"; + +export const fr: TranslationResources = { + common: { + back: "Dos", + loading: "Chargement...", + actions: { + back: "Dos", + cancel: "Annuler", + close: "Fermer", + copy: "Copie", + dismiss: "Rejeter", + retry: "Réessayer", + search: "Recherche", + select: "Sélectionner", + }, + placeholders: { + search: "Recherche...", + }, + empty: { + noResults: "Aucun résultat trouvé", + noOptionsMatchSearch: "Aucune option ne correspond à votre recherche.", + }, + states: { + loading: "Chargement...", + starting: "Départ...", + copied: "Copié", + copiedLabel: "{{label}}copié", + downloadComplete: "Téléchargement terminé", + downloadFailed: "Le téléchargement a échoué", + }, + errors: { + error: "Erreur", + unableToSave: "Impossible d'enregistrer", + nameRequired: "Le nom est requis", + daemonUnavailable: "Daemonindisponible", + daemonClientUnavailable: "ClientDaemonindisponible", + daemonClientDisconnected: "Le clientDaemonest déconnecté", + noFileFound: "Aucun fichier trouvé pour{{token}}", + unexpectedDictationError: + "Une erreur inattendue s'est produite lors du traitement de la dictée.", + }, + connectionStatus: { + online: "En ligne", + connecting: "De liaison", + offline: "Hors ligne", + error: "Erreur", + idle: "Inactif", + }, + }, + shell: { + menu: { + toggleSidebar: "Basculer la barre latérale", + open: "Ouvrir le menu", + close: "Fermer le menu", + }, + commandCenter: { + placeholder: "Tapez une commande ou recherchez des agents...", + noMatches: "Aucune correspondance", + actions: "Actes", + agents: "Agents", + newAgent: "Nouvel agent", + openProject: "Projet ouvert", + home: "Maison", + }, + }, + composer: { + placeholders: { + desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills", + mobile: "Message,@files,/commands", + fallback: "Message...", + }, + input: { + accessibilityLabel: "Agent de messagerie...", + focusHint: "{{shortcut}}pour se concentrer", + addAttachment: "Ajouter une pièce jointe", + interruptAgent: "Agent d'interruption", + queueMessage: "Message de file d'attente", + sendAndInterrupt: "Envoyer et interrompre", + sendMessage: "Envoyer un message", + queue: "File d'attente", + send: "Envoyer", + }, + cancel: { + cancelingAgent: "Agent d'annulation", + stopAgent: "Agent d'arrêt", + interrupt: "Interrompre", + }, + voice: { + enableVoiceMode: "Activer le mode vocal", + voiceMode: "Mode vocal", + unmuteVoiceMode: "Activer le mode vocal", + muteVoiceMode: "Mode voix muette", + stopDictation: "Arrêter la dictée", + startDictation: "Démarrer la dictée", + unmuteVoice: "Réactiver la voix", + muteVoice: "Voix muette", + dictation: "Dictée", + interruptBeforeVoice: "Interrompre l'agent avant de démarrer le mode vocal", + }, + attachments: { + addImage: "Ajouter une image", + addIssueOrPr: "Ajouter un problème ouPR", + dropImagesHere: "Déposez des images ici", + editQueuedMessage: "Modifier le message en file d'attente", + sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant", + openImage: "Ouvrir la pièce jointe de l'image", + removeImage: "Supprimer l'image jointe", + openGithub: "Ouvrir{{kind}}#{{number}}", + removeGithub: "Supprimer{{kind}}#{{number}}", + browserElement: "Élément ·{{tag}}", + openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur", + removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur", + openReview: "Ouvrir la pièce jointe de l'avis", + removeReview: "Supprimer la pièce jointe de l'avis", + }, + errors: { + failedToSend: "Échec de l'envoi du message", + failedToCreateAgent: "Échec de la création de l'agent", + noHostSelected: "Aucun hôte sélectionné", + initialPromptRequired: "Une invite initiale est requise", + alreadyLoading: "Déjà en cours de chargement", + }, + clientCommands: { + archiveAgent: "Archiver l'agent actuel", + freshDraft: "Archivez cet agent et démarrez un nouveau brouillon", + }, + github: { + searching: "Recherche...", + noResults: "Aucun résultat trouvé.", + searchPlaceholder: "Problèmes de recherche et PR...", + title: "Joindre le problème ouPR", + }, + }, + agentControls: { + provider: { + fallback: "Fournisseur", + select: "Sélectionnez le fournisseur d'agent", + }, + thinking: { + title: "Pensée", + unknown: "Inconnu", + extraHigh: "Très haut", + select: "Sélectionnez l'option de réflexion", + selectWithValue: "Sélectionnez l'option de réflexion ({{value}})", + }, + model: { + unknown: "Modèle inconnu", + }, + features: { + title: "Caractéristiques", + open: "Fonctionnalités de l'agent ouvert", + on: "Sur", + off: "Désactivé", + }, + mode: { + title: "Mode", + searchPlaceholder: "Modes de recherche...", + selectWithValue: "Sélectionnez le mode agent ({{value}})", + }, + hints: { + thinking: "Mode réflexion", + model: "Changer de modèle", + mode: "Changer le mode d'autorisation", + }, + }, + agentStream: { + empty: "Commencez à discuter avec cet agent...", + scrollToBottom: "Faire défiler vers le bas", + permission: { + plan: "Plan", + required: "Autorisation requise", + deny: "Refuser", + accept: "Accepter", + implement: "Mettre en œuvre", + question: "Comment souhaitez-vous procéder?", + proposedPlan: "Plan proposé", + }, + }, + agentPanel: { + states: { + notFound: "Agentintrouvable", + failedToLoad: "Échec du chargement de l'agent", + reconnecting: "Reconnexion...", + archivingTitle: "Agent d'archivage...", + archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.", + }, + unavailable: { + selectedHost: "Hôte sélectionné", + unknownHost: + "Impossible d'ouvrir cet agent car{{serverLabel}}n'est pas configuré sur ce périphérique.", + addHost: + "Ajoutez l'hôte dans Paramètres ou ouvrez un agent sur un serveur configuré pour continuer.", + preparingSession: "Préparation de la séance{{serverLabel}}...", + connecting: "Connexion à{{serverLabel}}...", + showSoon: "Nous montrerons cet agent dans un instant.", + showWhenOnline: "Nous afficherons cet agent une fois que l'hôte sera en ligne.", + reconnectingTo: "Reconnexion à{{serverLabel}}...", + showAgainWhenReachable: "Nous afficherons à nouveau cet agent dès que l'hôte sera joignable.", + }, + archived: { + callout: "Cet agent est archivé", + unarchive: "Désarchiver", + }, + }, + sessions: { + title: "Séances", + empty: "Aucune séance pour l'instant", + actions: { + loadMore: "Charger plus", + }, + }, + agentList: { + fallbackTitle: "Nouvelle séance", + dateSections: { + recent: "Récent", + today: "Aujourd'hui", + yesterday: "Hier", + thisWeek: "Cette semaine", + thisMonth: "Ce mois-ci", + older: "Plus vieux", + }, + status: { + initializing: "Départ", + idle: "Inactif", + running: "En cours d'exécution", + error: "Erreur", + closed: "Fermé", + }, + badges: { + archived: "Archivé", + pending: "{{count}}en attente", + attention: "Attention", + }, + archiveSheet: { + hostOffline: "Hosthors ligne", + runningAgent: "Cet agent est toujours en cours d'exécution. L’archiver arrêtera l’agent.", + archive: "Archive", + }, + }, + message: { + actions: { + copyCode: "Copier le code", + copyTurn: "Copier le tour", + copyMessage: "Copier le message", + openFile: "Ouvrir le fichier", + copied: "Copié", + }, + attachments: { + dismissImage: "Ignorer l'image", + closeImage: "Fermer l'image", + imageLoadFailed: "Impossible de charger l'image", + imageUnavailable: "Image indisponible", + imagePreviewUnavailable: "Aperçu de l'image indisponible.", + imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.", + reviewOne: "Avis · 1 commentaire", + reviewMany: "Révision · Commentaires{{count}}", + textAttachment: "Texte en pièce jointe", + }, + speak: { + header: "Rayon", + }, + activity: { + details: "Détails", + }, + dictation: { + start: "Démarrer la dictée vocale", + cancel: "Annuler la dictée", + retry: "Réessayer la dictée", + insert: "Insérer la transcription", + insertAndSend: "Insérer la transcription et envoyer", + failed: "Échec de la dictée:{{error}}", + failedRetry: "La dictée a échoué. Appuyez sur réessayer.", + }, + question: { + submit: "Soumettre", + next: "Suivant", + answerPlaceholder: "Tapez votre réponse...", + otherPlaceholder: "Autre...", + }, + todo: { + title: "Tâches", + empty: "Aucune tâche pour l'instant.", + }, + compaction: { + loading: "Compactage...", + auto: "Contexte automatiquement compacté", + manual: "Contexte compacté manuellement", + withTokens: "Contexte compacté (jetons{{tokens}}K)", + completed: "Contexte compacté", + }, + }, + importSession: { + title: "Session d'importation", + filters: { + all: "Tous", + }, + status: { + connectHost: "Connectez-vous à un hôte pour importer des sessions", + updateHost: "Mettez à jour l'hôte pour importer des sessions.", + noProviders: "Aucun fournisseur importable n'est activé.", + loading: "Chargement des sessions récentes...", + failedAll: "Impossible de charger les sessions récentes.", + failedProviders: "Impossible de charger les sessions pour{{providers}}.", + failedImport: "Impossible d'importer la session sélectionnée.", + }, + actions: { + refresh: "Sessions de rafraîchissement", + }, + preview: { + untitledSession: "Séance sans titre", + noPrompt: "Aucun aperçu rapide", + }, + empty: { + noRecent: "Aucune session récente à importer.", + alreadyImported: "Toutes les sessions récentes sont déjà importées.", + noProviderSessions: "Aucune session{{provider}}trouvée.", + }, + row: { + importing: "Importation...", + }, + }, + workspace: { + route: { + loading: "Chargement de l'espace de travail", + connecting: "De liaison", + hostOffline: "{{hostName}}est hors ligne", + cannotReachHost: "Impossible d'atteindre{{hostName}}", + hostStatus: "StatutHost:{{status}}", + missing: "Workspaceintrouvable", + manageHost: "Gérer l'hôte", + }, + hoverCard: { + scriptsAccessibility: "ScriptsWorkspace", + }, + fileExplorer: { + sort: { + name: "Nom", + modified: "Modifié", + size: "Taille", + }, + context: { + size: "Taille", + modified: "Modifié", + copyPath: "Copier le chemin", + download: "Télécharger", + }, + actions: { + back: "Dos", + retry: "Réessayer", + refresh: "Actualiser les fichiers", + refreshing: "Actualisation des fichiers", + }, + empty: { + noFiles: "Aucun fichier", + }, + states: { + unavailable: "Workspacen'est pas disponible", + loading: "Chargement des fichiers...", + }, + errors: { + failedToListDirectory: "Échec de la liste du répertoire", + }, + }, + setup: { + descriptor: { + label: "Installation", + completed: "Configuration terminée", + failed: "Échec de l'installation", + workspace: "ConfigurationWorkspace", + }, + status: { + running: "En cours d'exécution", + completed: "Complété", + failed: "Échoué", + waiting: "En attente de la sortie de configuration", + }, + waiting: "Configuration de l'espace de travail...", + empty: { + noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.", + }, + accessibility: { + noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail", + log: "Journal de configurationWorkspace", + }, + log: { + noOutput: "Aucune sortie", + }, + }, + browser: { + unavailable: { + title: "Le navigateur est réservé au bureau", + subtitle: "Ouvrez cet espace de travail dans Electron pour utiliser le navigateur intégré.", + }, + session: "Session de navigateur{{browserId}}", + controls: { + back: "Dos", + forward: "Avant", + stopLoading: "Arrêter le chargement", + refresh: "Rafraîchir", + browserUrl: "NavigateurURL", + enterUrl: "EntrezURL", + openDevTools: "Outils de développement du navigateur ouvert", + cancelSelector: "Annuler le sélecteur d'élément", + selectElement: "Sélectionner un élément", + }, + errors: { + failedToLoad: "Échec du chargement de la page", + invalidUrl: "NavigateurURLinvalide", + unsupportedProtocol: "Navigateur non pris en charge bloquéURL:{{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Hostn'est pas connecté", + unableToSubscribe: "Impossible de s'abonner au terminal", + }, + tabs: { + loading: "Chargement...", + loadingAgentTitle: "Titre d'agent de chargement", + emptyPane: "Aucun onglet dans ce volet.", + fallback: { + newAgent: "NouveauAgent", + setup: "Installation", + workspaceSetup: "ConfigurationWorkspace", + terminal: "Terminal", + browser: "Navigateur", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "Changer d'onglet ({{count}}ouvert)", + title: "Changer d'onglet", + searchPlaceholder: "Onglets de recherche", + }, + menu: { + openFor: "Ouvrir le menu pour{{label}}", + copyResumeCommand: "Copier la commande de reprise", + copyAgentId: "Copier l'identifiant de l'agent", + rename: "Rebaptiser", + closeAbove: "Fermer les onglets ci-dessus", + closeBelow: "Fermer les onglets ci-dessous", + closeLeft: "Près de la gauche", + closeRight: "Près de la droite", + closeOthers: "Fermer les autres onglets", + reloadAgent: "Agent de rechargement", + reloadAgentTooltip: + "Rechargez l'agent pour mettre à jour les compétences, les MCP ou le statut de connexion.", + close: "Fermer", + renameTerminal: "Renommer le terminal", + renameAgent: "Renommer l'agent", + }, + actions: { + newAgent: "Nouvel onglet agent", + newTerminal: "Nouvel onglet de terminal", + preparingTerminal: "Préparation de l'onglet du terminal", + preparingTerminalTooltip: "Préparation du terminal...", + newBrowser: "Nouvel onglet du navigateur", + splitRight: "Volet divisé à droite", + splitDown: "Diviser le volet vers le bas", + }, + explorer: { + open: "Ouvrir l'explorateur", + close: "Fermer l'explorateur", + toggle: "Basculer l'explorateur", + changes: "Changements", + files: "Fichiers", + }, + toasts: { + copyFailed: "Échec de la copie", + agentIdCopiedLabel: "AgentID", + resumeCommandCopiedLabel: "reprendre la commande", + resumeIdUnavailable: "ReprendreIDnon disponible", + resumeCommandUnavailable: "Commande de reprise non disponible", + reloadingAgent: "Agent de rechargement...", + reloadedAgent: "Agent rechargé", + failedToReloadAgent: "Échec du rechargement de l'agent", + }, + confirmations: { + close: "Fermer", + cancel: "Annuler", + archive: "Archive", + closeTerminalTitle: "Fermer le terminal?", + closeTerminalMessage: + "Tout processus en cours d’exécution dans ce terminal sera immédiatement arrêté.", + archiveRunningAgentTitle: "Archiver l'agent en cours d'exécution?", + archiveRunningAgentMessage: + "Cet agent est toujours en cours d'exécution. L'archiver arrêtera l'agent et fermera l'onglet.", + closeTabsLeftTitle: "Fermer les onglets à gauche?", + closeTabsRightTitle: "Fermer les onglets à droite?", + closeOtherTabsTitle: "Fermer les autres onglets?", + bulk: { + all: "Cela archivera les agents{{agents}}, fermera les terminaux{{terminals}}et fermera les onglets{{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + agentsAndTerminals: + "Cela archivera les agents{{agents}}et fermera les terminaux{{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + terminalsAndTabs: + "Cela fermera le(s) terminal(s){{terminals}}et fermera le(s) onglet(s){{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + agentsAndTabs: "Cela archivera les agents{{agents}}et fermera les onglets{{tabs}}.", + terminals: + "Cela fermera le(s) terminal(s){{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + tabs: "Cela fermera les onglets{{tabs}}.", + agents: "Cela archivera les agents{{agents}}.", + }, + }, + }, + header: { + actions: { + workspaceActions: "ActionsWorkspace", + newAgent: "Nouvel agent", + newTerminal: "Nouvelle borne", + newBrowser: "Nouvel onglet du navigateur", + importSession: "Session d'importation", + copyPath: "Copier le chemin de l'espace de travail", + copyBranchName: "Copier le nom de la branche", + showSetup: "Afficher la configuration", + }, + toasts: { + workspacePathUnavailable: "Le cheminWorkspacen'est pas encore disponible", + branchNameUnavailable: "Nom de la succursale non disponible", + terminalQueued: + "Préparation de l'espace de travail, ouverture du terminal lorsque vous êtes prêt...", + workspacePathCopiedLabel: "CheminWorkspace", + branchNameCopiedLabel: "Nom de la succursale", + }, + }, + scripts: { + title: "Scripts", + actions: { + run: "Courir", + view: "Voir", + }, + accessibility: { + trigger: "ScriptsWorkspace", + openAt: "Ouvrir{{scriptName}}à{{label}}", + viewTerminal: "Voir le terminal{{scriptName}}", + runScript: "Exécuter le script{{scriptName}}", + script: "Script{{scriptName}}", + }, + states: { + exitCode: "quitter{{code}}", + startFailed: "Échec du démarrage de{{scriptName}}", + }, + }, + git: { + actions: { + moreOptions: "Plus d'options", + moreActions: "Plus de propositions", + commit: { + label: "Commettre", + pending: "S'engager...", + success: "Engagé", + }, + pull: { + label: "Tirer", + pending: "Tirer...", + success: "Tiré", + }, + push: { + label: "Pousser", + pending: "Pousser...", + success: "Poussé", + }, + pullAndPush: { + label: "Tirez et poussez", + pending: "Tirer et pousser...", + success: "Tiré et poussé", + }, + viewPr: "VoirPR", + createPr: { + label: "CréerPR", + pending: "Création dePR...", + success: "PRcréé", + }, + mergeBranch: { + label: "Fusionner localement", + pending: "Fusion...", + success: "Fusionné", + }, + mergeFromBase: { + label: "Mise à jour de{{baseRef}}", + pending: "Mise à jour...", + success: "Mis à jour", + }, + archive: { + label: "Arbre de travail d'archivage", + pending: "Archivage...", + success: "Archivé", + }, + mergePr: { + squash: "Écraser et fusionner", + merge: "Créer un commit de fusion", + rebase: "Rebase et fusionner", + pending: "Fusion dePR...", + success: "PRfusionné", + }, + autoMerge: { + enableSquash: "Activer la fusion automatique avec squash", + enableMerge: "Activer la fusion automatique avec la validation de fusion", + enableRebase: "Activer la fusion automatique avec rebase", + enabled: "Fusion automatique activée", + enabling: "Activation de la fusion automatique...", + disabling: "Désactivation de la fusion automatique...", + disabled: "Fusion automatique désactivée", + }, + unavailable: { + viewPrNoGithub: "ViewPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + pullNoRemote: + "Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + pullDirty: + "Pull n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + pullUpToDate: "Pull n'est pas disponible car cette branche est déjà à jour", + pushNoRemote: + "Push n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + pushBehind: + "Push n'est pas encore disponible, car de nouvelles modifications doivent être apportées en premier.", + pushNothing: "Push n'est pas disponible car il n'y a rien de nouveau à envoyer", + pullAndPushNoRemote: + "Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande", + pullAndPushDirty: + "Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + pullAndPushInSync: + "Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée", + createPrNoGithub: + "CréerPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + createPrNoCommits: + "CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits", + mergeNoBase: + "La fusion n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + mergeDirty: + "La fusion n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + mergeNothing: + "La fusion n'est pas disponible car cette branche n'a encore rien de nouveau à fusionner", + updateNoBase: + "La mise à jour n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + updateDirty: + "La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + updateCurrent: + "La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}", + archiveNotWorktree: + "L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo", + mergePrNoGithub: + "La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + mergePrMissing: + "La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction", + mergePrDraft: + "La fusionPRn'est pas disponible car la demande d'extraction est encore un brouillon", + mergePrMerged: + "La fusionPRn'est pas disponible car la demande d'extraction est déjà fusionnée", + mergePrClosed: "La fusionPRn'est pas disponible car la demande d'extraction est fermée", + mergePrConflicts: + "La fusionPRn'est pas disponible car la demande d'extraction présente des conflits", + mergePrQueue: + "MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion", + mergePrNotReady: + "La fusionPRn'est pas disponible jusqu'à ce queGitHubsignale que la demande d'extraction est prête à fusionner", + autoMergeCannotDisable: + "La fusion automatique est activée, mais ce compte ne peut pas la désactiver", + }, + toasts: { + failedCommit: "Échec de la validation", + failedPull: "Échec de l'extraction", + failedPush: "Échec de la poussée", + failedPullAndPush: "Impossible de tirer et de pousser", + failedCreatePr: "Échec de la création dePR", + failedMergePr: "Échec de la fusion dePR", + failedEnableAutoMerge: "Échec de l'activation de la fusion automatique", + failedDisableAutoMerge: "Échec de la désactivation de la fusion automatique", + baseRefUnavailable: "Réf de base indisponible", + failedMerge: "Échec de la fusion", + failedMergeFromBase: "Échec de la fusion à partir de la base", + worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible", + failedArchive: "Échec de l'archivage de l'arbre de travail", + }, + archiveWarning: { + title: "Archiver «{{worktreeName}}»?", + confirm: "Archive", + cancel: "Annuler", + uncommittedChanges: "Modifications non validées", + uncommittedChangesWithDiff: "Modifications non validées ({{diffStat}})", + addedLine: "Ligne ajoutée{{count}}", + addedLines: "{{count}}lignes ajoutées", + deletedLine: "Ligne supprimée{{count}}", + deletedLines: "{{count}}lignes supprimées", + unpushedCommit: "Validation non poussée{{count}}", + unpushedCommits: "Validations non poussées{{count}}", + }, + }, + diff: { + binaryFile: "Fichier binaire", + tooLarge: "Diff trop grand pour être affiché", + unified: "Différentiel unifié", + split: "Différent côte à côte", + hideWhitespace: "Masquer les espaces", + scrollLongLines: "Faire défiler les longues lignes", + wrapLongLines: "Enroulez les longues lignes", + collapseAll: "Réduire tous les fichiers", + expandAll: "Développer tous les fichiers", + refreshing: "Rafraîchissant", + refresh: "Rafraîchir", + refreshState: "Actualiser l'état de git etGitHub", + failedRefresh: "Échec de l'actualisation de l'état git.", + emptyHiddenWhitespace: "Aucun changement visible après avoir masqué les espaces", + emptyUncommitted: "Aucune modification non validée", + emptyAgainstBase: "Aucun changement par rapport à{{baseRef}}", + checkingRepository: "Vérification du référentiel...", + notRepository: "Pas un dépôt git", + diffMode: "Mode différentiel", + uncommitted: "Non engagé", + committed: "Engagé", + branchUnknown: "Inconnu", + base: "base", + newFile: "Nouveau", + deletedFile: "Supprimé", + }, + openInEditor: { + open: "Ouvrir", + chooseEditor: "Choisir l'éditeur", + openIn: "Espace de travail ouvert dans{{target}}", + openFileIn: "Open {{fileName}} in {{target}}", + failedOpen: "Échec de l'ouverture de l'espace de travail", + }, + pr: { + sections: { + checks: "Chèques", + reviews: "Avis", + }, + accessibility: { + pullRequest: "Demande de tirage #{{number}}", + }, + states: { + draft: "Brouillon", + merged: "Fusionné", + closed: "Fermé", + open: "Ouvrir", + }, + activity: { + commented: "Commenté", + approved: "Approuvé", + requestedChanges: "Modifications demandées", + reviewed: "Révisé", + }, + time: { + justNow: "tout à l' heure", + }, + errors: { + statusLoadFailed: "Impossible de charger le statut de la demande d'extraction", + activityLoadFailed: "Impossible de charger l'activité de demande d'extraction", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "Aucun hôte", + switchTitle: "Changer d'hôte", + searchPlaceholder: "Rechercher des hôtes...", + }, + actions: { + addProject: "Ajouter un projet", + home: "Maison", + settings: "Paramètres", + closeSidebar: "Fermer la barre latérale", + }, + sections: { + sessions: "Séances", + }, + worktreeSetup: { + title: "Configurer les scripts d'arbre de travail", + description: + "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", + openProjectSettings: "Ouvrir les paramètres du projet", + }, + project: { + actions: { + menu: "Actions du projet", + openSettings: "Ouvrir les paramètres du projet", + openNewWindow: "Open in new window", + openNewWindowFailed: "Couldn't open a new window", + remove: "Supprimer le projet", + removing: "Suppression...", + }, + confirmations: { + removeTitle: "Supprimer le projet?", + removeMessage: + "Supprimer «{{projectName}}» de la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", + removeConfirm: "Retirer", + cancel: "Annuler", + }, + toasts: { + hostDisconnected: "Hostn'est pas connecté", + removeFailed: "Échec de la suppression de certains espaces de travail", + }, + empty: { + title: "Aucun projet pour l'instant", + description: "Ajoutez un projet pour commencer", + }, + }, + workspace: { + status: { + scriptsAvailable: "Scripts disponibles", + creating: "Création...", + }, + actions: { + menu: "ActionsWorkspace", + newWorkspace: "Nouvel espace de travail", + createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}", + copyPath: "Copier le chemin", + copyBranchName: "Copier le nom de la branche", + rename: "Renommer l'espace de travail", + archive: "Archive", + archiveWorktree: "Arbre de travail d'archivage", + hideFromSidebar: "Masquer de la barre latérale", + archiving: "Archivage...", + hiding: "Dissimulation...", + }, + confirmations: { + hideTitle: "Masquer l'espace de travail?", + hideMessage: + "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", + hideConfirm: "Cacher", + cancel: "Annuler", + }, + rename: { + title: "Renommer l'espace de travail", + submit: "Rebaptiser", + invalidBranchName: "Nom de succursale invalide", + }, + toasts: { + workspacePathUnavailable: "CheminWorkspacenon disponible", + pathCopied: "Chemin copié", + branchNameCopied: "Nom de la succursale copié", + hostDisconnected: "Hostn'est pas connecté", + hideFailed: "Échec du masquage de l'espace de travail", + archiveFailed: "Échec de l'archivage de l'arbre de travail", + }, + }, + }, + newWorkspace: { + title: "Nouvel espace de travail", + create: "Créer", + errors: { + hostDisconnected: "Hostn'est pas connecté", + createWorktreeFailed: "Échec de la création de l'arbre de travail", + composerStateRequired: "L'état du compositeur est requis", + selectModel: "Sélectionnez un modèle", + }, + refPicker: { + startingRef: "Réf de départ", + chooseStart: "Choisissez par où commencer", + checkoutHint: "DécouvrezPR#{{number}}?", + checkoutPr: "DécouvrezPR#{{number}}", + dismissCheckoutHint: "Ignorer l'indice de paiementPR#{{number}}", + intoBase: "dans{{baseRef}}", + searching: "Recherche...", + noMatchingRefs: "Aucune référence correspondante.", + searchPlaceholder: "Rechercher des succursales et des PR", + title: "Commencer à partir de", + }, + }, + desktop: { + quitting: { + title: "QuitterPaseo...", + detail: "Arrêt du démon local.", + }, + daemon: { + title: "Daemon", + status: { + title: "Statut", + builtInOnly: "Seul le démon de bureau intégré est affiché ici", + running: "en cours d'exécution", + notRunning: "ne fonctionne pas", + pid: "PID{{pid}}", + }, + management: { + title: "Gérer le démon intégré", + hint: "LaissezPaseodémarrer et arrêter le démon intégré", + pauseTitle: "Suspendre le démon intégré", + pauseMessage: + "Cela arrêtera immédiatement le démon intégré. Les agents en cours d'exécution et les terminaux connectés au démon intégré seront arrêtés.", + pauseAndStop: "Pause et arrêt", + registrationFailed: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + pausedStopFailed: + "La gestion du démon intégré a été suspendue, maisPaseon'a ​​pas pu arrêter le démon.", + updateFailed: "Impossible de mettre à jour la gestion des démons intégrés.", + }, + keepRunning: { + title: "Laisser le démon fonctionner après avoir quitté", + hint: "Daemoncontinue de fonctionner lorsque vous quittezPaseo", + }, + logs: { + title: "Fichier journal", + modalTitle: "JournauxDaemon", + unavailable: "Chemin du journal indisponible", + empty: "(le fichier journal est vide)", + copied: "Chemin du journal copié.", + copyFailed: "Impossible de copier le chemin du journal.", + open: "Journaux ouverts", + copyPath: "Copier le chemin", + }, + fullStatus: { + title: "Statut complet", + modalTitle: "StatutDaemon", + hint: "Exécute`paseo daemon status`et affiche la sortie", + view: "Afficher l'état", + copied: "Statut copié dans le presse-papiers.", + fetchFailed: "Échec de la récupération de l'état du démon:{{message}}", + }, + advancedSettings: "Paramètres avancés", + openAdvancedSettings: "Ouvrir les paramètres avancés du démon", + versionMismatch: + "Les versions de l'application et du démon ne correspondent pas. Mettez à jour les deux vers la même version pour une meilleure expérience.", + loadFailed: "Impossible de charger l'état du démon de bureau.", + }, + updates: { + status: { + checking: "Vérification des mises à jour de l'application...", + installing: "Installation de la mise à jour de l'application...", + upToDate: "L'application est à jour.", + upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", + pending: "Nous vous informerons lorsque la mise à jour sera prête.", + availableWithVersion: "Mise à jour prête:{{version}}", + available: "Une mise à jour de l'application est prête à être installée.", + installed: "Mise à jour de l'application installée. Redémarrage requis.", + failed: "Échec de la mise à jour de l'application.", + idle: "L'état de la mise à jour n'a pas encore été vérifié.", + }, + installError: "Impossible d'installer la mise à jour de l'application de bureau.", + callout: { + installingTitle: "Installation de la mise à jour", + failedTitle: "La mise à jour a échoué", + availableTitle: "Mise à jour disponible", + genericError: "Quelque chose s'est mal passé.", + whatsNew: "Quoi de neuf", + installingAction: "Installation...", + installAndRestart: "Installer et redémarrer", + installingDescription: "Installation et redémarrage...", + versionReady: "{{version}}est prêt à être installé.", + newVersionReady: "Une nouvelle version est prête à être installée.", + restartWarning: + "La mise à niveau de l'application arrêtera l'exécution des agents et fermera les sessions de terminal.", + }, + }, + settings: { + loadFailed: "Impossible de charger les paramètres du bureau.", + saveFailed: "Impossible d'enregistrer les paramètres du bureau.", + }, + rosetta: { + title: "Téléchargez la versionApple Silicon", + runningIntel: "Vous exécutez la versionInteldePaseosousRosettasurApple Silicon.", + highCpu: + "Cela entraîne une utilisation élevée du processeur. Téléchargez la versionApple Siliconpour le réparer.", + download: "Télécharger", + }, + permissions: { + notifications: { + allowed: "Les notifications sont autorisées par le système d'exploitation.", + denied: "Les notifications sont refusées dans les paramètres système.", + notGranted: "Les notifications n'ont pas encore été accordées.", + webOnly: + "L’état des notifications sur le bureau est uniquement disponible sur l’exécution Web.", + supported: "Les notifications de bureau sont prises en charge.", + unsupported: + "Les notifications de bureau ne sont pas prises en charge sur cette plateforme.", + apiUnavailable: "L'API de notification Web n'est pas disponible dans cet environnement.", + requestsWebOnly: + "Les demandes de notification sur le bureau sont uniquement disponibles sur l'exécution Web.", + requestUnavailable: "L'API de notification Web requestPermission() n'est pas disponible.", + requestFailed: "Échec de la demande d'autorisation de notification:{{message}}", + unexpectedState: "État d'autorisation de notification inattendu:{{state}}", + }, + microphone: { + webOnly: + "L’état du microphone de bureau est uniquement disponible dans l’environnement d’exécution Web.", + navigatorUnavailable: "Le navigateur n'est pas disponible dans cet environnement.", + granted: "L’accès au microphone est accordé.", + denied: "L'accès au microphone est refusé dans les paramètres système.", + notGranted: "L'autorisation du microphone n'a pas encore été accordée.", + unexpectedState: "État d'autorisation inattendu du microphone:{{state}}", + statusApiUnavailable: + "L’API d’état du microphone n’est pas disponible dans ce runtime. Utilisez Request pour vérifier l’accès.", + queryFailed: "Échec de l'interrogation de l'état du microphone:{{message}}", + captureUnavailable: "La capture du microphone n'est pas disponible dans cet environnement.", + permissionApiUnavailable: + "L'API d'état des autorisations n'est pas disponible. Utilisez Request pour vérifier l’accès.", + requestsWebOnly: + "Les demandes de microphone de bureau sont uniquement disponibles sur le runtime Web.", + captureApiUnavailable: + "L'API de capture de microphone n'est pas disponible dans cet environnement.", + requestDenied: + "L'autorisation du microphone a été refusée par l'utilisateur ou le système.", + noDevice: "Aucun microphone n'a été trouvé.", + requestFailed: "Échec de la demande d'autorisation du microphone:{{message}}", + }, + empty: { + notifications: "L'état de la notification n'a pas encore été vérifié.", + microphone: "L'état du microphone n'a pas encore été vérifié.", + }, + testNotification: { + title: "Test de notificationPaseo", + body: "Si vous pouvez voir cela, les notifications sur le bureau fonctionnent.", + notDelivered: + "La notification n'a pas été délivrée. Vérifiez Paramètres système > Notifications.", + failed: "Échec de l'envoi de la notification.", + }, + }, + integrations: { + cli: { + statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", + installFailed: "Impossible d'installer lePaseoCLI.", + }, + skills: { + statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", + installFailed: "Impossible d'installer les compétences d'orchestration.", + updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", + uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", + }, + }, + }, + startup: { + errorTitle: "Quelque chose s'est mal passé", + errorDescription: + "Le serveur local n'a pas pu démarrer. Si cela continue, veuillez signaler le problème surGitHubet inclure les journaux ci-dessous.", + logs: { + loading: "Chargement des journaux du démon...", + unavailable: "Aucun journal de démon disponible.", + loadFailed: "Impossible de charger les journaux du démon:{{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "Ajouter un projet", + description: "Ouvrez un dossier sur votre machine", + }, + importSession: { + title: "Session d'importation", + description: "Apportez des sessionsCLIexternes récentes", + }, + setupProviders: { + title: "Fournisseurs d'installation", + description: "ConfigurezClaude Code,Codexet plus", + }, + pairDevice: { + title: "Associer un appareil", + description: "Connectez votre téléphone à ce démon", + }, + }, + }, + projectPicker: { + placeholder: "Tapez un chemin de répertoire...", + opening: "Projet d'ouverture...", + empty: "Commencez à taper un chemin", + }, + branchSwitcher: { + currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", + placeholder: "Changer de branche...", + searchPlaceholder: "Filtrer les branches...", + empty: "Aucune branche trouvée.", + title: "Changer de branche", + uncommittedTitle: "Modifications non validées", + uncommittedMessage: + "Vous avez des modifications non validées. Les ranger avant de changer de branche?", + stashAndSwitch: "Cachette et changement", + failedToStash: "Échec de la sauvegarde des modifications", + failedToSwitch: "Échec du changement de branche", + restoreStashTitle: "Restaurer les modifications cachées?", + restoreStashMessage: + "Cette branche a caché les modifications d'une session précédente. Souhaitez-vous les restaurer?", + restore: "Restaurer", + later: "Plus tard", + stashRestored: "Modifications cachées restaurées", + }, + agentAutocomplete: { + searchingWorkspace: "Recherche dans l'espace de travail...", + loadingCommands: "Chargement des commandes...", + noFiles: "Aucun fichier ou répertoire trouvé", + noCommands: "Aucune commande trouvée", + failedToLoad: "Échec du chargement", + }, + loadOlderHistory: { + failed: "Impossible de charger l'ancien historique", + }, + imageAttachmentPicker: { + permissionTitle: "Autorisation requise", + permissionMessage: "Veuillez autoriser l'accès à votre photothèque pour joindre des images.", + errorTitle: "Erreur", + failedToSelect: "Échec de la sélection de l'image", + dialogTitle: "Joindre des images", + dialogFilterName: "Images", + }, + workspaceSetup: { + title: "Créer un espace de travail", + errors: { + failedCreateWorktree: "Échec de la création de l'arbre de travail", + failedOpenProject: "Échec de l'ouverture du projet", + selectModel: "Sélectionnez un modèle", + hostDisconnected: "Hostn'est pas connecté", + pendingRequired: "Aucune configuration d'espace de travail n'est en attente", + composerStateRequired: "L'état du compositeur de configurationWorkspaceest requis", + }, + }, + onboarding: { + title: "Bienvenue surPaseo", + subtitle: "Connectez votre ordinateur pour commencer", + actions: { + settings: "Paramètres", + }, + }, + modelSelector: { + title: "Sélectionnez le fournisseur", + selectModel: "Sélectionnez le modèle", + selectedModel: "Sélectionnez le modèle ({{model}})", + loading: "Chargement...", + loadingShort: "Chargement", + loadingSelector: "Chargement du sélecteur de modèle...", + error: "Erreur", + defaultModel: "Défaut", + favorites: "Favoris", + favoriteModel: "Modèle préféré", + unfavoriteModel: "Modèle défavorisé", + modelCount: "Modèle{{count}}", + modelCountPlural: "Modèles{{count}}", + retry: "Réessayer", + retrying: "Nouvelle tentative...", + noMatches: "Aucun modèle ne correspond à votre recherche", + searchPlaceholder: "Rechercher des modèles...", + openProviderSettings: "Ouvrir les paramètres{{provider}}", + }, + providerCatalog: { + title: "Ajouter un fournisseur", + search: "Fournisseurs de recherche", + noProviders: "Aucun fournisseur trouvé", + actions: { + add: "Ajouter", + adding: "Ajout", + installed: "Installé", + cancel: "Annuler", + installInstructions: "Instructions d'installation", + installInstructionsFor: "Instructions d'installation{{provider}}", + }, + errors: { + unableToInstall: "Impossible d'installer le fournisseur", + }, + }, + providerSelection: { + defaultModel: "Défaut", + selectModel: "Sélectionnez le modèle", + loading: "Chargement...", + error: "Erreur", + unavailable: "Indisponible", + unknownError: "Erreur inconnue", + readiness: { + initialPromptRequired: "Une invite initiale est requise", + noProviders: "Aucun fournisseur disponible sur l'hébergeur sélectionné", + modelDefaultsLoading: "Les valeurs par défaut du modèle sont toujours en cours de chargement", + noModelAvailable: "Aucun modèle n'est disponible pour le fournisseur sélectionné", + workspaceDirectoryNotFound: "RépertoireWorkspaceintrouvable", + hostDisconnected: "Hostn'est pas connecté", + }, + }, + pairing: { + connectionMethods: { + title: "Ajouter une connexion", + direct: { + title: "Connexion directe", + description: "Réseau local ou VPN.", + }, + scanQr: { + title: "Scanner le codeQR", + description: "Connexion relais cryptée.", + }, + pasteLink: { + title: "Coller le lien d'association", + description: "Connexion relais cryptée.", + }, + }, + direct: { + title: "Connexion directe", + helper: "Saisissez l'adresse d'un serveurPaseo.", + fields: { + host: "Host", + port: "Port", + password: "Mot de passe", + optional: "Facultatif", + useSsl: "Utiliser SSL", + connectionUri: "URI de connexion", + }, + advanced: { + label: "Avancé", + show: "Afficher avancé", + hide: "Masquer avancé", + }, + passwordVisibility: { + show: "Afficher le mot de passe", + hide: "Masquer le mot de passe", + }, + actions: { + cancel: "Annuler", + connect: "Connecter", + connecting: "De liaison...", + }, + errors: { + hostRequired: "Hostest requis", + invalidPort: "Le port doit être compris entre 1 et 65535", + invalidConnection: "Connexion invalide", + failedTitle: "La connexion a échoué", + failedToConnect: "Nous n'avons pas réussi à nous connecter à{{endpoint}}.", + noAdditionalDetails: "{{detail}}(aucun détail supplémentaire fourni)", + timedOut: "La connexion a expiré. Vérifiez l'hôte/portet votre réseau.", + refused: "Connexion rejetée. Le serveur fonctionne-t-il à cette adresse?", + hostNotFound: "Hostintrouvable. Vérifiez le nom d'hôte et réessayez.", + hostUnreachable: "Hostest inaccessible. Vérifiez votre réseau et votre pare-feu.", + tlsError: + "Erreur TLS. Les connexions directes utilisent SSL uniquement lorsqu'un terminateur TLS se trouve devant le démon.", + unableToConnect: + "Impossible de se connecter. Vérifiez l'hôte/portet que le démon est accessible.", + details: "Détails:{{detail}}", + }, + }, + link: { + title: "Coller le lien d'association", + helper: "Collez le lien d'appairage depuis votre serveur.", + label: "Lien d'appariement", + errors: { + required: "Collez un lien d'appairage (.../#offer=...)", + missingOffer: "Le lien doit inclure#offer=...", + emptyOffer: "La charge utile de l'offre est vide", + invalid: "Lien d'association invalide", + unableToPair: "Impossible de coupler l'hôte", + }, + alert: { + failedTitle: "Échec du couplage", + }, + actions: { + cancel: "Annuler", + pair: "Paire", + pairing: "L'appariement...", + }, + }, + scan: { + title: "ScannerQR", + webUnavailableTitle: "Non disponible sur le Web", + webUnavailableBody: + "L'analyseQRn'est pas prise en charge dans la version Web. Utilisez plutôt \"Coller le lien\".", + backToSettings: "Retour aux paramètres", + cameraPermissionTitle: "Autorisation de la caméra", + cameraPermissionBody: + "Autorisez l'accès à la caméra pour scanner le code d'appairageQRà partir de votre démon.", + grantPermission: "Accorder l'autorisation", + pairing: "L'appariement...", + unableToPair: "Impossible de coupler l'hôte", + errorTitle: "Erreur", + }, + device: { + loadingOffer: "Chargement de l'offre d'association...", + failedToLoadOffer: "Échec du chargement de l'offre d'association.", + relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.", + unavailable: "Offre de jumelage indisponible.", + hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", + qrUnavailable: "CodeQRindisponible.", + retry: "Réessayer", + copy: "Copie", + copied: "Copié", + }, + }, + realtimeVoice: { + actions: { + mute: "Couper la voix en temps réel", + unmute: "Réactiver la voix en temps réel", + stop: "Arrêtez la voix en temps réel et interrompez le tour", + }, + }, + rewind: { + tooltip: "Revenez à ce message", + warning: "Cette action ne peut pas être annulée", + actions: { + conversation: "Rembobiner la conversation", + files: "Rembobiner les fichiers", + both: "Rembobiner la conversation et les fichiers", + }, + errors: { + failed: "Échec du rembobinage de l'agent", + }, + }, + diffViewer: { + empty: "Aucun changement à afficher", + }, + serviceUrl: { + title: "Service ouvertURL", + message: "Ouvrir{{url}}?", + inPaseo: "DansPaseo", + externalBrowser: "Navigateur externe", + dontAskAgain: "Ne demande plus", + }, + downloads: { + requestTokenFailed: "Échec de la demande du jeton de téléchargement.", + hostUnavailable: "L'hôte de téléchargement n'est pas disponible.", + cancelled: "Le téléchargement a été annulé.", + failed: "Échec du téléchargement du fichier.", + shareFile: "Partager un fichier", + shareFileNamed: "Partager{{fileName}}", + }, + menu: { + backdrop: "Toile de fond du menu", + }, + subagents: { + archiveAction: "Archiver{{label}}", + archiveTooltip: "Sous-agent d'archivage", + }, + panels: { + draft: { + newAgent: "NouveauAgent", + creatingAgent: "Agent créateur", + }, + file: { + executionDirectoryMissing: "Répertoire d'exécutionWorkspaceintrouvable.", + loading: "Chargement du fichier...", + noPreview: "Aucun aperçu disponible", + binaryPreviewUnavailable: "Aperçu binaire indisponible", + failedToLoad: "Échec du chargement du fichier", + failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", + }, + }, + toolCallDetails: { + error: "Erreur", + empty: "Aucun détail supplémentaire disponible", + subAgentActivity: "Activité du sous-agent", + input: "Saisir", + output: "Sortir", + }, + renameModal: { + rename: "Rebaptiser", + saving: "Économie...", + }, + sidebarCallout: { + dismiss: "Rejeter", + }, + contextWindow: { + title: "Fenêtre contextuelle", + used: "{{percentage}}% utilisé", + tokens: "Jetons{{used}}/{{max}}", + sessionCost: "Coût de la séance{{cost}}", + accessibility: "Fenêtre contextuelle{{percentage}}% utilisé", + }, + review: { + comment: { + add: "Ajouter un commentaire", + edit: "Modifier le commentaire de l'avis", + delete: "Supprimer le commentaire de l'avis", + label: "Revoir le commentaire", + placeholder: "Laisser un commentaire", + cancel: "Annuler", + cancelAccessibility: "Annuler le commentaire de révision", + save: "Commentaire", + saveAccessibility: "Enregistrer le commentaire de l'avis", + }, + }, + settings: { + title: "Paramètres", + loading: "Chargement des paramètres...", + groups: { + app: "Application", + host: "Host", + }, + hostPicker: { + switchHost: "Changer d'hôte", + local: "Locale", + }, + backToWorkspace: "Dos", + addHost: "Ajouter un hôte", + projects: "Projets", + projectList: { + hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}", + editProject: "Modifier{{projectName}}", + }, + groupInfo: "À propos de{{title}}", + sections: { + general: "Général", + daemon: "Daemon", + appearance: "Apparence", + shortcuts: "Raccourcis", + integrations: "Intégrations", + permissions: "Autorisations", + diagnostics: "Diagnostic", + about: "À propos", + }, + hostSections: { + connections: "Relations", + agents: "Agents", + workspaces: "Workspaces", + providers: "Fournisseurs", + host: "Host", + }, + general: { + title: "Général", + defaultSend: { + label: "Envoi par défaut", + description: + "Que se passe-t-il lorsque vous appuyez sur Entrée alors que l'agent est en cours d'exécution?", + options: { + interrupt: "Interrompre", + queue: "File d'attente", + }, + }, + serviceUrls: { + label: "URL de services", + description: "Où ouvrir les URL à partir de scripts en cours d'exécution", + options: { + ask: "Demander", + inApp: "DansPaseo", + external: "Navigateur externe", + }, + }, + terminalScrollback: { + label: "DéfilementTerminal", + description: "Lignes conservées dans le tampon du terminal intégré", + accessibilityLabel: "Lignes de défilementTerminal", + }, + language: { + label: "Langue", + description: "Langue de l'application", + options: { + system: "Système", + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + zhCN: "中文", + }, + }, + }, + diagnostics: { + title: "Diagnostic", + testAudio: "Tester le son", + playTest: "Jouer à l'essai", + playing: "Jouant...", + playbackFailed: "Échec de la lecture:{{message}}", + }, + about: { + title: "À propos", + appVersion: "Version de l'application", + thisDevice: "Cet appareil", + connectedHosts: "Hôtes connectés", + offline: "Hors ligne", + versionDiffers: "La version diffère de cet appareil", + releaseChannel: { + label: "Canal de sortie", + description: + "Passez àBetapour obtenir des mises à jour plus tôt et contribuer à les façonner", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "Mises à jour de l'application", + readyToInstall: "Prêt à installer:{{version}}", + installTitle: "Installer la mise à jour du bureau", + installMessage: "Cela met à jourPaseosur cet ordinateur", + installConfirm: "Installer la mise à jour", + update: "Mise à jour", + updateTo: "Mise à jour vers{{version}}", + installing: "Installation...", + check: "Vérifier", + checking: "Vérification...", + alertTitle: "Erreur", + alertMessage: "Impossible d'ouvrir la boîte de dialogue de confirmation de mise à jour.", + }, + }, + appearance: { + theme: { + title: "Thème", + accessibilityLabel: "Thème:{{value}}", + options: { + light: "Lumière", + dark: "Sombre", + zinc: "Zinc", + midnight: "Minuit", + claude: "Claude", + ghostty: "Fantôme", + auto: "Système", + }, + }, + fonts: { + title: "Polices", + systemDefault: "Valeur par défaut du système", + interfaceFont: "Police d'interface", + interfaceFontHint: + "Utilisé dans toute l'application. Laisser vide pour la valeur par défaut du système", + interfaceFontAccessibility: "Famille de polices d'interface", + interfaceSize: "Taille de l'interface", + interfaceSizeAccessibility: "Taille de la police de l'interface", + codeFont: "Police de code", + codeFontHint: + "Utilisé dans le code, les différences et la sortie du terminal. Laisser vide pour la valeur par défaut du système", + codeFontAccessibility: "Famille de polices de code", + codeSize: "Taille du code", + codeSizeAccessibility: "Taille de la police du code", + }, + syntax: { + title: "Syntaxe", + highlightTheme: "Thème de surbrillance", + highlightThemeHint: "Couleurs du code, indépendamment du thème de l'application", + highlightThemeAccessibility: "Thème phare:{{value}}", + previewAccessibility: "Aperçu en direct du thème de syntaxe et de la police de code", + }, + }, + shortcuts: { + dialogTitle: "Raccourcis", + unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", + capturePrompt: "Appuyez sur le raccourci...", + actions: { + done: "Fait", + cancel: "Annuler", + rebind: "Relier", + reset: "Réinitialiser", + resetAll: "Tout réinitialiser", + }, + sections: { + navigation: "Navigation", + tabsPanes: "Onglets et volets", + projects: "Projets", + panels: "Panneaux", + agentInput: "EntréeAgent", + }, + help: { + openProject: "Projet ouvert", + newWorktree: "Nouvel arbre de travail", + archiveWorktree: "Arbre de travail d'archivage", + newTab: "Nouvel onglet", + closeCurrentTab: "Fermer l'onglet actuel", + jumpToWorkspace: "Accéder à l'espace de travail", + jumpToTab: "Aller à l'onglet", + previousWorkspace: "Espace de travail précédent", + nextWorkspace: "Espace de travail suivant", + previousTab: "Onglet précédent", + nextTab: "Onglet suivant", + splitPaneRight: "Volet divisé à droite", + splitPaneDown: "Diviser le volet vers le bas", + focusPaneLeft: "Volet de mise au point à gauche", + focusPaneRight: "Volet de mise au point à droite", + focusPaneUp: "Volet de mise au point vers le haut", + focusPaneDown: "Volet de mise au point vers le bas", + moveTabLeft: "Déplacer l'onglet vers la gauche", + moveTabRight: "Déplacer l'onglet vers la droite", + moveTabUp: "Déplacer l'onglet vers le haut", + moveTabDown: "Déplacer l'onglet vers le bas", + closePane: "Fermer le volet", + newTerminal: "Nouvelle borne", + toggleCommandCenter: "Basculer le centre de commande", + showKeyboardShortcuts: "Afficher les raccourcis clavier", + toggleLeftSidebar: "Basculer la barre latérale gauche", + toggleRightSidebar: "Basculer la barre latérale droite", + toggleBothSidebars: "Basculer les deux barres latérales", + toggleSettings: "Basculer les paramètres", + toggleFocusMode: "Basculer le mode de mise au point", + cycleTheme: "Thème du cycle", + focusMessageInput: "Saisie du message de focus", + toggleVoiceMode: "Changer le mode vocal", + startStopDictation: "Démarrer la dictée/stop", + interruptAgent: "Agent d'interruption", + sendMessage: "Envoyer un message", + queueMessage: "Message de file d'attente", + muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + }, + helpNotes: { + showKeyboardShortcuts: + "Disponible lorsque le focus n’est pas dans un champ de texte ou un terminal.", + }, + }, + integrations: { + title: "Intégrations", + docs: { + cli: "DocumentsCLI", + skills: "Documents de compétences", + openCli: "Ouvrir la documentationCLI", + openSkills: "Documentation des compétences ouvertes", + }, + commandLine: { + title: "Ligne de commande", + description: "Agents de contrôle et de script depuis votre terminal", + }, + skills: { + title: "Compétences en orchestration", + description: "Apprenez à vos agents à orchestrer via leCLI", + updateAvailable: "Mise à jour disponible", + updateTitle: "Mettre à jour les compétencesPaseo?", + updateFallback: "Synchronisez les compétences regroupées sur votre machine.", + uninstallTitle: "Désinstaller les compétencesPaseo?", + uninstallMessage: + "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", + }, + actions: { + install: "Installer", + installing: "Installation...", + installed: "Installé", + update: "Mise à jour", + working: "Fonctionnement...", + uninstall: "Désinstaller", + }, + operations: { + add: "Ajouter une compétence", + update: "Mettre à jour la compétence", + delete: "Supprimer la compétence", + }, + }, + permissions: { + title: "Autorisations", + notifications: "Notifications", + microphone: "Microphone", + refresh: "Rafraîchir", + refreshing: "Rafraîchissant...", + refreshAccessibility: "Actualiser les autorisations du bureau", + test: "Test", + actions: { + granted: "Accordé", + request: "Demande", + requesting: "Demander...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "Hostintrouvable", + badges: { + relay: "Relais", + local: "Locale", + }, + connections: { + title: "Relations", + removeTitle: "Supprimer la connexion", + removeMessage: "Supprimer{{name}}? Cela ne peut pas être annulé.", + removeAction: "Retirer", + removeErrorTitle: "Erreur", + removeErrorMessage: "Impossible de supprimer la connexion", + timeout: "Temps mort", + }, + pairDevices: { + title: "Associer des appareils", + rowTitle: "Associer un appareil", + rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte", + }, + orchestration: { + title: "Orchestration", + unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration", + enableTools: { + title: "Activer les outilsPaseo", + hint: "Les agents pourront gérer les arbres de travail, les agents et les horaires", + accessibilityLabel: "Injecter les outilsPaseo", + }, + systemPrompt: { + title: "Invite système", + hint: "Ajoute une invite système à tous les agents", + sheetTitle: "Ajouter une invite système", + accessibilityLabel: "Ajouter une invite système", + placeholder: "Gardez toujours des réponses concises.", + }, + }, + agents: { + unavailable: "Connect to this host to manage agents", + }, + workspaces: { + unavailable: "Connect to this host to manage workspaces", + }, + daemon: { + rename: { + editLabel: "Modifier l'étiquette", + title: "Renommer l'hôte", + placeholder: "MonHost", + }, + restart: { + title: "Redémarrer le démon", + hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", + confirmTitle: "Redémarrer{{name}}", + confirmMessage: + "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", + restarting: "Redémarrage...", + unableToReconnectTitle: "Impossible de se reconnecter", + unableToReconnectMessage: + "{{name}}n'est pas revenu en ligne. Veuillez vérifier qu'il a redémarré.", + unavailableTitle: "Hostindisponible", + unavailableMessage: + "Cet hôte n'est pas connecté. Attendez qu'il soit en ligne avant de redémarrer.", + offlineTitle: "Hosthors ligne", + offlineMessage: + "Cet hôte est hors ligne.Paseose reconnecte automatiquement: attendez qu'il soit de nouveau en ligne avant de redémarrer.", + requestFailedTitle: "Erreur", + requestFailedMessage: + "Échec de l'envoi de la demande de redémarrage.Paseose reconnecte automatiquement - réessayez une fois que l'hôte apparaît comme en ligne.", + dialogFailedMessage: + "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.", + }, + dangerZone: "Zone dangereuse", + remove: { + title: "Supprimer l'hôte", + localTitle: "Remove localhost connection", + hint: "Supprime cet hôte et ses connexions enregistrées de cet appareil", + localHint: "Removes localhost from this device and stops the built-in daemon", + localConfirmTitle: "Remove localhost connection and stop daemon?", + confirmMessage: "Supprimer{{name}}? Cela supprimera ses connexions enregistrées.", + localConfirmMessage: + "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + errorTitle: "Erreur", + errorMessage: "Impossible de supprimer l'hôte", + localErrorMessage: "Unable to remove localhost connection", + }, + }, + }, + providers: { + title: "Fournisseurs", + addProvider: "Ajouter un fournisseur", + providerDetails: "Détails du fournisseur{{name}}", + enableProvider: "Activer{{name}}", + unavailable: "Connectez-vous à cet hôte pour voir les fournisseurs", + loading: "Chargement...", + addErrorTitle: "Unable to add provider", + updateErrorTitle: "Impossible de mettre à jour le fournisseur", + statuses: { + disabled: "Désactivé", + loading: "Chargement", + error: "Erreur", + available: "Disponible", + notInstalled: "Non installé", + }, + models: { + one: "1 modèle", + many: "Modèles{{count}}", + addModel: "Ajouter un modèle", + addCustomTitle: "Ajouter un modèle personnalisé", + modelId: "ModèleID", + modelIdPlaceholder: "par ex. ouvert/gpt-5", + add: "Ajouter", + adding: "Ajout...", + failedToSave: "Échec de l'enregistrement du modèle", + removeModel: "Supprimer{{id}}", + searchPlaceholder: "Rechercher des modèles", + loading: "Chargement des modèles...", + retry: "Réessayer", + retrying: "Nouvelle tentative...", + noSearchMatches: "Aucun modèle ne correspond à votre recherche", + noneDetected: "Aucun modèle détecté", + discovered: "Découvert", + custom: "Modèles personnalisés", + updated: "{{time}}mis à jour", + }, + diagnostic: { + title: "Diagnostique", + button: "Diagnostique", + refresh: "Rafraîchir", + refreshing: "Rafraîchissant...", + refreshAccessibility: "Actualiser le diagnostic", + refreshingAccessibility: "Diagnostic rafraîchissant", + running: "Exécution du diagnostic...", + none: "Aucun diagnostic disponible", + failedToFetch: "Échec de la récupération du diagnostic", + unknownError: "Erreur inconnue", + }, + }, + project: { + noEditableTarget: + "Nous n'avons pas de copie modifiable de ce projet sur aucun hôte connecté.", + backToProjects: "Retour aux projets", + switchHost: "Changer d'hôte", + rename: { + renamedToast: "Projet renommé", + errorFallback: "Impossible de renommer le projet", + renameLabel: "Renommer le projet", + resetLabel: "Réinitialiser le nom du projet par défaut", + projectNameLabel: "Nom du projet", + saveLabel: "Enregistrer le nom du projet", + cancelLabel: "Annuler le changement de nom", + reset: "Réinitialiser", + }, + readFailures: { + invalidTitle: "paseo.json n'a pas pu être analysé", + invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", + missingTitle: "Cet hôte n'a pas ce projet", + missingWithHosts: "Basculez vers un autre hôte ci-dessus ou rechargez.", + missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.", + transportTitle: "Impossible de charger paseo.json", + transportFallback: "L'hôte n'a pas répondu.", + failedTitle: "Impossible de charger paseo.json", + failedDescription: "Rechargez pour réessayer.", + }, + worktree: { + title: "Crochets de cycle de vie Worktree", + info: "Commandes exécutées lorsqu'un arbre de travail est créé ou supprimé pour ce projet", + docs: "Documents", + docsTooltip: + "Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes", + setup: "Installation", + setupAccessibility: "Commandes de configuration de Worktree", + teardown: "Démolir", + teardownAccessibility: "Commandes de démontage de Worktree", + }, + scripts: { + title: "Scripts", + info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", + empty: "Pas encore de scripts.", + untitled: "Script sans titre", + port: "port{{port}}", + menuAccessibility: "Ouvrir le menu des scripts", + removeTitle: "Supprimer le script?", + removeMessage: "Supprimer{{name}}?", + removeFallbackName: "ce scénario", + name: "Nom", + command: "Commande", + nameAccessibility: "Nom du script", + commandAccessibility: "Commande de script", + nameRequired: "Le nom est requis", + commandRequired: "La commande est requise", + newScript: "Nouveau scénario", + editScript: "Modifier{{name}}", + runAsService: "Exécuter en tant que service", + serviceHint: "Paseosupervise le processus et attribue un port via $PASEO_PORT", + actions: { + add: "Ajouter un script", + edit: "Modifier", + remove: "Retirer", + }, + }, + metadata: { + title: "Génération de métadonnées", + info: "Instructions spécifiques au projet injectées dans les invites de l'IA quePaseoutilise pour générer des métadonnées: utilisez-les pour appliquer les conventions de votre équipe telles que la dénomination des branches, le style de validation ou le formatPR.", + agentTitle: "TitresAgent", + agentTitlePlaceholder: "Gardez les titres impératifs et inférieurs à 40 caractères", + branchName: "Noms des succursales", + branchNamePlaceholder: + "Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles", + commitMessage: "Valider les messages", + commitMessagePlaceholder: "Utiliser des commits conventionnels avec une portée", + pullRequest: "Demandes de tirage", + pullRequestPlaceholder: + "Commencez avec un résumé d'un paragraphe, incluez une section sur le plan de test", + }, + writeFailures: { + staleTitle: "Configuration modifiée sur le disque", + staleDescription: "Rechargez pour récupérer le dernier paseo.json avant de sauvegarder.", + failedTitle: "Impossible d'enregistrer paseo.json", + failedDescription: "Réessayez ou rechargez la dernière version à partir du disque.", + }, + actions: { + reload: "Recharger", + tryAgain: "Essayer à nouveau", + save: "Sauvegarder", + saved: "Projet enregistré", + saving: "Économie...", + cancel: "Annuler", + }, + }, + }, +}; diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts new file mode 100644 index 000000000..525708d18 --- /dev/null +++ b/packages/app/src/i18n/resources/ru.ts @@ -0,0 +1,1837 @@ +import type { TranslationResources } from "./en"; + +export const ru: TranslationResources = { + common: { + back: "Назад", + loading: "Загрузка...", + actions: { + back: "Назад", + cancel: "Отмена", + close: "Закрывать", + copy: "Копировать", + dismiss: "Увольнять", + retry: "Повторить попытку", + search: "Поиск", + select: "Выбирать", + }, + placeholders: { + search: "Поиск...", + }, + empty: { + noResults: "Результаты не найдены", + noOptionsMatchSearch: "Нет вариантов, соответствующих вашему запросу.", + }, + states: { + loading: "Загрузка...", + starting: "Начало...", + copied: "Скопировано", + copiedLabel: "Скопировано{{label}}", + downloadComplete: "Загрузка завершена", + downloadFailed: "Загрузка не удалась", + }, + errors: { + error: "Ошибка", + unableToSave: "Не удалось сохранить", + nameRequired: "Требуется имя", + daemonUnavailable: "Daemon недоступен", + daemonClientUnavailable: "Клиент Daemon недоступен", + daemonClientDisconnected: "Клиент Daemon отключен", + noFileFound: "Файл для{{token}}не найден", + unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.", + }, + connectionStatus: { + online: "Онлайн", + connecting: "Подключение", + offline: "Оффлайн", + error: "Ошибка", + idle: "Праздный", + }, + }, + shell: { + menu: { + toggleSidebar: "Переключить боковую панель", + open: "Открыть меню", + close: "Закрыть меню", + }, + commandCenter: { + placeholder: "Введите команду или найдите агентов...", + noMatches: "Нет совпадений", + actions: "Действия", + agents: "Агенты", + newAgent: "Новый агент", + openProject: "Открыть проект", + home: "Дом", + }, + }, + composer: { + placeholders: { + desktop: "Напишите агенту сообщение, отметьте @files или используйте /commands и /skills.", + mobile: "Сообщение,@files,/commands", + fallback: "Сообщение...", + }, + input: { + accessibilityLabel: "Агент сообщений...", + focusHint: "{{shortcut}}, чтобы сосредоточиться", + addAttachment: "Добавить вложение", + interruptAgent: "Агент прерываний", + queueMessage: "Сообщение в очереди", + sendAndInterrupt: "Отправить и прервать", + sendMessage: "Отправить сообщение", + queue: "Очередь", + send: "Отправлять", + }, + cancel: { + cancelingAgent: "Отменяющий агент", + stopAgent: "Остановить агент", + interrupt: "Прерывать", + }, + voice: { + enableVoiceMode: "Включить голосовой режим", + voiceMode: "Голосовой режим", + unmuteVoiceMode: "Включить голосовой режим", + muteVoiceMode: "Отключить голосовой режим", + stopDictation: "Остановить диктовку", + startDictation: "Начать диктовку", + unmuteVoice: "Включить звук", + muteVoice: "Отключить голос", + dictation: "Диктант", + interruptBeforeVoice: "Прерывайте агента перед запуском голосового режима", + }, + attachments: { + addImage: "Добавить изображение", + addIssueOrPr: "Добавить проблему или PR", + dropImagesHere: "Скиньте изображения сюда", + editQueuedMessage: "Изменить сообщение в очереди", + sendQueuedMessageNow: "Отправить сообщение в очереди сейчас", + openImage: "Открыть прикрепленное изображение", + removeImage: "Удалить прикрепленное изображение", + openGithub: "Открыть{{kind}}#{{number}}", + removeGithub: "Удалить{{kind}}#{{number}}", + browserElement: "Элемент ·{{tag}}", + openBrowserElement: "Открыть вложение элемента браузера", + removeBrowserElement: "Удалить вложение элемента браузера", + openReview: "Открыть прикрепленный файл с отзывом", + removeReview: "Удалить прикрепленный отзыв", + }, + errors: { + failedToSend: "Не удалось отправить сообщение", + failedToCreateAgent: "Не удалось создать агента.", + noHostSelected: "Хост не выбран", + initialPromptRequired: "Требуется начальное приглашение", + alreadyLoading: "Уже загружается", + }, + clientCommands: { + archiveAgent: "Архивировать текущего агента", + freshDraft: "Архивируйте этот агент и начните новый черновик", + }, + github: { + searching: "Идет поиск...", + noResults: "Результаты не найдены.", + searchPlaceholder: "Поиск проблем и пиар...", + title: "Прикрепите проблему или PR", + }, + }, + agentControls: { + provider: { + fallback: "Поставщик", + select: "Выберите поставщика агентов", + }, + thinking: { + title: "мышление", + unknown: "Неизвестный", + extraHigh: "Очень высокий", + select: "Выберите вариант мышления", + selectWithValue: "Выберите вариант мышления ({{value}})", + }, + model: { + unknown: "Неизвестная модель", + }, + features: { + title: "Функции", + open: "Открытые возможности агента", + on: "На", + off: "Выключенный", + }, + mode: { + title: "Режим", + searchPlaceholder: "Режимы поиска...", + selectWithValue: "Выберите режим агента ({{value}})", + }, + hints: { + thinking: "Режим мышления", + model: "Изменить модель", + mode: "Изменить режим разрешений", + }, + }, + agentStream: { + empty: "Начните общаться с этим агентом...", + scrollToBottom: "Прокрутить вниз", + permission: { + plan: "План", + required: "Требуется разрешение", + deny: "Отрицать", + accept: "Принимать", + implement: "Осуществлять", + question: "Как бы вы хотели продолжить?", + proposedPlan: "Предлагаемый план", + }, + }, + agentPanel: { + states: { + notFound: "Agent не найден", + failedToLoad: "Не удалось загрузить агент", + reconnecting: "Повторное подключение...", + archivingTitle: "Архивный агент...", + archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.", + }, + unavailable: { + selectedHost: "Выбранный хост", + unknownHost: + "Невозможно открыть этот агент, поскольку{{serverLabel}}не настроен на этом устройстве.", + addHost: + "Добавьте хост в настройках или откройте агент на настроенном сервере, чтобы продолжить.", + preparingSession: "Подготовка сеанса{{serverLabel}}...", + connecting: "Подключение к{{serverLabel}}...", + showSoon: "Мы покажем этого агента через минуту.", + showWhenOnline: "Мы покажем этого агента, как только хост будет онлайн.", + reconnectingTo: "Повторное подключение к{{serverLabel}}...", + showAgainWhenReachable: "Мы снова покажем этого агента, как только хост станет доступен.", + }, + archived: { + callout: "Этот агент находится в архиве", + unarchive: "Разархивировать", + }, + }, + sessions: { + title: "Сессии", + empty: "Сеансов пока нет", + actions: { + loadMore: "Загрузить больше", + }, + }, + agentList: { + fallbackTitle: "Новая сессия", + dateSections: { + recent: "Недавний", + today: "Сегодня", + yesterday: "Вчера", + thisWeek: "На этой неделе", + thisMonth: "В этом месяце", + older: "Старше", + }, + status: { + initializing: "Начало", + idle: "Праздный", + running: "Бег", + error: "Ошибка", + closed: "Закрыто", + }, + badges: { + archived: "В архиве", + pending: "{{count}}на рассмотрении", + attention: "Внимание", + }, + archiveSheet: { + hostOffline: "Host оффлайн", + runningAgent: "Этот агент все еще работает. Архивирование остановит агент.", + archive: "Архив", + }, + }, + message: { + actions: { + copyCode: "Скопировать код", + copyTurn: "Копировать ход", + copyMessage: "Копировать сообщение", + openFile: "Открыть файл", + copied: "Скопировано", + }, + attachments: { + dismissImage: "Закрыть изображение", + closeImage: "Закрыть изображение", + imageLoadFailed: "Не удалось загрузить изображение", + imageUnavailable: "Изображение недоступно", + imagePreviewUnavailable: "Предварительный просмотр изображения недоступен.", + imagePreviewLoadFailed: "Невозможно загрузить предварительный просмотр изображения.", + reviewOne: "Отзыв · 1 комментарий", + reviewMany: "Обзор · Комментарии{{count}}", + textAttachment: "Текстовое вложение", + }, + speak: { + header: "Говорил", + }, + activity: { + details: "Подробности", + }, + dictation: { + start: "Начать голосовой диктовку", + cancel: "Отменить диктовку", + retry: "Повторить диктовку", + insert: "Вставить транскрипцию", + insertAndSend: "Вставьте транскрипцию и отправьте", + failed: "Диктовка не удалась:{{error}}", + failedRetry: "Диктант не удался. Нажмите «Повторить».", + }, + question: { + submit: "Представлять на рассмотрение", + next: "Далее", + answerPlaceholder: "Введите ответ...", + otherPlaceholder: "Другой...", + }, + todo: { + title: "Задачи", + empty: "Заданий пока нет.", + }, + compaction: { + loading: "Уплотнение...", + auto: "Контекст автоматически сжимается", + manual: "Контекст сжимается вручную", + withTokens: "Сжатый контекст (токены{{tokens}}K)", + completed: "Контекст сжат", + }, + }, + importSession: { + title: "Импортировать сеанс", + filters: { + all: "Все", + }, + status: { + connectHost: "Подключитесь к хосту, чтобы импортировать сеансы", + updateHost: "Обновите хост для импорта сеансов.", + noProviders: "Импортируемые поставщики не включены.", + loading: "Загрузка последних сеансов...", + failedAll: "Не удалось загрузить последние сеансы.", + failedProviders: "Не удалось загрузить сеансы для{{providers}}.", + failedImport: "Не удалось импортировать выбранный сеанс.", + }, + actions: { + refresh: "Обновить сеансы", + }, + preview: { + untitledSession: "Сессия без названия", + noPrompt: "Нет быстрого предварительного просмотра", + }, + empty: { + noRecent: "Нет последних сеансов для импорта.", + alreadyImported: "Все последние сеансы уже импортированы.", + noProviderSessions: "Сеансы{{provider}}не найдены.", + }, + row: { + importing: "Импорт...", + }, + }, + workspace: { + route: { + loading: "Загрузка рабочей области", + connecting: "Подключение", + hostOffline: "{{hostName}}не в сети", + cannotReachHost: "Невозможно связаться с{{hostName}}", + hostStatus: "Статус Host:{{status}}", + missing: "Workspace не найден", + manageHost: "Управление хостом", + }, + hoverCard: { + scriptsAccessibility: "Скрипты Workspace", + }, + fileExplorer: { + sort: { + name: "Имя", + modified: "Модифицированный", + size: "Размер", + }, + context: { + size: "Размер", + modified: "Модифицированный", + copyPath: "Копировать путь", + download: "Скачать", + }, + actions: { + back: "Назад", + retry: "Повторить попытку", + refresh: "Обновить файлы", + refreshing: "Обновление файлов", + }, + empty: { + noFiles: "Нет файлов", + }, + states: { + unavailable: "Workspace недоступен", + loading: "Загрузка файлов...", + }, + errors: { + failedToListDirectory: "Не удалось указать каталог", + }, + }, + setup: { + descriptor: { + label: "Настраивать", + completed: "Настройка завершена", + failed: "Установка не удалась", + workspace: "Настройка Workspace", + }, + status: { + running: "Бег", + completed: "Завершенный", + failed: "Неуспешный", + waiting: "Ожидание вывода настройки", + }, + waiting: "Настройка рабочего места...", + empty: { + noCommands: "Для этой рабочей области не выполнялись команды настройки.", + }, + accessibility: { + noCommands: "Для этой рабочей области не выполнялись команды настройки.", + log: "Журнал настройки Workspace", + }, + log: { + noOutput: "Нет вывода", + }, + }, + browser: { + unavailable: { + title: "Браузер доступен только на рабочем столе", + subtitle: + "Откройте это рабочее пространство в Electron, чтобы использовать встроенный браузер.", + }, + session: "Сеанс браузера{{browserId}}", + controls: { + back: "Назад", + forward: "Вперед", + stopLoading: "Остановить загрузку", + refresh: "Обновить", + browserUrl: "Браузер URL", + enterUrl: "Введите URL", + openDevTools: "Открыть инструменты разработки браузера", + cancelSelector: "Отменить выбор элемента", + selectElement: "Выберите элемент", + }, + errors: { + failedToLoad: "Не удалось загрузить страницу", + invalidUrl: "Неверный браузер URL", + unsupportedProtocol: "Заблокирован неподдерживаемый браузер URL:{{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Host не подключен", + unableToSubscribe: "Невозможно подписаться на терминал", + }, + tabs: { + loading: "Загрузка...", + loadingAgentTitle: "Название агента загрузки", + emptyPane: "На этой панели нет вкладок.", + fallback: { + newAgent: "Новый Agent", + setup: "Настраивать", + workspaceSetup: "Настройка Workspace", + terminal: "Terminal", + browser: "Браузер", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "Переключить вкладки ({{count}}открыт)", + title: "Переключить вкладку", + searchPlaceholder: "Вкладки поиска", + }, + menu: { + openFor: "Открыть меню для{{label}}", + copyResumeCommand: "Копировать команду возобновления", + copyAgentId: "Скопировать идентификатор агента", + rename: "Переименовать", + closeAbove: "Закрыть вкладки выше", + closeBelow: "Закройте вкладки ниже", + closeLeft: "Ближе к левому краю", + closeRight: "Ближе к правому", + closeOthers: "Закрыть другие вкладки", + reloadAgent: "Перезагрузить агент", + reloadAgentTooltip: "Перезагрузите агента, чтобы обновить навыки, MCP или статус входа.", + close: "Закрывать", + renameTerminal: "Переименование терминала", + renameAgent: "Переименовать агента", + }, + actions: { + newAgent: "Новая вкладка агента", + newTerminal: "Новая вкладка терминала", + preparingTerminal: "Подготовка вкладки терминала", + preparingTerminalTooltip: "Подготовка терминала...", + newBrowser: "Новая вкладка браузера", + splitRight: "Разделить панель справа", + splitDown: "Разделить панель вниз", + }, + explorer: { + open: "Открыть проводник", + close: "Закрыть проводник", + toggle: "Переключить проводник", + changes: "Изменения", + files: "Файлы", + }, + toasts: { + copyFailed: "Не удалось скопировать", + agentIdCopiedLabel: "AgentID", + resumeCommandCopiedLabel: "команда возобновления", + resumeIdUnavailable: "Резюме ID недоступно", + resumeCommandUnavailable: "Команда возобновления недоступна", + reloadingAgent: "Перезагрузка агента...", + reloadedAgent: "Перезагруженный агент", + failedToReloadAgent: "Не удалось перезагрузить агент", + }, + confirmations: { + close: "Закрывать", + cancel: "Отмена", + archive: "Архив", + closeTerminalTitle: "Закрыть терминал?", + closeTerminalMessage: + "Любой запущенный процесс в этом терминале будет немедленно остановлен.", + archiveRunningAgentTitle: "Агент запуска архива?", + archiveRunningAgentMessage: + "Этот агент все еще работает. Архивирование остановит агент и закроет вкладку.", + closeTabsLeftTitle: "Закрыть вкладки слева?", + closeTabsRightTitle: "Закрыть вкладки справа?", + closeOtherTabsTitle: "Закрыть другие вкладки?", + bulk: { + all: "При этом агенты{{agents}}будут заархивированы, терминалы{{terminals}}закроются, а вкладки{{tabs}}будут закрыты. Любой запущенный процесс в закрытом терминале будет немедленно остановлен.", + agentsAndTerminals: + "Это приведет к архивированию агентов{{agents}}и закрытию терминалов{{terminals}}. Любой запущенный процесс в закрытом терминале будет немедленно остановлен.", + terminalsAndTabs: + "Это закроет терминал(ы){{terminals}}и закроет вкладки(и){{tabs}}. Любой запущенный процесс в закрытом терминале будет немедленно остановлен.", + agentsAndTabs: + "При этом агенты{{agents}}будут заархивированы, а вкладки{{tabs}}закроются.", + terminals: + "Это закроет терминал(ы){{terminals}}. Любой запущенный процесс в закрытом терминале будет немедленно остановлен.", + tabs: "Это закроет вкладки{{tabs}}.", + agents: "Это приведет к архивированию агентов{{agents}}.", + }, + }, + }, + header: { + actions: { + workspaceActions: "Действия Workspace", + newAgent: "Новый агент", + newTerminal: "Новый терминал", + newBrowser: "Новая вкладка браузера", + importSession: "Импортировать сеанс", + copyPath: "Копировать путь к рабочей области", + copyBranchName: "Скопировать название ветки", + showSetup: "Показать настройки", + }, + toasts: { + workspacePathUnavailable: "Путь Workspace пока недоступен.", + branchNameUnavailable: "Название филиала недоступно", + terminalQueued: "Подготовка рабочего пространства, открытие терминала по готовности...", + workspacePathCopiedLabel: "Путь Workspace", + branchNameCopiedLabel: "Название филиала", + }, + }, + scripts: { + title: "Скрипты", + actions: { + run: "Бегать", + view: "Вид", + }, + accessibility: { + trigger: "Скрипты Workspace", + openAt: "Откройте{{scriptName}}на{{label}}", + viewTerminal: "Посмотреть терминал{{scriptName}}", + runScript: "Запустите скрипт{{scriptName}}", + script: "скрипт{{scriptName}}", + }, + states: { + exitCode: "выйти из{{code}}", + startFailed: "Не удалось запустить{{scriptName}}", + }, + }, + git: { + actions: { + moreOptions: "Больше возможностей", + moreActions: "Дополнительные действия", + commit: { + label: "Совершить", + pending: "Совершение...", + success: "Преданный идее", + }, + pull: { + label: "Тянуть", + pending: "Тяну...", + success: "Вытащил", + }, + push: { + label: "Толкать", + pending: "Толкаю...", + success: "Нажатый", + }, + pullAndPush: { + label: "Тяни и толкай", + pending: "Тянет и толкает...", + success: "Вытащил и толкнул", + }, + viewPr: "Посмотреть PR", + createPr: { + label: "Создать PR", + pending: "Создание PR...", + success: "PR создано", + }, + mergeBranch: { + label: "Объединить локально", + pending: "Слияние...", + success: "Объединено", + }, + mergeFromBase: { + label: "Обновление от{{baseRef}}", + pending: "Обновление...", + success: "Обновлено", + }, + archive: { + label: "Архив рабочего дерева", + pending: "Архивирование...", + success: "В архиве", + }, + mergePr: { + squash: "Сжать и объединить", + merge: "Создать коммит слияния", + rebase: "Перебазировать и объединить", + pending: "Объединение PR...", + success: "PR объединен", + }, + autoMerge: { + enableSquash: "Включить автоматическое объединение со сквошом", + enableMerge: "Включить автоматическое слияние с фиксацией слияния", + enableRebase: "Включить автоматическое слияние с перебазированием", + enabled: "Автоматическое объединение включено", + enabling: "Включение автоматического объединения...", + disabling: "Отключение автоматического объединения...", + disabled: "Автоматическое объединение отключено", + }, + unavailable: { + viewPrNoGithub: "Просмотр PR сейчас недоступен, поскольку GitHub не подключен.", + pullNoRemote: + "Функция Pull здесь недоступна, поскольку эта ветка еще не подключена к удаленному серверу.", + pullDirty: + "Функция Pull недоступна, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.", + pullUpToDate: "Функция Pull недоступна, поскольку эта ветка уже обновлена.", + pushNoRemote: + "Push здесь недоступен, поскольку эта ветка еще не подключена к удаленному устройству.", + pushBehind: "Push пока недоступен, поскольку сначала нужно внести новые изменения.", + pushNothing: "Push недоступен, поскольку нет ничего нового для отправки.", + pullAndPushNoRemote: + "Функция Pull and Push здесь недоступна, поскольку эта ветка еще не подключена к удаленному устройству.", + pullAndPushDirty: + "Функция извлечения и отправки недоступна, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.", + pullAndPushInSync: + "Функция Pull and Push недоступна, поскольку эта ветвь уже синхронизирована.", + createPrNoGithub: + "Функция «Создать PR» сейчас недоступна, поскольку GitHub не подключен.", + createPrNoCommits: + "Функция «Создать PR» недоступна, поскольку в этой ветке еще нет новых коммитов.", + mergeNoBase: "Объединение недоступно, поскольку нам не удалось определить базовую ветку.", + mergeDirty: + "Объединение недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.", + mergeNothing: + "Объединение недоступно, поскольку в этой ветке еще нет ничего нового для объединения.", + updateNoBase: "Обновление недоступно, поскольку нам не удалось определить базовую ветку.", + updateDirty: + "Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.", + updateCurrent: + "Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.", + archiveNotWorktree: + "Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.", + mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.", + mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение", + mergePrDraft: + "Объединение PR недоступно, поскольку запрос на включение все еще находится на стадии черновика.", + mergePrMerged: "Объединение PR недоступно, поскольку запрос на включение уже объединен.", + mergePrClosed: "Объединение PR недоступно, поскольку запрос на включение закрыт.", + mergePrConflicts: + "Объединение PR недоступно, поскольку запрос на включение содержит конфликты.", + mergePrQueue: + "Слияние PR здесь недоступно, поскольку этот репозиторий использует очередь слияния.", + mergePrNotReady: + "Функция слияния PR недоступна до тех пор, пока GitHub не сообщит, что запрос на включение готов к слиянию.", + autoMergeCannotDisable: + "Автоматическое объединение включено, но этот аккаунт не может его отключить.", + }, + toasts: { + failedCommit: "Не удалось совершить фиксацию", + failedPull: "Не удалось вытащить", + failedPush: "Не удалось нажать", + failedPullAndPush: "Не удалось тянуть и толкать", + failedCreatePr: "Не удалось создать PR.", + failedMergePr: "Не удалось объединить PR.", + failedEnableAutoMerge: "Не удалось включить автоматическое объединение", + failedDisableAutoMerge: "Не удалось отключить автоматическое объединение", + baseRefUnavailable: "Базовый номер недоступен.", + failedMerge: "Не удалось объединиться", + failedMergeFromBase: "Не удалось объединиться с базой.", + worktreePathUnavailable: "Путь к рабочему дереву недоступен.", + failedArchive: "Не удалось заархивировать рабочее дерево.", + }, + archiveWarning: { + title: 'Архив "{{worktreeName}}"?', + confirm: "Архив", + cancel: "Отмена", + uncommittedChanges: "Незафиксированные изменения", + uncommittedChangesWithDiff: "Незафиксированные изменения ({{diffStat}})", + addedLine: "{{count}}добавлена ​​строка", + addedLines: "{{count}}добавил строки", + deletedLine: "{{count}}удалена строка", + deletedLines: "{{count}}удалил строки", + unpushedCommit: "Неотправленная фиксация{{count}}", + unpushedCommits: "Неотправленные коммиты{{count}}", + }, + }, + diff: { + binaryFile: "Бинарный файл", + tooLarge: "Разница слишком велика для отображения", + unified: "Единый дифференциал", + split: "Параллельная разница", + hideWhitespace: "Скрыть пробелы", + scrollLongLines: "Прокручивать длинные строки", + wrapLongLines: "Перенос длинных строк", + collapseAll: "Свернуть все файлы", + expandAll: "Развернуть все файлы", + refreshing: "Освежающий", + refresh: "Обновить", + refreshState: "Обновить состояние git и GitHub.", + failedRefresh: "Не удалось обновить состояние git.", + emptyHiddenWhitespace: "Никаких видимых изменений после скрытия пробелов", + emptyUncommitted: "Нет незафиксированных изменений", + emptyAgainstBase: "Никаких изменений по сравнению с{{baseRef}}", + checkingRepository: "Проверяем репозиторий...", + notRepository: "Не git- репозиторий", + diffMode: "Режим разницы", + uncommitted: "Незафиксированный", + committed: "Преданный идее", + branchUnknown: "Неизвестный", + base: "база", + newFile: "Новый", + deletedFile: "Удалено", + }, + openInEditor: { + open: "Открыть", + chooseEditor: "Выбрать редактор", + openIn: "Открыть рабочую область в{{target}}", + openFileIn: "Open {{fileName}} in {{target}}", + failedOpen: "Не удалось открыть рабочую область", + }, + pr: { + sections: { + checks: "Чеки", + reviews: "Отзывы", + }, + accessibility: { + pullRequest: "Запрос на извлечение №{{number}}", + }, + states: { + draft: "Черновик", + merged: "Объединено", + closed: "Закрыто", + open: "Открыть", + }, + activity: { + commented: "Прокомментировал", + approved: "Одобренный", + requestedChanges: "Запрошенные изменения", + reviewed: "Рассмотрено", + }, + time: { + justNow: "прямо сейчас", + }, + errors: { + statusLoadFailed: "Невозможно загрузить статус запроса на включение", + activityLoadFailed: "Невозможно загрузить активность запроса на включение", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "Нет хоста", + switchTitle: "Сменить хост", + searchPlaceholder: "Поиск хостов...", + }, + actions: { + addProject: "Добавить проект", + home: "Дом", + settings: "Настройки", + closeSidebar: "Закрыть боковую панель", + }, + sections: { + sessions: "Сессии", + }, + worktreeSetup: { + title: "Настройка сценариев рабочего дерева", + description: + "Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.", + openProjectSettings: "Открыть настройки проекта", + }, + project: { + actions: { + menu: "Действия проекта", + openSettings: "Открыть настройки проекта", + openNewWindow: "Open in new window", + openNewWindowFailed: "Couldn't open a new window", + remove: "Удалить проект", + removing: "Удаление...", + }, + confirmations: { + removeTitle: "Удалить проект?", + removeMessage: + "Удалить «{{projectName}}» с боковой панели?\n\n Файлы на диске не будут изменены.", + removeConfirm: "Удалять", + cancel: "Отмена", + }, + toasts: { + hostDisconnected: "Host не подключен", + removeFailed: "Не удалось удалить некоторые рабочие области.", + }, + empty: { + title: "Пока нет проектов", + description: "Добавьте проект, чтобы начать", + }, + }, + workspace: { + status: { + scriptsAvailable: "Доступны скрипты", + creating: "Создание...", + }, + actions: { + menu: "Действия Workspace", + newWorkspace: "Новое рабочее пространство", + createWorkspaceFor: "Создайте новое рабочее пространство для{{projectName}}.", + copyPath: "Копировать путь", + copyBranchName: "Скопировать название ветки", + rename: "Переименовать рабочую область", + archive: "Архив", + archiveWorktree: "Архив рабочего дерева", + hideFromSidebar: "Скрыть с боковой панели", + archiving: "Архивирование...", + hiding: "Скрытие...", + }, + confirmations: { + hideTitle: "Скрыть рабочее пространство?", + hideMessage: + "Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.", + hideConfirm: "Скрывать", + cancel: "Отмена", + }, + rename: { + title: "Переименовать рабочую область", + submit: "Переименовать", + invalidBranchName: "Неверное название ветки", + }, + toasts: { + workspacePathUnavailable: "Путь Workspace недоступен", + pathCopied: "Путь скопирован", + branchNameCopied: "Название филиала скопировано.", + hostDisconnected: "Host не подключен", + hideFailed: "Не удалось скрыть рабочую область.", + archiveFailed: "Не удалось заархивировать рабочее дерево.", + }, + }, + }, + newWorkspace: { + title: "Новое рабочее пространство", + create: "Создавать", + errors: { + hostDisconnected: "Host не подключен", + createWorktreeFailed: "Не удалось создать рабочее дерево.", + composerStateRequired: "Требуется состояние композитора.", + selectModel: "Выберите модель", + }, + refPicker: { + startingRef: "Начальная ссылка", + chooseStart: "Выберите, с чего начать", + checkoutHint: "Проверьте PR#{{number}}?", + checkoutPr: "Проверьте PR#{{number}}", + dismissCheckoutHint: "Отклонить подсказку по оформлению заказа PR#{{number}}", + intoBase: "в{{baseRef}}", + searching: "Идет поиск...", + noMatchingRefs: "Нет подходящих ссылок.", + searchPlaceholder: "Поиск филиалов и PR", + title: "Начать с", + }, + }, + desktop: { + quitting: { + title: "Выход из Paseo...", + detail: "Остановка локального демона.", + }, + daemon: { + title: "Daemon", + status: { + title: "Статус", + builtInOnly: "Здесь показан только встроенный демон рабочего стола.", + running: "бег", + notRunning: "не работает", + pid: "PID{{pid}}", + }, + management: { + title: "Управление встроенным демоном", + hint: "Позвольте Paseo запустить и остановить встроенный демон.", + pauseTitle: "Приостановить встроенный демон", + pauseMessage: + "Это немедленно остановит встроенный демон. Запущенные агенты и терминалы, подключенные к встроенному демону, будут остановлены.", + pauseAndStop: "Пауза и остановка", + registrationFailed: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + pausedStopFailed: + "Встроенное управление демоном было приостановлено, но Paseo не смог остановить демон.", + updateFailed: "Невозможно обновить встроенное управление демонами.", + }, + keepRunning: { + title: "Продолжать работу демона после выхода", + hint: "Daemon продолжает работать, когда вы выходите из Paseo", + }, + logs: { + title: "Файл журнала", + modalTitle: "Журналы Daemon", + unavailable: "Путь к журналу недоступен", + empty: "(файл журнала пуст)", + copied: "Путь к журналу скопирован.", + copyFailed: "Невозможно скопировать путь к журналу.", + open: "Открыть журналы", + copyPath: "Копировать путь", + }, + fullStatus: { + title: "Полный статус", + modalTitle: "Статус Daemon", + hint: "Запускает`paseo daemon status`и показывает результат", + view: "Посмотреть статус", + copied: "Статус скопирован в буфер обмена.", + fetchFailed: "Не удалось получить статус демона:{{message}}.", + }, + advancedSettings: "Расширенные настройки", + openAdvancedSettings: "Открыть дополнительные настройки демона", + versionMismatch: + "Версии приложения и демона не совпадают. Обновите обе версии до одной и той же версии для лучшего опыта.", + loadFailed: "Невозможно загрузить статус демона рабочего стола.", + }, + updates: { + status: { + checking: "Проверка обновлений приложения...", + installing: "Установка обновления приложения...", + upToDate: "Приложение актуально.", + upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", + pending: "Мы сообщим вам, когда обновление будет готово.", + availableWithVersion: "Обновление готово:{{version}}", + available: "Обновление приложения готово к установке.", + installed: "Обновление приложения установлено. Требуется перезагрузка.", + failed: "Не удалось обновить приложение.", + idle: "Статус обновления еще не проверен.", + }, + installError: "Невозможно установить обновление настольного приложения.", + callout: { + installingTitle: "Установка обновления", + failedTitle: "Обновление не выполнено", + availableTitle: "Доступно обновление", + genericError: "Что- то пошло не так.", + whatsNew: "Что нового", + installingAction: "Установка...", + installAndRestart: "Установить и перезапустить", + installingDescription: "Установка и перезапуск...", + versionReady: "{{version}}готов к установке.", + newVersionReady: "Новая версия готова к установке.", + restartWarning: + "Обновление приложения приведет к остановке работы агентов и закрытию сеансов терминала.", + }, + }, + settings: { + loadFailed: "Невозможно загрузить настройки рабочего стола.", + saveFailed: "Невозможно сохранить настройки рабочего стола.", + }, + rosetta: { + title: "Загрузите сборку Apple Silicon", + runningIntel: "Вы используете сборку Intel для Paseo под Rosetta на Apple Silicon.", + highCpu: + "Это приводит к высокой загрузке ЦП. Загрузите сборку Apple Silicon, чтобы исправить это.", + download: "Скачать", + }, + permissions: { + notifications: { + allowed: "Уведомления разрешены операционной системой.", + denied: "Уведомления запрещены в настройках системы.", + notGranted: "Уведомления еще не были предоставлены.", + webOnly: "Статус уведомлений на рабочем столе доступен только в веб- среде выполнения.", + supported: "Уведомления на рабочем столе поддерживаются.", + unsupported: "Уведомления на рабочем столе не поддерживаются на этой платформе.", + apiUnavailable: "API веб- уведомлений недоступен в этой среде.", + requestsWebOnly: + "Запросы уведомлений на рабочем столе доступны только в веб- среде выполнения.", + requestUnavailable: "API веб- уведомлений requestPermission() недоступен.", + requestFailed: "Не удалось запросить разрешение на уведомление:{{message}}.", + unexpectedState: "Состояние разрешения на непредвиденное уведомление:{{state}}.", + }, + microphone: { + webOnly: "Статус настольного микрофона доступен только в веб- среде выполнения.", + navigatorUnavailable: "Навигатор недоступен в этой среде.", + granted: "Доступ к микрофону разрешен.", + denied: "Доступ к микрофону запрещен в настройках системы.", + notGranted: "Разрешение на использование микрофона еще не получено.", + unexpectedState: "Неожиданное состояние разрешения микрофона:{{state}}.", + statusApiUnavailable: + "API состояния микрофона недоступен в этой среде выполнения. Используйте Запрос, чтобы проверить доступ.", + queryFailed: "Не удалось запросить статус микрофона:{{message}}.", + captureUnavailable: "В этой среде захват микрофона недоступен.", + permissionApiUnavailable: + "API статуса разрешения недоступен. Используйте Запрос, чтобы проверить доступ.", + requestsWebOnly: "Запросы настольного микрофона доступны только в веб- среде выполнения.", + captureApiUnavailable: "API захвата микрофона недоступен в этой среде.", + requestDenied: + "Разрешение на использование микрофона было отклонено пользователем или системой.", + noDevice: "Микрофонное устройство не обнаружено.", + requestFailed: "Не удалось запросить разрешение микрофона:{{message}}.", + }, + empty: { + notifications: "Статус уведомления еще не проверен.", + microphone: "Состояние микрофона еще не проверялось.", + }, + testNotification: { + title: "Тест уведомлений Paseo", + body: "Если вы это видите, уведомления на рабочем столе работают.", + notDelivered: + "Уведомление не было доставлено. Проверьте Системные настройки > Уведомления.", + failed: "Не удалось отправить уведомление.", + }, + }, + integrations: { + cli: { + statusFailed: "Невозможно проверить статус установки CLI.", + installFailed: "Невозможно установить PaseoCLI.", + }, + skills: { + statusFailed: "Невозможно проверить статус навыков оркестровки.", + installFailed: "Невозможно установить навыки оркестровки.", + updateFailed: "Невозможно обновить навыки оркестровки.", + uninstallFailed: "Невозможно удалить навыки оркестровки.", + }, + }, + }, + startup: { + errorTitle: "Что- то пошло не так", + errorDescription: + "Локальный сервер не удалось запустить. Если это повторяется, сообщите о проблеме на GitHub и приложите журналы ниже.", + logs: { + loading: "Загрузка журналов демона...", + unavailable: "Журналы демона отсутствуют.", + loadFailed: "Невозможно загрузить журналы демона:{{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "Добавить проект", + description: "Откройте папку на своем компьютере", + }, + importSession: { + title: "Импортировать сеанс", + description: "Добавьте последние внешние сеансы CLI.", + }, + setupProviders: { + title: "Поставщики установки", + description: "Настройте Claude Code,Codex и другие", + }, + pairDevice: { + title: "Сопряжение устройства", + description: "Подключите свой телефон к этому демону", + }, + }, + }, + projectPicker: { + placeholder: "Введите путь к каталогу...", + opening: "Открытие проекта...", + empty: "Начните вводить путь", + }, + branchSwitcher: { + currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.", + placeholder: "Сменить ветку...", + searchPlaceholder: "Фильтровать ветки...", + empty: "Филиалов не найдено.", + title: "Переключить ветку", + uncommittedTitle: "Незафиксированные изменения", + uncommittedMessage: + "У вас есть незафиксированные изменения. Спрятать их перед переключением веток?", + stashAndSwitch: "Тайник и переключатель", + failedToStash: "Не удалось сохранить изменения.", + failedToSwitch: "Не удалось переключить ветку", + restoreStashTitle: "Восстановить спрятанные изменения?", + restoreStashMessage: + "В этой ветке сохранены изменения с предыдущего сеанса. Хотели бы вы их восстановить?", + restore: "Восстановить", + later: "Позже", + stashRestored: "Спрятанные изменения восстановлены.", + }, + agentAutocomplete: { + searchingWorkspace: "Ищем рабочее место...", + loadingCommands: "Загрузка команд...", + noFiles: "Файлы и каталоги не найдены", + noCommands: "Команды не найдены", + failedToLoad: "Не удалось загрузить", + }, + loadOlderHistory: { + failed: "Не удалось загрузить старую историю.", + }, + imageAttachmentPicker: { + permissionTitle: "Требуется разрешение", + permissionMessage: + "Пожалуйста, разрешите доступ к вашей библиотеке фотографий, чтобы прикреплять изображения.", + errorTitle: "Ошибка", + failedToSelect: "Не удалось выбрать изображение", + dialogTitle: "Прикрепите изображения", + dialogFilterName: "Изображения", + }, + workspaceSetup: { + title: "Создать рабочую область", + errors: { + failedCreateWorktree: "Не удалось создать рабочее дерево.", + failedOpenProject: "Не удалось открыть проект", + selectModel: "Выберите модель", + hostDisconnected: "Host не подключен", + pendingRequired: "Никакой настройки рабочей области не ожидается.", + composerStateRequired: "Требуется состояние композитора настройки Workspace.", + }, + }, + onboarding: { + title: "Добро пожаловать в Paseo", + subtitle: "Подключите компьютер, чтобы начать", + actions: { + settings: "Настройки", + }, + }, + modelSelector: { + title: "Выберите провайдера", + selectModel: "Выберите модель", + selectedModel: "Выберите модель ({{model}})", + loading: "Загрузка...", + loadingShort: "Загрузка", + loadingSelector: "Загрузка выбора модели...", + error: "Ошибка", + defaultModel: "По умолчанию", + favorites: "Избранное", + favoriteModel: "Любимая модель", + unfavoriteModel: "Нелюбимая модель", + modelCount: "Модель{{count}}", + modelCountPlural: "Модели{{count}}", + retry: "Повторить попытку", + retrying: "Повторная попытка...", + noMatches: "Ни одна модель не соответствует вашему запросу", + searchPlaceholder: "Поиск моделей...", + openProviderSettings: "Открыть настройки{{provider}}", + }, + providerCatalog: { + title: "Добавить провайдера", + search: "Поиск поставщиков", + noProviders: "Поставщики не найдены", + actions: { + add: "Добавлять", + adding: "Добавление", + installed: "Установлено", + cancel: "Отмена", + installInstructions: "Инструкции по установке", + installInstructionsFor: "Инструкция по установке{{provider}}", + }, + errors: { + unableToInstall: "Невозможно установить провайдера", + }, + }, + providerSelection: { + defaultModel: "По умолчанию", + selectModel: "Выберите модель", + loading: "Загрузка...", + error: "Ошибка", + unavailable: "Недоступно", + unknownError: "Неизвестная ошибка", + readiness: { + initialPromptRequired: "Требуется начальное приглашение", + noProviders: "На выбранном хосте нет доступных провайдеров", + modelDefaultsLoading: "Настройки модели по умолчанию все еще загружаются", + noModelAvailable: "Для выбранного поставщика модель недоступна.", + workspaceDirectoryNotFound: "Каталог Workspace не найден", + hostDisconnected: "Host не подключен", + }, + }, + pairing: { + connectionMethods: { + title: "Добавить соединение", + direct: { + title: "Прямое подключение", + description: "Локальная сеть или VPN.", + }, + scanQr: { + title: "Сканировать код QR", + description: "Зашифрованное релейное соединение.", + }, + pasteLink: { + title: "Вставьте ссылку на сопряжение", + description: "Зашифрованное релейное соединение.", + }, + }, + direct: { + title: "Прямое подключение", + helper: "Введите адрес сервера Paseo.", + fields: { + host: "Host", + port: "Порт", + password: "Пароль", + optional: "Необязательный", + useSsl: "Использовать SSL", + connectionUri: "URI подключения", + }, + advanced: { + label: "Передовой", + show: "Показать расширенные", + hide: "Скрыть расширенные", + }, + passwordVisibility: { + show: "Показать пароль", + hide: "Скрыть пароль", + }, + actions: { + cancel: "Отмена", + connect: "Соединять", + connecting: "Подключение...", + }, + errors: { + hostRequired: "Требуется Host", + invalidPort: "Порт должен быть в диапазоне от 1 до 65535.", + invalidConnection: "Неверное соединение", + failedTitle: "Соединение не удалось", + failedToConnect: "Нам не удалось подключиться к{{endpoint}}.", + noAdditionalDetails: "{{detail}}(дополнительная информация не предоставлена)", + timedOut: "Время подключения истекло. Проверьте хост /port и вашу сеть.", + refused: "В соединении отказано. Сервер работает по этому адресу?", + hostNotFound: "Host не найден. Проверьте имя хоста и повторите попытку.", + hostUnreachable: "Host недоступен. Проверьте свою сеть и брандмауэр.", + tlsError: + "Ошибка TLS. Прямые соединения используют SSL только тогда, когда перед демоном находится терминатор TLS.", + unableToConnect: + "Не удалось подключиться. Проверьте хост /port и убедитесь, что демон доступен.", + details: "Подробности:{{detail}}", + }, + }, + link: { + title: "Вставьте ссылку на сопряжение", + helper: "Вставьте ссылку на сопряжение с вашего сервера.", + label: "Ссылка на сопряжение", + errors: { + required: "Вставьте ссылку для сопряжения (.../#offer=...)", + missingOffer: "Ссылка должна содержать #offer=...", + emptyOffer: "Полезная нагрузка предложения пуста.", + invalid: "Неверная ссылка для сопряжения", + unableToPair: "Невозможно подключить хост", + }, + alert: { + failedTitle: "Сопряжение не удалось", + }, + actions: { + cancel: "Отмена", + pair: "Пара", + pairing: "Сопряжение...", + }, + }, + scan: { + title: "Сканировать QR", + webUnavailableTitle: "Недоступно в Интернете", + webUnavailableBody: + "Сканирование QR не поддерживается в веб- сборке. Вместо этого используйте «Вставить ссылку».", + backToSettings: "Вернуться к настройкам", + cameraPermissionTitle: "Разрешение камеры", + cameraPermissionBody: + "Разрешите камере доступ к сканированию кода сопряжения QR с вашего демона.", + grantPermission: "Предоставить разрешение", + pairing: "Сопряжение...", + unableToPair: "Невозможно подключить хост", + errorTitle: "Ошибка", + }, + device: { + loadingOffer: "Загрузка предложения по сопряжению...", + failedToLoadOffer: "Не удалось загрузить предложение сопряжения.", + relayDisabled: "Реле не включено. Включите реле для сопряжения устройства.", + unavailable: "Предложение по сопряжению недоступно.", + hint: "Отсканируйте этот код QR с помощью Paseo на своем телефоне или скопируйте ссылку ниже.", + qrUnavailable: "Код QR недоступен.", + retry: "Повторить попытку", + copy: "Копировать", + copied: "Скопировано", + }, + }, + realtimeVoice: { + actions: { + mute: "Отключить звук в реальном времени", + unmute: "Включить звук голоса в реальном времени", + stop: "Остановить голос в реальном времени и прервать поворот", + }, + }, + rewind: { + tooltip: "Перемотка назад к этому сообщению", + warning: "Это действие нельзя отменить.", + actions: { + conversation: "Перемотать разговор назад", + files: "Перемотка файлов", + both: "Перемотка разговора и файлов назад", + }, + errors: { + failed: "Не удалось перемотать агент", + }, + }, + diffViewer: { + empty: "Нет изменений для отображения", + }, + serviceUrl: { + title: "Открыть сервис URL", + message: "Открыть{{url}}?", + inPaseo: "В Paseo", + externalBrowser: "Внешний браузер", + dontAskAgain: "Не спрашивай больше", + }, + downloads: { + requestTokenFailed: "Не удалось запросить токен загрузки.", + hostUnavailable: "Хост загрузки недоступен.", + cancelled: "Загрузка отменена.", + failed: "Не удалось загрузить файл.", + shareFile: "Поделиться файлом", + shareFileNamed: "Поделиться {{fileName}}", + }, + menu: { + backdrop: "Фон меню", + }, + subagents: { + archiveAction: "Архив{{label}}", + archiveTooltip: "Архивный субагент", + }, + panels: { + draft: { + newAgent: "Новый Agent", + creatingAgent: "Создание агента", + }, + file: { + executionDirectoryMissing: "Каталог выполнения Workspace не найден.", + loading: "Загрузка файла...", + noPreview: "Предварительный просмотр недоступен", + binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", + failedToLoad: "Не удалось загрузить файл", + failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", + }, + }, + toolCallDetails: { + error: "Ошибка", + empty: "Дополнительные сведения отсутствуют", + subAgentActivity: "Субагентская деятельность", + input: "Вход", + output: "Выход", + }, + renameModal: { + rename: "Переименовать", + saving: "Сохранение...", + }, + sidebarCallout: { + dismiss: "Увольнять", + }, + contextWindow: { + title: "Контекстное окно", + used: "{{percentage}}% использовано", + tokens: "Токены{{used}}/{{max}}", + sessionCost: "Стоимость сеанса{{cost}}", + accessibility: "Контекстное окно{{percentage}}% использовано", + }, + review: { + comment: { + add: "Добавить комментарий к обзору", + edit: "Изменить комментарий к отзыву", + delete: "Удалить комментарий к отзыву", + label: "Посмотреть комментарий", + placeholder: "Оставить комментарий", + cancel: "Отмена", + cancelAccessibility: "Отменить комментарий к отзыву", + save: "Комментарий", + saveAccessibility: "Сохранить комментарий к отзыву", + }, + }, + settings: { + title: "Настройки", + loading: "Загрузка настроек...", + groups: { + app: "Приложение", + host: "Host", + }, + hostPicker: { + switchHost: "Сменить хост", + local: "Местный", + }, + backToWorkspace: "Назад", + addHost: "Добавить хост", + projects: "Проекты", + projectList: { + hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.", + editProject: "Изменить{{projectName}}", + }, + groupInfo: "О{{title}}", + sections: { + general: "Общий", + daemon: "Daemon", + appearance: "Появление", + shortcuts: "Ярлыки", + integrations: "Интеграции", + permissions: "Разрешения", + diagnostics: "Диагностика", + about: "О", + }, + hostSections: { + connections: "Соединения", + agents: "Agents", + workspaces: "Workspaces", + providers: "Провайдеры", + host: "Host", + }, + general: { + title: "Общий", + defaultSend: { + label: "Отправка по умолчанию", + description: "Что произойдет, если вы нажмете Enter во время работы агента", + options: { + interrupt: "Прерывать", + queue: "Очередь", + }, + }, + serviceUrls: { + label: "URL- адреса служб", + description: "Где открыть URL- адреса запущенных скриптов", + options: { + ask: "Просить", + inApp: "В Paseo", + external: "Внешний браузер", + }, + }, + terminalScrollback: { + label: "Terminal прокрутка назад", + description: "Строки, хранящиеся во встроенном буфере терминала.", + accessibilityLabel: "Линии прокрутки Terminal", + }, + language: { + label: "Язык", + description: "Язык приложения", + options: { + system: "Система", + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + zhCN: "中文", + }, + }, + }, + diagnostics: { + title: "Диагностика", + testAudio: "Тестирование звука", + playTest: "Игровой тест", + playing: "Игра...", + playbackFailed: "Ошибка воспроизведения:{{message}}", + }, + about: { + title: "О", + appVersion: "Версия приложения", + thisDevice: "Это устройство", + connectedHosts: "Подключенные хосты", + offline: "Оффлайн", + versionDiffers: "Версия отличается от этого устройства", + releaseChannel: { + label: "Канал выпуска", + description: + "Перейдите на Beta, чтобы получать обновления раньше и помогать их формировать.", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "Обновления приложений", + readyToInstall: "Готово к установке:{{version}}", + installTitle: "Установить обновление рабочего стола", + installMessage: "Это обновит Paseo на этом компьютере.", + installConfirm: "Установить обновление", + update: "Обновлять", + updateTo: "Обновление до{{version}}", + installing: "Установка...", + check: "Проверять", + checking: "Проверка...", + alertTitle: "Ошибка", + alertMessage: "Невозможно открыть диалоговое окно подтверждения обновления.", + }, + }, + appearance: { + theme: { + title: "Тема", + accessibilityLabel: "Тема:{{value}}", + options: { + light: "Свет", + dark: "Темный", + zinc: "Цинк", + midnight: "Полночь", + claude: "Клод", + ghostty: "Призрачный", + auto: "Система", + }, + }, + fonts: { + title: "Шрифты", + systemDefault: "Система по умолчанию", + interfaceFont: "Шрифт интерфейса", + interfaceFontHint: + "Используется во всем приложении. Оставьте пустым для системного значения по умолчанию.", + interfaceFontAccessibility: "Семейство интерфейсных шрифтов", + interfaceSize: "Размер интерфейса", + interfaceSizeAccessibility: "Размер шрифта интерфейса", + codeFont: "Шрифт кода", + codeFontHint: + "Используется в коде, различиях и выводе терминала. Оставьте пустым для системного значения по умолчанию.", + codeFontAccessibility: "Семейство шрифтов кода", + codeSize: "Размер кода", + codeSizeAccessibility: "Размер шрифта кода", + }, + syntax: { + title: "Синтаксис", + highlightTheme: "Выделить тему", + highlightThemeHint: "Цвета кода независимо от темы приложения", + highlightThemeAccessibility: "Выделить тему:{{value}}", + previewAccessibility: + "Предварительный просмотр темы синтаксиса и шрифта кода в реальном времени.", + }, + }, + shortcuts: { + dialogTitle: "Ярлыки", + unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.", + capturePrompt: "Нажмите ярлык...", + actions: { + done: "Сделанный", + cancel: "Отмена", + rebind: "Перепривязка", + reset: "Перезагрузить", + resetAll: "Сбросить все", + }, + sections: { + navigation: "Навигация", + tabsPanes: "Вкладки и панели", + projects: "Проекты", + panels: "Панели", + agentInput: "Вход Agent", + }, + help: { + openProject: "Открыть проект", + newWorktree: "Новое рабочее дерево", + archiveWorktree: "Архив рабочего дерева", + newTab: "Новая вкладка", + closeCurrentTab: "Закрыть текущую вкладку", + jumpToWorkspace: "Перейти в рабочую область", + jumpToTab: "Перейти на вкладку", + previousWorkspace: "Предыдущая рабочая область", + nextWorkspace: "Следующая рабочая область", + previousTab: "Предыдущая вкладка", + nextTab: "Следующая вкладка", + splitPaneRight: "Разделить панель справа", + splitPaneDown: "Разделить панель вниз", + focusPaneLeft: "Панель фокусировки слева", + focusPaneRight: "Панель фокусировки справа", + focusPaneUp: "Панель фокусировки вверх", + focusPaneDown: "Панель фокусировки вниз", + moveTabLeft: "Переместить вкладку влево", + moveTabRight: "Переместить вкладку вправо", + moveTabUp: "Переместить вкладку вверх", + moveTabDown: "Переместить вкладку вниз", + closePane: "Закрыть панель", + newTerminal: "Новый терминал", + toggleCommandCenter: "Переключить командный центр", + showKeyboardShortcuts: "Показать сочетания клавиш", + toggleLeftSidebar: "Переключить левую боковую панель", + toggleRightSidebar: "Переключить правую боковую панель", + toggleBothSidebars: "Переключить обе боковые панели", + toggleSettings: "Переключить настройки", + toggleFocusMode: "Переключить режим фокусировки", + cycleTheme: "Циклическая тема", + focusMessageInput: "Фокус ввода сообщения", + toggleVoiceMode: "Переключить голосовой режим", + startStopDictation: "Начать диктовку /stop", + interruptAgent: "Агент прерываний", + sendMessage: "Отправить сообщение", + queueMessage: "Сообщение в очереди", + muteUnmuteVoiceMode: "Отключить голосовой режим /unmute", + }, + helpNotes: { + showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.", + }, + }, + integrations: { + title: "Интеграции", + docs: { + cli: "Документация CLI", + skills: "Документы по навыкам", + openCli: "Открыть документацию CLI", + openSkills: "Открытая документация по навыкам", + }, + commandLine: { + title: "Командная строка", + description: "Агенты управления и сценариев с вашего терминала", + }, + skills: { + title: "Навыки оркестровки", + description: "Научите своих агентов организовывать работу через CLI", + updateAvailable: "Доступно обновление", + updateTitle: "Обновить навыки Paseo?", + updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.", + uninstallTitle: "Удалить навыки Paseo?", + uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.", + }, + actions: { + install: "Установить", + installing: "Установка...", + installed: "Установлено", + update: "Обновлять", + working: "Работающий...", + uninstall: "Удалить", + }, + operations: { + add: "Добавить навык", + update: "Обновить навык", + delete: "Удалить навык", + }, + }, + permissions: { + title: "Разрешения", + notifications: "Уведомления", + microphone: "Микрофон", + refresh: "Обновить", + refreshing: "Освежающий...", + refreshAccessibility: "Обновить разрешения рабочего стола", + test: "Тест", + actions: { + granted: "Предоставленный", + request: "Запрос", + requesting: "Запрос...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "Host не найден", + badges: { + relay: "Реле", + local: "Местный", + }, + connections: { + title: "Соединения", + removeTitle: "Удалить соединение", + removeMessage: "Удалить{{name}}? Это невозможно отменить.", + removeAction: "Удалять", + removeErrorTitle: "Ошибка", + removeErrorMessage: "Невозможно удалить соединение", + timeout: "Тайм- аут", + }, + pairDevices: { + title: "Сопряжение устройств", + rowTitle: "Сопряжение устройства", + rowHint: + "Отсканируйте код QR или скопируйте ссылку, чтобы подключить свой телефон к этому хосту.", + }, + orchestration: { + title: "оркестровка", + unavailable: "Подключитесь к этому хосту, чтобы управлять оркестрацией.", + enableTools: { + title: "Включить инструменты Paseo", + hint: "Агенты смогут управлять рабочими деревьями, агентами и расписаниями.", + accessibilityLabel: "Инструменты внедрения Paseo", + }, + systemPrompt: { + title: "Системная подсказка", + hint: "Добавляет системное приглашение всем агентам", + sheetTitle: "Добавить системное приглашение", + accessibilityLabel: "Добавить системное приглашение", + placeholder: "Всегда отвечайте кратко.", + }, + }, + agents: { + unavailable: "Connect to this host to manage agents", + }, + workspaces: { + unavailable: "Connect to this host to manage workspaces", + }, + daemon: { + rename: { + editLabel: "Изменить ярлык", + title: "Переименовать хост", + placeholder: "Мой Host", + }, + restart: { + title: "Перезапустить демон", + hint: "Перезапускает процесс демона. Приложение автоматически переподключится", + confirmTitle: "Перезапустите{{name}}", + confirmMessage: + "Это перезапустит демон. Агенты, работающие на нем, будут продолжать работать; приложение автоматически повторно подключится.", + restarting: "Перезапуск...", + unableToReconnectTitle: "Невозможно повторно подключиться", + unableToReconnectMessage: + "{{name}}не вернулся в онлайн. Пожалуйста, убедитесь, что он перезапущен.", + unavailableTitle: "Host недоступен", + unavailableMessage: + "Этот хост не подключен. Подождите, пока он подключится к сети, прежде чем перезапустить.", + offlineTitle: "Host оффлайн", + offlineMessage: + "Этот хост не в сети.Paseo автоматически повторно подключается — подождите, пока он снова подключится к сети, прежде чем перезапускаться.", + requestFailedTitle: "Ошибка", + requestFailedMessage: + "Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.", + dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.", + }, + dangerZone: "Опасная зона", + remove: { + title: "Удалить хост", + localTitle: "Remove localhost connection", + hint: "Удаляет этот хост и его сохраненные подключения с этого устройства.", + localHint: "Removes localhost from this device and stops the built-in daemon", + localConfirmTitle: "Remove localhost connection and stop daemon?", + confirmMessage: "Удалить{{name}}? Это приведет к удалению сохраненных соединений.", + localConfirmMessage: + "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + errorTitle: "Ошибка", + errorMessage: "Невозможно удалить хост", + localErrorMessage: "Unable to remove localhost connection", + }, + }, + }, + providers: { + title: "Провайдеры", + addProvider: "Добавить провайдера", + providerDetails: "Подробности о провайдере{{name}}", + enableProvider: "Включить{{name}}", + unavailable: "Подключитесь к этому хосту, чтобы увидеть поставщиков", + loading: "Загрузка...", + addErrorTitle: "Unable to add provider", + updateErrorTitle: "Невозможно обновить провайдера", + statuses: { + disabled: "Неполноценный", + loading: "Загрузка", + error: "Ошибка", + available: "Доступный", + notInstalled: "Не установлено", + }, + models: { + one: "1 модель", + many: "Модели{{count}}", + addModel: "Добавить модель", + addCustomTitle: "Добавить пользовательскую модель", + modelId: "Модель ID", + modelIdPlaceholder: "например опенай /gpt-5", + add: "Добавлять", + adding: "Добавление...", + failedToSave: "Не удалось сохранить модель.", + removeModel: "Удалить{{id}}", + searchPlaceholder: "Поиск моделей", + loading: "Загрузка моделей...", + retry: "Повторить попытку", + retrying: "Повторная попытка...", + noSearchMatches: "Ни одна модель не соответствует вашему запросу", + noneDetected: "Модели не обнаружены", + discovered: "Обнаруженный", + custom: "Пользовательские модели", + updated: "Обновлен{{time}}", + }, + diagnostic: { + title: "Диагностика", + button: "Диагностика", + refresh: "Обновить", + refreshing: "Освежающий...", + refreshAccessibility: "Обновить диагностику", + refreshingAccessibility: "Обновление диагностики", + running: "Запускаю диагностику...", + none: "Диагностика недоступна", + failedToFetch: "Не удалось получить диагностику.", + unknownError: "Неизвестная ошибка", + }, + }, + project: { + noEditableTarget: + "У нас нет редактируемой копии этого проекта ни на одном подключенном хосте.", + backToProjects: "Вернуться к проектам", + switchHost: "Сменить хост", + rename: { + renamedToast: "Проект переименован", + errorFallback: "Не удалось переименовать проект", + renameLabel: "Переименовать проект", + resetLabel: "Сбросить имя проекта по умолчанию", + projectNameLabel: "Название проекта", + saveLabel: "Сохранить название проекта", + cancelLabel: "Отменить переименование", + reset: "Перезагрузить", + }, + readFailures: { + invalidTitle: "paseo.json не удалось разобрать", + invalidDescription: "Исправьте файл на диске, затем перезагрузите.", + missingTitle: "У этого хоста нет этого проекта", + missingWithHosts: "Переключитесь на другой хост выше или перезагрузите компьютер.", + missingSingleHost: "У выбранного хоста нет записей об этом проекте.", + transportTitle: "Не удалось загрузить paseo.json.", + transportFallback: "Хозяин не ответил.", + failedTitle: "Не удалось загрузить paseo.json.", + failedDescription: "Перезагрузите, чтобы попробовать еще раз.", + }, + worktree: { + title: "Перехватчики жизненного цикла Worktree", + info: "Команды, которые выполняются при создании или удалении рабочего дерева для этого проекта.", + docs: "Документы", + docsTooltip: + "Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.", + setup: "Настраивать", + setupAccessibility: "Команды настройки рабочего дерева", + teardown: "Срывать", + teardownAccessibility: "Команды разрушения рабочего дерева", + }, + scripts: { + title: "Скрипты", + info: "Долгоработающие службы и одноразовые команды, которые можно запускать из любого агента в этом проекте.", + empty: "Скриптов пока нет.", + untitled: "Безымянный сценарий", + port: "порт{{port}}", + menuAccessibility: "Открыть меню скриптов", + removeTitle: "Удалить скрипт?", + removeMessage: "Удалить{{name}}?", + removeFallbackName: "этот сценарий", + name: "Имя", + command: "Команда", + nameAccessibility: "Имя сценария", + commandAccessibility: "Команда сценария", + nameRequired: "Требуется имя", + commandRequired: "Требуется команда", + newScript: "Новый сценарий", + editScript: "Изменить{{name}}", + runAsService: "Запуск как служба", + serviceHint: "Paseo контролирует процесс и назначает порт через $PASEO_PORT.", + actions: { + add: "Добавить скрипт", + edit: "Редактировать", + remove: "Удалять", + }, + }, + metadata: { + title: "Генерация метаданных", + info: "Инструкции для конкретного проекта, внедренные в подсказки ИИ, которые Paseo использует для генерации метаданных. Используйте их для обеспечения соблюдения соглашений вашей команды, таких как наименование ветвей, стиль фиксации или формат PR.", + agentTitle: "Agent заголовки", + agentTitlePlaceholder: "Сохраняйте заголовки обязательными и длиной не более 40 символов.", + branchName: "Названия ветвей", + branchNamePlaceholder: "Префиксные ветки с feat/ или fix/, mb/ для личных веток", + commitMessage: "Фиксировать сообщения", + commitMessagePlaceholder: "Используйте обычные фиксации с областью действия", + pullRequest: "Запросы на вытягивание", + pullRequestPlaceholder: + "Начните с резюме в один абзац, включая раздел «План тестирования».", + }, + writeFailures: { + staleTitle: "Конфигурация изменена на диске", + staleDescription: + "Перед сохранением перезагрузите компьютер, чтобы получить последнюю версию файла paseo.json.", + failedTitle: "Не удалось сохранить paseo.json.", + failedDescription: "Попробуйте еще раз или перезагрузите последнюю версию с диска.", + }, + actions: { + reload: "Перезагрузить", + tryAgain: "Попробуйте еще раз", + save: "Сохранять", + saved: "Проект сохранен.", + saving: "Сохранение...", + cancel: "Отмена", + }, + }, + }, +}; diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts new file mode 100644 index 000000000..f1c131e5c --- /dev/null +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -0,0 +1,1779 @@ +import type { TranslationResources } from "./en"; + +export const zhCN: TranslationResources = { + common: { + back: "返回", + loading: "加载中...", + actions: { + back: "返回", + cancel: "取消", + close: "关闭", + copy: "复制", + dismiss: "关闭", + retry: "重试", + search: "搜索", + select: "选择", + }, + placeholders: { + search: "搜索...", + }, + empty: { + noResults: "没有结果", + noOptionsMatchSearch: "没有匹配搜索的选项。", + }, + states: { + loading: "加载中...", + starting: "正在开始...", + copied: "已复制", + copiedLabel: "已复制 {{label}}", + downloadComplete: "下载完成", + downloadFailed: "下载失败", + }, + errors: { + error: "错误", + unableToSave: "无法保存", + nameRequired: "名称必填", + daemonUnavailable: "Daemon 不可用", + daemonClientUnavailable: "Daemon client 不可用", + daemonClientDisconnected: "Daemon client 已断开连接", + noFileFound: "未找到 {{token}} 对应的文件", + unexpectedDictationError: "处理听写时发生意外错误。", + }, + connectionStatus: { + online: "在线", + connecting: "正在连接", + offline: "离线", + error: "错误", + idle: "空闲", + }, + }, + shell: { + menu: { + toggleSidebar: "切换侧边栏", + open: "打开菜单", + close: "关闭菜单", + }, + commandCenter: { + placeholder: "输入命令或搜索 Agent...", + noMatches: "没有匹配项", + actions: "操作", + agents: "Agents", + newAgent: "新建 Agent", + openProject: "打开项目", + home: "首页", + }, + }, + composer: { + placeholders: { + desktop: "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills", + mobile: "发消息,@files,/commands", + fallback: "输入消息...", + }, + input: { + accessibilityLabel: "给 Agent 发消息...", + focusHint: "{{shortcut}} 聚焦", + addAttachment: "添加附件", + interruptAgent: "中断 Agent", + queueMessage: "消息排队", + sendAndInterrupt: "发送并中断", + sendMessage: "发送消息", + queue: "排队", + send: "发送", + }, + cancel: { + cancelingAgent: "正在取消 Agent", + stopAgent: "停止 Agent", + interrupt: "中断", + }, + voice: { + enableVoiceMode: "启用语音模式", + voiceMode: "语音模式", + unmuteVoiceMode: "取消静音语音模式", + muteVoiceMode: "静音语音模式", + stopDictation: "停止听写", + startDictation: "开始听写", + unmuteVoice: "取消静音", + muteVoice: "静音", + dictation: "听写", + interruptBeforeVoice: "启动语音模式前请先中断 Agent", + }, + attachments: { + addImage: "添加图片", + addIssueOrPr: "添加 issue 或 PR", + dropImagesHere: "将图片拖放到这里", + editQueuedMessage: "编辑排队消息", + sendQueuedMessageNow: "立即发送排队消息", + openImage: "打开图片附件", + removeImage: "移除图片附件", + openGithub: "打开 {{kind}} #{{number}}", + removeGithub: "移除 {{kind}} #{{number}}", + browserElement: "元素 · {{tag}}", + openBrowserElement: "打开浏览器元素附件", + removeBrowserElement: "移除浏览器元素附件", + openReview: "打开 review 附件", + removeReview: "移除 review 附件", + }, + errors: { + failedToSend: "发送消息失败", + failedToCreateAgent: "创建 Agent 失败", + noHostSelected: "未选择 Host", + initialPromptRequired: "初始 prompt 必填", + alreadyLoading: "正在加载", + }, + clientCommands: { + archiveAgent: "归档当前 Agent", + freshDraft: "归档此 Agent 并开始新的草稿", + }, + github: { + searching: "正在搜索...", + noResults: "没有结果。", + searchPlaceholder: "搜索 issues 和 PRs...", + title: "附加 issue 或 PR", + }, + }, + agentControls: { + provider: { + fallback: "Provider", + select: "选择 Agent Provider", + }, + thinking: { + title: "Thinking", + unknown: "未知", + extraHigh: "Extra high", + select: "选择 thinking 选项", + selectWithValue: "选择 thinking 选项({{value}})", + }, + model: { + unknown: "未知 Model", + }, + features: { + title: "Features", + open: "打开 Agent features", + on: "开启", + off: "关闭", + }, + mode: { + title: "Mode", + searchPlaceholder: "搜索 modes...", + selectWithValue: "选择 Agent mode({{value}})", + }, + hints: { + thinking: "Thinking mode", + model: "切换 Model", + mode: "切换权限 Mode", + }, + }, + agentStream: { + empty: "开始和这个 Agent 对话...", + scrollToBottom: "滚动到底部", + permission: { + plan: "Plan", + required: "需要权限", + deny: "拒绝", + accept: "接受", + implement: "实施", + question: "你想如何继续?", + proposedPlan: "建议计划", + }, + }, + agentPanel: { + states: { + notFound: "未找到 Agent", + failedToLoad: "加载 Agent 失败", + reconnecting: "正在重连...", + archivingTitle: "正在归档 Agent...", + archivingSubtitle: "请稍候,我们正在归档这个 Agent。", + }, + unavailable: { + selectedHost: "选中的 Host", + unknownHost: "无法打开此 Agent,因为此设备上未配置 {{serverLabel}}。", + addHost: "请在设置中添加 Host,或打开已配置 server 上的 Agent 后继续。", + preparingSession: "正在准备 {{serverLabel}} 会话...", + connecting: "正在连接 {{serverLabel}}...", + showSoon: "稍后将显示此 Agent。", + showWhenOnline: "Host 在线后将显示此 Agent。", + reconnectingTo: "正在重新连接 {{serverLabel}}...", + showAgainWhenReachable: "Host 可访问后将再次显示此 Agent。", + }, + archived: { + callout: "此 Agent 已归档", + unarchive: "取消归档", + }, + }, + sessions: { + title: "会话", + empty: "还没有会话", + actions: { + loadMore: "加载更多", + }, + }, + agentList: { + fallbackTitle: "新会话", + dateSections: { + recent: "最近", + today: "今天", + yesterday: "昨天", + thisWeek: "本周", + thisMonth: "本月", + older: "更早", + }, + status: { + initializing: "正在启动", + idle: "空闲", + running: "运行中", + error: "错误", + closed: "已关闭", + }, + badges: { + archived: "已归档", + pending: "{{count}} 个待处理", + attention: "需要注意", + }, + archiveSheet: { + hostOffline: "Host 离线", + runningAgent: "此 Agent 仍在运行。归档会停止该 Agent。", + archive: "归档", + }, + }, + message: { + actions: { + copyCode: "复制代码", + copyTurn: "复制回合", + copyMessage: "复制消息", + openFile: "打开文件", + copied: "已复制", + }, + attachments: { + dismissImage: "关闭图片", + closeImage: "关闭图片", + imageLoadFailed: "无法加载图片", + imageUnavailable: "图片不可用", + imagePreviewUnavailable: "图片预览不可用。", + imagePreviewLoadFailed: "无法加载图片预览。", + reviewOne: "Review · 1 条评论", + reviewMany: "Review · {{count}} 条评论", + textAttachment: "文本附件", + }, + speak: { + header: "已朗读", + }, + activity: { + details: "详情", + }, + dictation: { + start: "开始语音听写", + cancel: "取消听写", + retry: "重试听写", + insert: "插入转写", + insertAndSend: "插入转写并发送", + failed: "听写失败:{{error}}", + failedRetry: "听写失败。点按重试。", + }, + question: { + submit: "提交", + next: "下一步", + answerPlaceholder: "输入你的回答...", + otherPlaceholder: "其他...", + }, + todo: { + title: "任务", + empty: "还没有任务。", + }, + compaction: { + loading: "正在压缩...", + auto: "上下文已自动压缩", + manual: "上下文已手动压缩", + withTokens: "上下文已压缩({{tokens}}K tokens)", + completed: "上下文已压缩", + }, + }, + importSession: { + title: "导入会话", + filters: { + all: "全部", + }, + status: { + connectHost: "连接到 Host 以导入会话", + updateHost: "更新 Host 以导入会话。", + noProviders: "没有已启用的可导入 Provider。", + loading: "正在加载最近会话...", + failedAll: "无法加载最近会话。", + failedProviders: "无法加载 {{providers}} 的会话。", + failedImport: "无法导入所选会话。", + }, + actions: { + refresh: "刷新会话", + }, + preview: { + untitledSession: "未命名会话", + noPrompt: "没有 prompt 预览", + }, + empty: { + noRecent: "没有可导入的最近会话。", + alreadyImported: "所有最近会话都已导入。", + noProviderSessions: "没有找到 {{provider}} 会话。", + }, + row: { + importing: "正在导入...", + }, + }, + workspace: { + route: { + loading: "正在加载 workspace", + connecting: "正在连接", + hostOffline: "{{hostName}} 已离线", + cannotReachHost: "无法连接 {{hostName}}", + hostStatus: "Host 状态:{{status}}", + missing: "Workspace 未找到", + manageHost: "管理 Host", + }, + hoverCard: { + scriptsAccessibility: "Workspace scripts", + }, + fileExplorer: { + sort: { + name: "名称", + modified: "修改时间", + size: "大小", + }, + context: { + size: "大小", + modified: "修改时间", + copyPath: "复制路径", + download: "下载", + }, + actions: { + back: "返回", + retry: "重试", + refresh: "刷新文件", + refreshing: "正在刷新文件", + }, + empty: { + noFiles: "没有文件", + }, + states: { + unavailable: "Workspace 不可用", + loading: "正在加载文件...", + }, + errors: { + failedToListDirectory: "列出目录失败", + }, + }, + setup: { + descriptor: { + label: "Setup", + completed: "Setup 已完成", + failed: "Setup 失败", + workspace: "Workspace setup", + }, + status: { + running: "正在运行", + completed: "已完成", + failed: "失败", + waiting: "正在等待 setup 输出", + }, + waiting: "正在 setup workspace...", + empty: { + noCommands: "此 workspace 没有运行 setup 命令。", + }, + accessibility: { + noCommands: "此 workspace 没有运行 setup 命令", + log: "Workspace setup 日志", + }, + log: { + noOutput: "没有输出", + }, + }, + browser: { + unavailable: { + title: "浏览器仅桌面端可用", + subtitle: "在 Electron 中打开此 workspace 以使用内置浏览器。", + }, + session: "浏览器会话 {{browserId}}", + controls: { + back: "后退", + forward: "前进", + stopLoading: "停止加载", + refresh: "刷新", + browserUrl: "浏览器 URL", + enterUrl: "输入 URL", + openDevTools: "打开浏览器开发者工具", + cancelSelector: "取消元素选择器", + selectElement: "选择元素", + }, + errors: { + failedToLoad: "页面加载失败", + invalidUrl: "浏览器 URL 无效", + unsupportedProtocol: "已阻止不支持的浏览器 URL:{{protocol}}", + }, + }, + terminal: { + hostDisconnected: "Host 未连接", + unableToSubscribe: "无法订阅 Terminal", + }, + tabs: { + loading: "正在加载...", + loadingAgentTitle: "正在加载 Agent 标题", + emptyPane: "此窗格中没有标签。", + fallback: { + newAgent: "新建 Agent", + setup: "Setup", + workspaceSetup: "Workspace setup", + terminal: "Terminal", + browser: "浏览器", + agent: "Agent", + workspace: "Workspace", + }, + switcher: { + trigger: "切换标签(已打开 {{count}} 个)", + title: "切换标签", + searchPlaceholder: "搜索标签", + }, + menu: { + openFor: "打开 {{label}} 的菜单", + copyResumeCommand: "复制恢复命令", + copyAgentId: "复制 Agent ID", + rename: "重命名", + closeAbove: "关闭上方标签", + closeBelow: "关闭下方标签", + closeLeft: "关闭左侧标签", + closeRight: "关闭右侧标签", + closeOthers: "关闭其他标签", + reloadAgent: "重新加载 Agent", + reloadAgentTooltip: "重新加载 Agent 以更新 skills、MCPs 或登录状态。", + close: "关闭", + renameTerminal: "重命名 Terminal", + renameAgent: "重命名 Agent", + }, + actions: { + newAgent: "新建 Agent 标签", + newTerminal: "新建 Terminal 标签", + preparingTerminal: "正在准备 Terminal 标签", + preparingTerminalTooltip: "正在准备 Terminal...", + newBrowser: "新建浏览器标签", + splitRight: "向右拆分窗格", + splitDown: "向下拆分窗格", + }, + explorer: { + open: "打开 explorer", + close: "关闭 explorer", + toggle: "切换 explorer", + changes: "变更", + files: "文件", + }, + toasts: { + copyFailed: "复制失败", + agentIdCopiedLabel: "Agent ID", + resumeCommandCopiedLabel: "恢复命令", + resumeIdUnavailable: "恢复 ID 不可用", + resumeCommandUnavailable: "恢复命令不可用", + reloadingAgent: "正在重新加载 Agent...", + reloadedAgent: "已重新加载 Agent", + failedToReloadAgent: "重新加载 Agent 失败", + }, + confirmations: { + close: "关闭", + cancel: "取消", + archive: "归档", + closeTerminalTitle: "关闭 Terminal?", + closeTerminalMessage: "此 Terminal 中任何正在运行的进程都会立即停止。", + archiveRunningAgentTitle: "归档正在运行的 Agent?", + archiveRunningAgentMessage: "此 Agent 仍在运行。归档会停止该 Agent 并关闭标签。", + closeTabsLeftTitle: "关闭左侧标签?", + closeTabsRightTitle: "关闭右侧标签?", + closeOtherTabsTitle: "关闭其他标签?", + bulk: { + all: "这会归档 {{agents}} 个 Agent,关闭 {{terminals}} 个 Terminal,并关闭 {{tabs}} 个标签。已关闭 Terminal 中任何正在运行的进程都会立即停止。", + agentsAndTerminals: + "这会归档 {{agents}} 个 Agent,并关闭 {{terminals}} 个 Terminal。已关闭 Terminal 中任何正在运行的进程都会立即停止。", + terminalsAndTabs: + "这会关闭 {{terminals}} 个 Terminal,并关闭 {{tabs}} 个标签。已关闭 Terminal 中任何正在运行的进程都会立即停止。", + agentsAndTabs: "这会归档 {{agents}} 个 Agent,并关闭 {{tabs}} 个标签。", + terminals: + "这会关闭 {{terminals}} 个 Terminal。已关闭 Terminal 中任何正在运行的进程都会立即停止。", + tabs: "这会关闭 {{tabs}} 个标签。", + agents: "这会归档 {{agents}} 个 Agent。", + }, + }, + }, + header: { + actions: { + workspaceActions: "Workspace 操作", + newAgent: "新建 Agent", + newTerminal: "新建 Terminal", + newBrowser: "新建浏览器标签", + importSession: "导入会话", + copyPath: "复制 workspace 路径", + copyBranchName: "复制分支名称", + showSetup: "显示 setup", + }, + toasts: { + workspacePathUnavailable: "Workspace 路径尚不可用", + branchNameUnavailable: "分支名称不可用", + terminalQueued: "正在准备 workspace,Terminal 准备好后会打开...", + workspacePathCopiedLabel: "Workspace 路径", + branchNameCopiedLabel: "分支名称", + }, + }, + scripts: { + title: "Scripts", + actions: { + run: "运行", + view: "查看", + }, + accessibility: { + trigger: "Workspace scripts", + openAt: "在 {{label}} 打开 {{scriptName}}", + viewTerminal: "查看 {{scriptName}} Terminal", + runScript: "运行 {{scriptName}} script", + script: "{{scriptName}} script", + }, + states: { + exitCode: "exit {{code}}", + startFailed: "启动 {{scriptName}} 失败", + }, + }, + git: { + actions: { + moreOptions: "更多选项", + moreActions: "更多操作", + commit: { + label: "Commit", + pending: "正在 commit...", + success: "已 commit", + }, + pull: { + label: "Pull", + pending: "正在 pull...", + success: "已 pull", + }, + push: { + label: "Push", + pending: "正在 push...", + success: "已 push", + }, + pullAndPush: { + label: "Pull 并 push", + pending: "正在 pull 并 push...", + success: "已 pull 并 push", + }, + viewPr: "查看 PR", + createPr: { + label: "创建 PR", + pending: "正在创建 PR...", + success: "PR 已创建", + }, + mergeBranch: { + label: "本地 merge", + pending: "正在 merge...", + success: "已 merge", + }, + mergeFromBase: { + label: "从 {{baseRef}} 更新", + pending: "正在更新...", + success: "已更新", + }, + archive: { + label: "归档 worktree", + pending: "正在归档...", + success: "已归档", + }, + mergePr: { + squash: "Squash and merge", + merge: "Create a merge commit", + rebase: "Rebase and merge", + pending: "正在 merge PR...", + success: "PR 已 merge", + }, + autoMerge: { + enableSquash: "启用 squash auto-merge", + enableMerge: "启用 merge commit auto-merge", + enableRebase: "启用 rebase auto-merge", + enabled: "Auto-merge 已启用", + enabling: "正在启用 auto-merge...", + disabling: "正在禁用 auto-merge...", + disabled: "Auto-merge 已禁用", + }, + unavailable: { + viewPrNoGithub: "当前无法查看 PR,因为 GitHub 未连接", + pullNoRemote: "此处无法 pull,因为此分支尚未连接到 remote", + pullDirty: "有本地变更时无法 pull,请先 commit 或 stash", + pullUpToDate: "无法 pull,因为此分支已是最新", + pushNoRemote: "此处无法 push,因为此分支尚未连接到 remote", + pushBehind: "暂时无法 push,因为需要先拉取更新的变更", + pushNothing: "无法 push,因为没有新的内容可发送", + pullAndPushNoRemote: "此处无法 pull 并 push,因为此分支尚未连接到 remote", + pullAndPushDirty: "有本地变更时无法 pull 并 push,请先 commit 或 stash", + pullAndPushInSync: "无法 pull 并 push,因为此分支已同步", + createPrNoGithub: "当前无法创建 PR,因为 GitHub 未连接", + createPrNoCommits: "无法创建 PR,因为此分支还没有新的 commit", + mergeNoBase: "无法 merge,因为无法确定 base branch", + mergeDirty: "有本地变更时无法 merge,请先 commit 或 stash", + mergeNothing: "无法 merge,因为此分支没有可 merge 的新内容", + updateNoBase: "无法更新,因为无法确定 base branch", + updateDirty: "有本地变更时无法更新,请先 commit 或 stash", + updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新", + archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的", + mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接", + mergePrMissing: "无法 merge PR,因为还没有 pull request", + mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft", + mergePrMerged: "无法 merge PR,因为 pull request 已 merge", + mergePrClosed: "无法 merge PR,因为 pull request 已关闭", + mergePrConflicts: "无法 merge PR,因为 pull request 存在冲突", + mergePrQueue: "此处无法 merge PR,因为此 repository 使用 merge queue", + mergePrNotReady: "GitHub 报告 pull request 可 merge 后才能 merge PR", + autoMergeCannotDisable: "Auto-merge 已启用,但此账号无法禁用", + }, + toasts: { + failedCommit: "Commit 失败", + failedPull: "Pull 失败", + failedPush: "Push 失败", + failedPullAndPush: "Pull 并 push 失败", + failedCreatePr: "创建 PR 失败", + failedMergePr: "Merge PR 失败", + failedEnableAutoMerge: "启用 auto-merge 失败", + failedDisableAutoMerge: "禁用 auto-merge 失败", + baseRefUnavailable: "Base ref 不可用", + failedMerge: "Merge 失败", + failedMergeFromBase: "从 base merge 失败", + worktreePathUnavailable: "Worktree 路径不可用", + failedArchive: "归档 worktree 失败", + }, + archiveWarning: { + title: "归档「{{worktreeName}}」?", + confirm: "归档", + cancel: "取消", + uncommittedChanges: "未 commit 的变更", + uncommittedChangesWithDiff: "未 commit 的变更({{diffStat}})", + addedLine: "新增 {{count}} 行", + addedLines: "新增 {{count}} 行", + deletedLine: "删除 {{count}} 行", + deletedLines: "删除 {{count}} 行", + unpushedCommit: "{{count}} 个未 push 的 commit", + unpushedCommits: "{{count}} 个未 push 的 commit", + }, + }, + diff: { + binaryFile: "二进制文件", + tooLarge: "Diff 过大,无法显示", + unified: "Unified diff", + split: "Side-by-side diff", + hideWhitespace: "隐藏空白差异", + scrollLongLines: "滚动长行", + wrapLongLines: "自动换行长行", + collapseAll: "折叠所有文件", + expandAll: "展开所有文件", + refreshing: "正在刷新", + refresh: "刷新", + refreshState: "刷新 git 和 GitHub 状态", + failedRefresh: "刷新 git 状态失败。", + emptyHiddenWhitespace: "隐藏空白差异后没有可见变更", + emptyUncommitted: "没有未 commit 的变更", + emptyAgainstBase: "相对于 {{baseRef}} 没有变更", + checkingRepository: "正在检查 repository...", + notRepository: "不是 git repository", + diffMode: "Diff 模式", + uncommitted: "未 commit", + committed: "已 commit", + branchUnknown: "未知", + base: "base", + newFile: "新增", + deletedFile: "已删除", + }, + openInEditor: { + open: "打开", + chooseEditor: "选择编辑器", + openIn: "在 {{target}} 中打开 workspace", + openFileIn: "在 {{target}} 中打开 {{fileName}}", + failedOpen: "打开 workspace 失败", + }, + pr: { + sections: { + checks: "Checks", + reviews: "Reviews", + }, + accessibility: { + pullRequest: "Pull request #{{number}}", + }, + states: { + draft: "Draft", + merged: "已 merge", + closed: "已关闭", + open: "Open", + }, + activity: { + commented: "已评论", + approved: "已批准", + requestedChanges: "请求修改", + reviewed: "已 review", + }, + time: { + justNow: "刚刚", + }, + errors: { + statusLoadFailed: "无法加载 Pull Request 状态", + activityLoadFailed: "无法加载 Pull Request 活动", + }, + }, + }, + }, + sidebar: { + host: { + noHost: "没有 Host", + switchTitle: "切换 Host", + searchPlaceholder: "搜索 Hosts...", + }, + actions: { + addProject: "添加 project", + home: "首页", + settings: "设置", + closeSidebar: "关闭侧边栏", + }, + sections: { + sessions: "会话", + }, + worktreeSetup: { + title: "设置 worktree scripts", + description: "添加 setup 命令,让新的 worktree 自动安装依赖并完成准备。", + openProjectSettings: "打开 project 设置", + }, + project: { + actions: { + menu: "Project 操作", + openSettings: "打开 project 设置", + openNewWindow: "在新窗口中打开", + openNewWindowFailed: "无法打开新窗口", + remove: "移除 project", + removing: "正在移除...", + }, + confirmations: { + removeTitle: "移除 project?", + removeMessage: "从侧边栏移除「{{projectName}}」?\n\n磁盘上的文件不会被更改。", + removeConfirm: "移除", + cancel: "取消", + }, + toasts: { + hostDisconnected: "Host 未连接", + removeFailed: "部分 workspace 移除失败", + }, + empty: { + title: "还没有 projects", + description: "添加 project 以开始", + }, + }, + workspace: { + status: { + scriptsAvailable: "有可用 scripts", + creating: "正在创建...", + }, + actions: { + menu: "Workspace 操作", + newWorkspace: "新建 workspace", + createWorkspaceFor: "为 {{projectName}} 新建 workspace", + copyPath: "复制路径", + copyBranchName: "复制分支名称", + rename: "重命名 workspace", + archive: "归档", + archiveWorktree: "归档 worktree", + hideFromSidebar: "从侧边栏隐藏", + archiving: "正在归档...", + hiding: "正在隐藏...", + }, + confirmations: { + hideTitle: "隐藏 workspace?", + hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。", + hideConfirm: "隐藏", + cancel: "取消", + }, + rename: { + title: "重命名 workspace", + submit: "重命名", + invalidBranchName: "无效的分支名称", + }, + toasts: { + workspacePathUnavailable: "Workspace 路径不可用", + pathCopied: "路径已复制", + branchNameCopied: "分支名称已复制", + hostDisconnected: "Host 未连接", + hideFailed: "隐藏 workspace 失败", + archiveFailed: "归档 worktree 失败", + }, + }, + }, + newWorkspace: { + title: "新建 workspace", + create: "创建", + errors: { + hostDisconnected: "Host 未连接", + createWorktreeFailed: "创建 worktree 失败", + composerStateRequired: "Composer 状态必填", + selectModel: "请选择模型", + }, + refPicker: { + startingRef: "起始 ref", + chooseStart: "选择起始位置", + checkoutHint: "Checkout PR #{{number}}?", + checkoutPr: "Checkout PR #{{number}}", + dismissCheckoutHint: "忽略 PR #{{number}} checkout 提示", + intoBase: "进入 {{baseRef}}", + searching: "正在搜索...", + noMatchingRefs: "没有匹配的 refs。", + searchPlaceholder: "搜索分支和 PR", + title: "起始位置", + }, + }, + desktop: { + quitting: { + title: "正在退出 Paseo...", + detail: "正在停止本地 daemon。", + }, + daemon: { + title: "Daemon", + status: { + title: "状态", + builtInOnly: "这里只显示内置桌面 daemon", + running: "running", + notRunning: "not running", + pid: "PID {{pid}}", + }, + management: { + title: "管理内置 daemon", + hint: "让 Paseo 启动和停止内置 daemon", + pauseTitle: "暂停内置 daemon", + pauseMessage: + "这会立即停止内置 daemon。连接到内置 daemon 的运行中 agents 和 terminals 会被停止。", + pauseAndStop: "暂停并停止", + registrationFailed: + "内置 daemon 已启动,但 Paseo 无法保存 localhost 连接。请关闭后重新开启 daemon 管理,或手动添加 localhost。", + pausedStopFailed: "内置 daemon 管理已暂停,但 Paseo 无法停止 daemon。", + updateFailed: "无法更新内置 daemon 管理设置。", + }, + keepRunning: { + title: "退出后保持 daemon 运行", + hint: "退出 Paseo 后 daemon 会继续运行", + }, + logs: { + title: "日志文件", + modalTitle: "Daemon 日志", + unavailable: "日志路径不可用", + empty: "(日志文件为空)", + copied: "日志路径已复制。", + copyFailed: "无法复制日志路径。", + open: "打开日志", + copyPath: "复制路径", + }, + fullStatus: { + title: "完整状态", + modalTitle: "Daemon 状态", + hint: "运行 `paseo daemon status` 并显示输出", + view: "查看状态", + copied: "状态已复制到剪贴板。", + fetchFailed: "获取 daemon 状态失败:{{message}}", + }, + advancedSettings: "高级设置", + openAdvancedSettings: "打开 daemon 高级设置", + versionMismatch: "App 和 daemon 版本不匹配。请将两者更新到相同版本,以获得最佳体验。", + loadFailed: "无法加载桌面 daemon 状态。", + }, + updates: { + status: { + checking: "正在检查 app 更新...", + installing: "正在安装 app 更新...", + upToDate: "App 已是最新版本。", + upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。", + pending: "更新准备好后会通知你。", + availableWithVersion: "更新已就绪:{{version}}", + available: "有 app 更新可安装。", + installed: "App 更新已安装。需要重启。", + failed: "App 更新失败。", + idle: "尚未检查更新状态。", + }, + installError: "无法安装 desktop app 更新。", + callout: { + installingTitle: "正在安装更新", + failedTitle: "更新失败", + availableTitle: "有可用更新", + genericError: "出了点问题。", + whatsNew: "更新内容", + installingAction: "正在安装...", + installAndRestart: "安装并重启", + installingDescription: "正在安装并重启...", + versionReady: "{{version}} 已准备好安装。", + newVersionReady: "新版本已准备好安装。", + restartWarning: "升级 app 会停止正在运行的 agents,并关闭 terminal 会话。", + }, + }, + settings: { + loadFailed: "无法加载桌面设置。", + saveFailed: "无法保存桌面设置。", + }, + rosetta: { + title: "下载 Apple Silicon 构建", + runningIntel: "你正在 Apple Silicon 上通过 Rosetta 运行 Paseo 的 Intel 构建。", + highCpu: "这会导致较高 CPU 使用率。下载 Apple Silicon 构建即可修复。", + download: "下载", + }, + permissions: { + notifications: { + allowed: "系统已允许通知。", + denied: "系统设置中已拒绝通知。", + notGranted: "通知权限尚未授予。", + webOnly: "桌面通知状态仅在 web runtime 中可用。", + supported: "支持桌面通知。", + unsupported: "此平台不支持桌面通知。", + apiUnavailable: "此环境中 Web Notification API 不可用。", + requestsWebOnly: "桌面通知请求仅在 web runtime 中可用。", + requestUnavailable: "Web Notification API requestPermission() 不可用。", + requestFailed: "请求通知权限失败:{{message}}", + unexpectedState: "意外的通知权限状态:{{state}}", + }, + microphone: { + webOnly: "桌面麦克风状态仅在 web runtime 中可用。", + navigatorUnavailable: "此环境中 Navigator 不可用。", + granted: "已授予麦克风访问权限。", + denied: "系统设置中已拒绝麦克风访问。", + notGranted: "麦克风权限尚未授予。", + unexpectedState: "意外的麦克风权限状态:{{state}}", + statusApiUnavailable: "此 runtime 中麦克风状态 API 不可用。请使用请求来检查访问权限。", + queryFailed: "查询麦克风状态失败:{{message}}", + captureUnavailable: "此环境中麦克风采集不可用。", + permissionApiUnavailable: "权限状态 API 不可用。请使用请求来检查访问权限。", + requestsWebOnly: "桌面麦克风请求仅在 web runtime 中可用。", + captureApiUnavailable: "此环境中麦克风采集 API 不可用。", + requestDenied: "用户或系统拒绝了麦克风权限。", + noDevice: "未找到麦克风设备。", + requestFailed: "请求麦克风权限失败:{{message}}", + }, + empty: { + notifications: "尚未检查通知状态。", + microphone: "尚未检查麦克风状态。", + }, + testNotification: { + title: "Paseo 通知测试", + body: "如果你能看到这条通知,说明桌面通知可用。", + notDelivered: "通知未送达。请检查 System Settings > Notifications。", + failed: "发送通知失败。", + }, + }, + integrations: { + cli: { + statusFailed: "无法检查 CLI 安装状态。", + installFailed: "无法安装 Paseo CLI。", + }, + skills: { + statusFailed: "无法检查编排 skills 状态。", + installFailed: "无法安装编排 skills。", + updateFailed: "无法更新编排 skills。", + uninstallFailed: "无法卸载编排 skills。", + }, + }, + }, + startup: { + errorTitle: "出现问题", + errorDescription: "本地服务器启动失败。如果持续发生,请在 GitHub 报告问题并附上下方日志。", + logs: { + loading: "正在加载 daemon 日志...", + unavailable: "没有可用的 daemon 日志。", + loadFailed: "无法加载 daemon 日志:{{message}}", + }, + }, + openProject: { + tiles: { + addProject: { + title: "添加 project", + description: "打开此机器上的文件夹", + }, + importSession: { + title: "导入会话", + description: "导入最近的外部 CLI 会话", + }, + setupProviders: { + title: "设置 providers", + description: "配置 Claude Code、Codex 等", + }, + pairDevice: { + title: "配对设备", + description: "将手机连接到此 daemon", + }, + }, + }, + projectPicker: { + placeholder: "输入目录路径...", + opening: "正在打开 project...", + empty: "开始输入路径", + }, + branchSwitcher: { + currentBranch: "当前分支:{{branchName}}。按下以切换分支。", + placeholder: "切换分支...", + searchPlaceholder: "筛选分支...", + empty: "没有找到分支。", + title: "切换分支", + uncommittedTitle: "未 commit 的变更", + uncommittedMessage: "你有未 commit 的变更。切换分支前要先 stash 吗?", + stashAndSwitch: "Stash 并切换", + failedToStash: "Stash 变更失败", + failedToSwitch: "切换分支失败", + restoreStashTitle: "恢复 stashed 变更?", + restoreStashMessage: "此分支有上一会话 stashed 的变更。要恢复它们吗?", + restore: "恢复", + later: "稍后", + stashRestored: "Stashed 变更已恢复", + }, + agentAutocomplete: { + searchingWorkspace: "正在搜索 workspace...", + loadingCommands: "正在加载 commands...", + noFiles: "没有找到文件或目录", + noCommands: "没有找到 commands", + failedToLoad: "加载失败", + }, + loadOlderHistory: { + failed: "无法加载更早历史", + }, + imageAttachmentPicker: { + permissionTitle: "需要权限", + permissionMessage: "请允许访问照片图库以附加图片。", + errorTitle: "错误", + failedToSelect: "选择图片失败", + dialogTitle: "附加图片", + dialogFilterName: "图片", + }, + workspaceSetup: { + title: "创建 workspace", + errors: { + failedCreateWorktree: "创建 worktree 失败", + failedOpenProject: "打开 project 失败", + selectModel: "请选择模型", + hostDisconnected: "Host 未连接", + pendingRequired: "没有待处理的 workspace setup", + composerStateRequired: "Workspace setup composer 状态必填", + }, + }, + onboarding: { + title: "欢迎使用 Paseo", + subtitle: "连接你的电脑即可开始", + actions: { + settings: "设置", + }, + }, + modelSelector: { + title: "选择 provider", + selectModel: "选择模型", + selectedModel: "选择模型({{model}})", + loading: "正在加载...", + loadingShort: "正在加载", + loadingSelector: "正在加载模型选择器...", + error: "错误", + defaultModel: "默认", + favorites: "收藏", + favoriteModel: "收藏模型", + unfavoriteModel: "取消收藏模型", + modelCount: "{{count}} 个模型", + modelCountPlural: "{{count}} 个模型", + retry: "重试", + retrying: "正在重试...", + noMatches: "没有匹配的模型", + searchPlaceholder: "搜索模型...", + openProviderSettings: "打开 {{provider}} 设置", + }, + providerCatalog: { + title: "添加 provider", + search: "搜索 providers", + noProviders: "未找到 providers", + actions: { + add: "添加", + adding: "正在添加", + installed: "已安装", + cancel: "取消", + installInstructions: "安装说明", + installInstructionsFor: "{{provider}} 安装说明", + }, + errors: { + unableToInstall: "无法安装 provider", + }, + }, + providerSelection: { + defaultModel: "默认", + selectModel: "选择模型", + loading: "正在加载...", + error: "错误", + unavailable: "不可用", + unknownError: "未知错误", + readiness: { + initialPromptRequired: "初始 prompt 必填", + noProviders: "所选 Host 上没有可用的 provider", + modelDefaultsLoading: "模型默认值仍在加载", + noModelAvailable: "所选 provider 没有可用模型", + workspaceDirectoryNotFound: "Workspace 目录未找到", + hostDisconnected: "Host 未连接", + }, + }, + pairing: { + connectionMethods: { + title: "添加连接", + direct: { + title: "直接连接", + description: "本地网络或 VPN。", + }, + scanQr: { + title: "扫描二维码", + description: "加密 relay 连接。", + }, + pasteLink: { + title: "粘贴配对链接", + description: "加密 relay 连接。", + }, + }, + direct: { + title: "直接连接", + helper: "输入 Paseo server 的地址。", + fields: { + host: "Host", + port: "端口", + password: "密码", + optional: "可选", + useSsl: "使用 SSL", + connectionUri: "连接 URI", + }, + advanced: { + label: "高级", + show: "显示高级选项", + hide: "隐藏高级选项", + }, + passwordVisibility: { + show: "显示密码", + hide: "隐藏密码", + }, + actions: { + cancel: "取消", + connect: "连接", + connecting: "正在连接...", + }, + errors: { + hostRequired: "Host 必填", + invalidPort: "端口必须在 1 到 65535 之间", + invalidConnection: "无效连接", + failedTitle: "连接失败", + failedToConnect: "无法连接到 {{endpoint}}。", + noAdditionalDetails: "{{detail}}(未提供更多详情)", + timedOut: "连接超时。请检查 host/port 和网络。", + refused: "连接被拒绝。server 是否正在此地址运行?", + hostNotFound: "未找到 host。请检查主机名后重试。", + hostUnreachable: "Host 不可达。请检查网络和防火墙。", + tlsError: "TLS 错误。只有 daemon 前方有 TLS terminator 时,直接连接才使用 SSL。", + unableToConnect: "无法连接。请检查 host/port,并确认 daemon 可达。", + details: "详情:{{detail}}", + }, + }, + link: { + title: "粘贴配对链接", + helper: "粘贴来自 server 的配对链接。", + label: "配对链接", + errors: { + required: "请粘贴配对链接(.../#offer=...)", + missingOffer: "链接必须包含 #offer=...", + emptyOffer: "Offer payload 为空", + invalid: "无效的配对链接", + unableToPair: "无法配对 host", + }, + alert: { + failedTitle: "配对失败", + }, + actions: { + cancel: "取消", + pair: "配对", + pairing: "正在配对...", + }, + }, + scan: { + title: "扫描二维码", + webUnavailableTitle: "Web 上不可用", + webUnavailableBody: "Web build 不支持二维码扫描。请改用“粘贴链接”。", + backToSettings: "返回设置", + cameraPermissionTitle: "相机权限", + cameraPermissionBody: "允许相机访问,以扫描 daemon 提供的配对二维码。", + grantPermission: "授予权限", + pairing: "正在配对...", + unableToPair: "无法配对 host", + errorTitle: "错误", + }, + device: { + loadingOffer: "正在加载配对 offer...", + failedToLoadOffer: "加载配对 offer 失败。", + relayDisabled: "Relay 未启用。启用 relay 后才能配对设备。", + unavailable: "配对 offer 不可用。", + hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。", + qrUnavailable: "二维码不可用。", + retry: "重试", + copy: "复制", + copied: "已复制", + }, + }, + realtimeVoice: { + actions: { + mute: "静音 realtime voice", + unmute: "取消静音 realtime voice", + stop: "停止 realtime voice 并中断 turn", + }, + }, + rewind: { + tooltip: "回退到此消息", + warning: "此操作无法撤销", + actions: { + conversation: "回退对话", + files: "回退文件", + both: "回退对话和文件", + }, + errors: { + failed: "回退 agent 失败", + }, + }, + diffViewer: { + empty: "没有可显示的变更", + }, + serviceUrl: { + title: "打开服务 URL", + message: "打开 {{url}}?", + inPaseo: "在 Paseo 中", + externalBrowser: "外部浏览器", + dontAskAgain: "不再询问", + }, + downloads: { + requestTokenFailed: "请求下载 token 失败。", + hostUnavailable: "下载 Host 不可用。", + cancelled: "下载已取消。", + failed: "下载文件失败。", + shareFile: "共享文件", + shareFileNamed: "共享 {{fileName}}", + }, + menu: { + backdrop: "菜单背景", + }, + subagents: { + archiveAction: "归档 {{label}}", + archiveTooltip: "归档 subagent", + }, + panels: { + draft: { + newAgent: "新建 Agent", + creatingAgent: "正在创建 Agent", + }, + file: { + executionDirectoryMissing: "未找到 workspace 执行目录。", + loading: "正在加载文件...", + noPreview: "没有可用预览", + binaryPreviewUnavailable: "二进制预览不可用", + failedToLoad: "加载文件失败", + failedToLoadPreview: "加载文件预览失败", + }, + }, + toolCallDetails: { + error: "错误", + empty: "没有可用的更多详情", + subAgentActivity: "Sub-agent 活动", + input: "输入", + output: "输出", + }, + renameModal: { + rename: "重命名", + saving: "正在保存...", + }, + sidebarCallout: { + dismiss: "关闭", + }, + contextWindow: { + title: "上下文窗口", + used: "已使用 {{percentage}}%", + tokens: "{{used}} / {{max}} tokens", + sessionCost: "会话费用 {{cost}}", + accessibility: "上下文窗口已使用 {{percentage}}%", + }, + review: { + comment: { + add: "添加 review 评论", + edit: "编辑 review 评论", + delete: "删除 review 评论", + label: "Review 评论", + placeholder: "留下评论", + cancel: "取消", + cancelAccessibility: "取消 review 评论", + save: "评论", + saveAccessibility: "保存 review 评论", + }, + }, + settings: { + title: "设置", + loading: "正在加载设置...", + groups: { + app: "应用", + host: "主机", + }, + hostPicker: { + switchHost: "切换主机", + local: "本机", + }, + backToWorkspace: "返回", + addHost: "添加主机", + projects: "项目", + projectList: { + hostLoadFailed: "无法从 Host {{hostName}} 加载 projects:{{message}}", + editProject: "编辑 {{projectName}}", + }, + groupInfo: "关于 {{title}}", + sections: { + general: "通用", + daemon: "Daemon", + appearance: "外观", + shortcuts: "快捷键", + integrations: "集成", + permissions: "权限", + diagnostics: "诊断", + about: "关于", + }, + hostSections: { + connections: "连接", + agents: "Agents", + workspaces: "Workspaces", + providers: "Providers", + host: "Host", + }, + general: { + title: "通用", + defaultSend: { + label: "默认发送", + description: "Agent 运行时按 Enter 的行为", + options: { + interrupt: "中断", + queue: "排队", + }, + }, + serviceUrls: { + label: "服务 URL", + description: "运行脚本中的 URL 打开位置", + options: { + ask: "询问", + inApp: "在 Paseo 中", + external: "外部浏览器", + }, + }, + terminalScrollback: { + label: "终端回滚", + description: "内置终端缓冲区保留的行数", + accessibilityLabel: "终端回滚行数", + }, + language: { + label: "语言", + description: "应用语言", + options: { + system: "系统", + ar: "العربية", + en: "English", + es: "Español", + fr: "Français", + ru: "Русский", + zhCN: "简体中文", + }, + }, + }, + diagnostics: { + title: "诊断", + testAudio: "测试音频", + playTest: "播放测试", + playing: "正在播放...", + playbackFailed: "播放失败:{{message}}", + }, + about: { + title: "关于", + appVersion: "应用版本", + thisDevice: "此设备", + connectedHosts: "已连接的 Host", + offline: "离线", + versionDiffers: "版本与此设备不同", + releaseChannel: { + label: "发布通道", + description: "切换到 Beta 可更早获取更新并参与改进", + stable: "Stable", + beta: "Beta", + }, + updates: { + label: "应用更新", + readyToInstall: "可安装:{{version}}", + installTitle: "安装桌面版更新", + installMessage: "这会更新此电脑上的 Paseo", + installConfirm: "安装更新", + update: "更新", + updateTo: "更新到 {{version}}", + installing: "正在安装...", + check: "检查", + checking: "正在检查...", + alertTitle: "错误", + alertMessage: "无法打开更新确认对话框。", + }, + }, + appearance: { + theme: { + title: "主题", + accessibilityLabel: "主题:{{value}}", + options: { + light: "Light", + dark: "Dark", + zinc: "Zinc", + midnight: "Midnight", + claude: "Claude", + ghostty: "Ghostty", + auto: "系统", + }, + }, + fonts: { + title: "字体", + systemDefault: "系统默认", + interfaceFont: "界面字体", + interfaceFontHint: "用于整个应用。留空则使用系统默认", + interfaceFontAccessibility: "界面字体族", + interfaceSize: "界面字号", + interfaceSizeAccessibility: "界面字号", + codeFont: "代码字体", + codeFontHint: "用于代码、diff 和终端输出。留空则使用系统默认", + codeFontAccessibility: "代码字体族", + codeSize: "代码字号", + codeSizeAccessibility: "代码字号", + }, + syntax: { + title: "语法", + highlightTheme: "高亮主题", + highlightThemeHint: "代码配色,独立于应用主题", + highlightThemeAccessibility: "高亮主题:{{value}}", + previewAccessibility: "语法主题和代码字体的实时预览", + }, + }, + shortcuts: { + dialogTitle: "快捷键", + unavailableOnMobile: "键盘快捷键仅在桌面端可用", + capturePrompt: "按下快捷键...", + actions: { + done: "完成", + cancel: "取消", + rebind: "重新绑定", + reset: "重置", + resetAll: "全部重置", + }, + sections: { + navigation: "导航", + tabsPanes: "标签和窗格", + projects: "项目", + panels: "面板", + agentInput: "Agent 输入", + }, + help: { + openProject: "打开项目", + newWorktree: "新建 worktree", + archiveWorktree: "归档 worktree", + newTab: "新建标签", + closeCurrentTab: "关闭当前标签", + jumpToWorkspace: "跳转到 workspace", + jumpToTab: "跳转到标签", + previousWorkspace: "上一个 workspace", + nextWorkspace: "下一个 workspace", + previousTab: "上一个标签", + nextTab: "下一个标签", + splitPaneRight: "向右拆分窗格", + splitPaneDown: "向下拆分窗格", + focusPaneLeft: "聚焦左侧窗格", + focusPaneRight: "聚焦右侧窗格", + focusPaneUp: "聚焦上方窗格", + focusPaneDown: "聚焦下方窗格", + moveTabLeft: "向左移动标签", + moveTabRight: "向右移动标签", + moveTabUp: "向上移动标签", + moveTabDown: "向下移动标签", + closePane: "关闭窗格", + newTerminal: "新建终端", + toggleCommandCenter: "切换命令中心", + showKeyboardShortcuts: "显示键盘快捷键", + toggleLeftSidebar: "切换左侧边栏", + toggleRightSidebar: "切换右侧边栏", + toggleBothSidebars: "切换两侧边栏", + toggleSettings: "切换设置", + toggleFocusMode: "切换专注模式", + cycleTheme: "循环切换主题", + focusMessageInput: "聚焦消息输入框", + toggleVoiceMode: "切换语音模式", + startStopDictation: "开始/停止听写", + interruptAgent: "中断 Agent", + sendMessage: "发送消息", + queueMessage: "消息排队", + muteUnmuteVoiceMode: "静音/取消静音语音模式", + }, + helpNotes: { + showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。", + }, + }, + integrations: { + title: "集成", + docs: { + cli: "CLI 文档", + skills: "Skills 文档", + openCli: "打开 CLI 文档", + openSkills: "打开 skills 文档", + }, + commandLine: { + title: "命令行", + description: "从终端控制 Agent 并运行脚本", + }, + skills: { + title: "编排 skills", + description: "教会 Agent 通过 CLI 编排任务", + updateAvailable: "有更新可用", + updateTitle: "更新 Paseo skills?", + updateFallback: "将内置 skills 同步到你的机器。", + uninstallTitle: "卸载 Paseo skills?", + uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。", + }, + actions: { + install: "安装", + installing: "正在安装...", + installed: "已安装", + update: "更新", + working: "处理中...", + uninstall: "卸载", + }, + operations: { + add: "添加 skill", + update: "更新 skill", + delete: "删除 skill", + }, + }, + permissions: { + title: "权限", + notifications: "通知", + microphone: "麦克风", + refresh: "刷新", + refreshing: "正在刷新...", + refreshAccessibility: "刷新桌面端权限", + test: "测试", + actions: { + granted: "已授权", + request: "请求", + requesting: "正在请求...", + busySuffix: "{{label}}...", + }, + }, + host: { + notFound: "Host 未找到", + badges: { + relay: "Relay", + local: "本地", + }, + connections: { + title: "连接", + removeTitle: "移除连接", + removeMessage: "移除 {{name}}?此操作无法撤销。", + removeAction: "移除", + removeErrorTitle: "错误", + removeErrorMessage: "无法移除连接", + timeout: "超时", + }, + pairDevices: { + title: "配对设备", + rowTitle: "配对设备", + rowHint: "扫描二维码或复制链接,将手机连接到这个 Host", + }, + orchestration: { + title: "编排", + unavailable: "连接到这个 Host 以管理编排", + enableTools: { + title: "启用 Paseo tools", + hint: "Agent 将能够管理 worktree、Agent 和计划", + accessibilityLabel: "注入 Paseo tools", + }, + systemPrompt: { + title: "System prompt", + hint: "为所有 Agent 添加 system prompt", + sheetTitle: "追加 system prompt", + accessibilityLabel: "追加 system prompt", + placeholder: "始终保持回复简洁。", + }, + }, + agents: { + unavailable: "连接到这个 Host 以管理 Agent", + }, + workspaces: { + unavailable: "连接到这个 Host 以管理 Workspace", + }, + daemon: { + rename: { + editLabel: "编辑标签", + title: "重命名 Host", + placeholder: "我的 Host", + }, + restart: { + title: "重启 Daemon", + hint: "重启 Daemon 进程。应用会自动重新连接", + confirmTitle: "重启 {{name}}", + confirmMessage: "这会重启 Daemon。其上运行的 Agent 会继续运行;应用会自动重新连接。", + restarting: "正在重启...", + unableToReconnectTitle: "无法重新连接", + unableToReconnectMessage: "{{name}} 没有重新上线。请确认它已重启。", + unavailableTitle: "Host 不可用", + unavailableMessage: "这个 Host 尚未连接。请等待它上线后再重启。", + offlineTitle: "Host 离线", + offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。", + requestFailedTitle: "错误", + requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。", + dialogFailedMessage: "无法打开重启确认对话框。", + }, + dangerZone: "危险区域", + remove: { + title: "移除 Host", + localTitle: "移除 localhost 连接", + hint: "从此设备移除这个 Host 及其已保存连接", + localHint: "从此设备移除 localhost,并停止内置 Daemon", + localConfirmTitle: "移除 localhost 连接并停止 Daemon?", + confirmMessage: "移除 {{name}}?这会删除其已保存连接。", + localConfirmMessage: + "这会移除 localhost 连接、关闭内置 Daemon 管理,并停止托管 Daemon。远程 Host 会保持连接。", + errorTitle: "错误", + errorMessage: "无法移除 Host", + localErrorMessage: "无法移除 localhost 连接", + }, + }, + }, + providers: { + title: "Providers", + addProvider: "添加 Provider", + providerDetails: "{{name}} Provider 详情", + enableProvider: "启用 {{name}}", + unavailable: "连接到这个 Host 以查看 Providers", + loading: "正在加载...", + addErrorTitle: "无法添加 Provider", + updateErrorTitle: "无法更新 Provider", + statuses: { + disabled: "已禁用", + loading: "正在加载", + error: "错误", + available: "可用", + notInstalled: "未安装", + }, + models: { + one: "1 个 Model", + many: "{{count}} 个 Model", + addModel: "添加 Model", + addCustomTitle: "添加自定义 Model", + modelId: "Model ID", + modelIdPlaceholder: "例如 openai/gpt-5", + add: "添加", + adding: "正在添加...", + failedToSave: "保存 Model 失败", + removeModel: "移除 {{id}}", + searchPlaceholder: "搜索 Models", + loading: "正在加载 Models...", + retry: "重试", + retrying: "正在重试...", + noSearchMatches: "没有匹配搜索的 Model", + noneDetected: "未检测到 Model", + discovered: "已发现", + custom: "自定义 Models", + updated: "已更新 {{time}}", + }, + diagnostic: { + title: "诊断", + button: "诊断", + refresh: "刷新", + refreshing: "正在刷新...", + refreshAccessibility: "刷新诊断", + refreshingAccessibility: "正在刷新诊断", + running: "正在运行诊断...", + none: "没有可用诊断", + failedToFetch: "获取诊断失败", + unknownError: "未知错误", + }, + }, + project: { + noEditableTarget: "任何已连接 Host 上都没有这个 Project 的可编辑副本。", + backToProjects: "返回 Projects", + switchHost: "切换 Host", + rename: { + renamedToast: "Project 已重命名", + errorFallback: "无法重命名 Project", + renameLabel: "重命名 Project", + resetLabel: "将 Project 名称重置为默认值", + projectNameLabel: "Project 名称", + saveLabel: "保存 Project 名称", + cancelLabel: "取消重命名", + reset: "重置", + }, + readFailures: { + invalidTitle: "无法解析 paseo.json", + invalidDescription: "修复磁盘上的文件,然后重新加载。", + missingTitle: "这个 Host 没有这个 Project", + missingWithHosts: "切换到上方其他 Host,或重新加载。", + missingSingleHost: "所选 Host 没有这个 Project 的记录。", + transportTitle: "无法加载 paseo.json", + transportFallback: "Host 没有响应。", + failedTitle: "无法加载 paseo.json", + failedDescription: "重新加载以重试。", + }, + worktree: { + title: "Worktree 生命周期 hooks", + info: "为此 Project 创建或清理 worktree 时运行的命令", + docs: "文档", + docsTooltip: "查看命令可用的环境变量和更多细节", + setup: "Setup", + setupAccessibility: "Worktree setup 命令", + teardown: "Teardown", + teardownAccessibility: "Worktree teardown 命令", + }, + scripts: { + title: "Scripts", + info: "可从此 Project 中任意 Agent 启动的长期服务和一次性命令", + empty: "还没有 scripts。", + untitled: "未命名 script", + port: "端口 {{port}}", + menuAccessibility: "打开 script 菜单", + removeTitle: "移除 script?", + removeMessage: "移除 {{name}}?", + removeFallbackName: "这个 script", + name: "名称", + command: "命令", + nameAccessibility: "Script 名称", + commandAccessibility: "Script 命令", + nameRequired: "名称必填", + commandRequired: "命令必填", + newScript: "新建 script", + editScript: "编辑 {{name}}", + runAsService: "作为服务运行", + serviceHint: "Paseo 会监管该进程,并通过 $PASEO_PORT 分配端口", + actions: { + add: "添加 script", + edit: "编辑", + remove: "移除", + }, + }, + metadata: { + title: "元数据生成", + info: "注入到 Paseo 用来生成元数据的 AI prompts 中的 Project 专属指令,可用于强制执行团队约定,例如分支命名、提交风格或 PR 格式", + agentTitle: "Agent 标题", + agentTitlePlaceholder: "标题保持祈使句且不超过 40 个字符", + branchName: "分支名称", + branchNamePlaceholder: "分支以 feat/ 或 fix/ 开头,个人分支使用 mb/", + commitMessage: "提交消息", + commitMessagePlaceholder: "使用带 scope 的 Conventional Commits", + pullRequest: "Pull requests", + pullRequestPlaceholder: "先写一段摘要,并包含 Test plan 部分", + }, + writeFailures: { + staleTitle: "磁盘上的配置已变更", + staleDescription: "保存前请重新加载最新的 paseo.json。", + failedTitle: "无法保存 paseo.json", + failedDescription: "重试,或从磁盘重新加载最新版本。", + }, + actions: { + reload: "重新加载", + tryAgain: "重试", + save: "保存", + saved: "Project 已保存", + saving: "正在保存...", + cancel: "取消", + }, + }, + }, +}; diff --git a/packages/app/src/i18n/sync-language.test.ts b/packages/app/src/i18n/sync-language.test.ts new file mode 100644 index 000000000..1d10b617d --- /dev/null +++ b/packages/app/src/i18n/sync-language.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureI18nLanguageForRender } from "./sync-language"; + +describe("ensureI18nLanguageForRender", () => { + it("changes the i18n language before callers render children", () => { + const calls: string[] = []; + const i18n = { + language: "en", + changeLanguage: (locale: string) => { + calls.push(locale); + i18n.language = locale; + return Promise.resolve(); + }, + }; + + ensureI18nLanguageForRender("zh-CN", i18n); + + expect(i18n.language).toBe("zh-CN"); + expect(calls).toEqual(["zh-CN"]); + }); + + it("does not call changeLanguage when the current language already matches", () => { + const changeLanguage = vi.fn<() => Promise>().mockResolvedValue(undefined); + + ensureI18nLanguageForRender("en", { + language: "en", + changeLanguage, + }); + + expect(changeLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/i18n/sync-language.ts b/packages/app/src/i18n/sync-language.ts new file mode 100644 index 000000000..32aca5e8d --- /dev/null +++ b/packages/app/src/i18n/sync-language.ts @@ -0,0 +1,26 @@ +import type { SupportedLocale } from "./locales"; + +interface I18nLanguageController { + language?: string; + changeLanguage: (language: SupportedLocale) => Promise; +} + +type I18nErrorReporter = (message: string, error: unknown) => void; + +export const reportI18nError: I18nErrorReporter = (message, error) => { + console.error(message, error); +}; + +export function ensureI18nLanguageForRender( + locale: SupportedLocale, + i18n: I18nLanguageController, + reportError: I18nErrorReporter = reportI18nError, +): void { + if (i18n.language === locale) { + return; + } + + i18n + .changeLanguage(locale) + .catch((error) => reportError("[i18n] Failed to change language", error)); +} diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 6d675ac4c..c29de8830 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -586,4 +586,18 @@ describe("keyboard-shortcut help sections", () => { expect(findRow(sections, id)?.keys).toEqual(keys); } }); + + it("returns stable i18n keys for section titles and help rows", () => { + const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + const projects = sections.find((section) => section.id === "projects"); + const panels = sections.find((section) => section.id === "panels"); + const openProject = findRow(sections, "new-agent"); + const showShortcuts = findRow(sections, "show-shortcuts"); + + expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects"); + expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels"); + expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject"); + expect(openProject?.label).toBe("Open project"); + expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts"); + }); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index a67945aae..a80dc4138 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -28,8 +28,10 @@ export interface KeyboardShortcutMatch { export interface KeyboardShortcutHelpRow { id: string; label: string; + labelKey: string; keys: ShortcutKey[]; note?: string; + noteKey?: string; } export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input"; @@ -37,6 +39,7 @@ export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "pane export interface KeyboardShortcutHelpSection { id: ShortcutSectionId; title: string; + titleKey: string; rows: KeyboardShortcutHelpRow[]; } @@ -107,6 +110,59 @@ const SHORTCUT_HELP_SECTION_TITLES: Record = { "agent-input": "Agent Input", }; +const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record = { + navigation: "settings.shortcuts.sections.navigation", + "tabs-panes": "settings.shortcuts.sections.tabsPanes", + projects: "settings.shortcuts.sections.projects", + panels: "settings.shortcuts.sections.panels", + "agent-input": "settings.shortcuts.sections.agentInput", +}; + +const SHORTCUT_HELP_LABEL_KEYS: Record = { + "new-agent": "settings.shortcuts.help.openProject", + "new-worktree": "settings.shortcuts.help.newWorktree", + "archive-worktree": "settings.shortcuts.help.archiveWorktree", + "workspace-tab-new": "settings.shortcuts.help.newTab", + "workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab", + "workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace", + "workspace-tab-jump-index": "settings.shortcuts.help.jumpToTab", + "workspace-prev": "settings.shortcuts.help.previousWorkspace", + "workspace-next": "settings.shortcuts.help.nextWorkspace", + "workspace-tab-prev": "settings.shortcuts.help.previousTab", + "workspace-tab-next": "settings.shortcuts.help.nextTab", + "workspace-pane-split-right": "settings.shortcuts.help.splitPaneRight", + "workspace-pane-split-down": "settings.shortcuts.help.splitPaneDown", + "workspace-pane-focus-left": "settings.shortcuts.help.focusPaneLeft", + "workspace-pane-focus-right": "settings.shortcuts.help.focusPaneRight", + "workspace-pane-focus-up": "settings.shortcuts.help.focusPaneUp", + "workspace-pane-focus-down": "settings.shortcuts.help.focusPaneDown", + "workspace-pane-move-tab-left": "settings.shortcuts.help.moveTabLeft", + "workspace-pane-move-tab-right": "settings.shortcuts.help.moveTabRight", + "workspace-pane-move-tab-up": "settings.shortcuts.help.moveTabUp", + "workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown", + "workspace-pane-close": "settings.shortcuts.help.closePane", + "workspace-terminal-new": "settings.shortcuts.help.newTerminal", + "toggle-command-center": "settings.shortcuts.help.toggleCommandCenter", + "show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts", + "toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar", + "toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar", + "toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars", + "toggle-settings": "settings.shortcuts.help.toggleSettings", + "toggle-focus": "settings.shortcuts.help.toggleFocusMode", + "cycle-theme": "settings.shortcuts.help.cycleTheme", + "focus-message-input": "settings.shortcuts.help.focusMessageInput", + "voice-toggle": "settings.shortcuts.help.toggleVoiceMode", + "dictation-toggle": "settings.shortcuts.help.startStopDictation", + "agent-interrupt": "settings.shortcuts.help.interruptAgent", + "message-input-send": "settings.shortcuts.help.sendMessage", + "message-input-queue": "settings.shortcuts.help.queueMessage", + "voice-mute-toggle": "settings.shortcuts.help.muteUnmuteVoiceMode", +}; + +const SHORTCUT_HELP_NOTE_KEYS: Record = { + "show-shortcuts": "settings.shortcuts.helpNotes.showKeyboardShortcuts", +}; + // --- Binding definitions --- const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ @@ -1343,8 +1399,10 @@ export function buildKeyboardShortcutHelpSections( rows.push({ id: help.id, label: help.label, + labelKey: SHORTCUT_HELP_LABEL_KEYS[help.id] ?? help.label, keys: help.keys, ...(help.note ? { note: help.note } : {}), + ...(SHORTCUT_HELP_NOTE_KEYS[help.id] ? { noteKey: SHORTCUT_HELP_NOTE_KEYS[help.id] } : {}), }); } @@ -1365,6 +1423,7 @@ export function buildKeyboardShortcutHelpSections( { id: sectionId, title: SHORTCUT_HELP_SECTION_TITLES[sectionId], + titleKey: SHORTCUT_HELP_SECTION_LABEL_KEYS[sectionId], rows, }, ]; diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index db780e3d9..30daf5ffa 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; function TestIcon() { @@ -41,4 +42,27 @@ describe("buildDraftPanelDescriptor", () => { statusBucket: null, }); }); + + it("uses the active language for draft descriptor chrome", async () => { + await i18n.changeLanguage("zh-CN"); + const idleDescriptor = buildDraftPanelDescriptor({ + isCreating: false, + icon: TestIcon, + }); + const creatingDescriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: " ", + icon: TestIcon, + }); + + expect(idleDescriptor).toMatchObject({ + label: "新建 Agent", + subtitle: "新建 Agent", + }); + expect(creatingDescriptor).toMatchObject({ + label: "新建 Agent", + subtitle: "正在创建 Agent", + }); + await i18n.changeLanguage("en"); + }); }); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 57f20a63e..62407f34b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,6 +1,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { TFunction } from "i18next"; import { SquarePen } from "lucide-react-native"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -149,13 +151,14 @@ function buildChatAgentFromState( function renderChatAgentNonReadyView(args: { viewState: AgentScreenViewState; effectiveAgent: AgentScreenAgent | null; + t: TFunction; }): React.ReactElement | null { - const { viewState, effectiveAgent } = args; + const { viewState, effectiveAgent, t } = args; if (viewState.tag === "not_found") { return ( - Agent not found + {t("agentPanel.states.notFound")} ); @@ -164,7 +167,7 @@ function renderChatAgentNonReadyView(args: { return ( - Failed to load agent + {t("agentPanel.states.failedToLoad")} {viewState.message} @@ -443,6 +446,7 @@ function AgentPanelContent({ isPaneFocused: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const { t } = useTranslation(); const resolvedAgentId = agentId.trim() || undefined; const resolvedServerId = serverId.trim() || undefined; const daemons = useHosts(); @@ -456,7 +460,8 @@ function AgentPanelContent({ const daemon = connectionServerId ? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null) : null; - const serverLabel = daemon?.label ?? connectionServerId ?? "Selected host"; + const serverLabel = + daemon?.label ?? connectionServerId ?? t("agentPanel.unavailable.selectedHost"); const isUnknownDaemon = Boolean(connectionServerId && !daemon); const connectionStatus: HostRuntimeConnectionStatus = isUnknownDaemon && runtimeConnectionStatus === "connecting" @@ -471,6 +476,7 @@ function AgentPanelContent({ connectionStatus={connectionStatus} lastError={lastConnectionError} isUnknownDaemon={isUnknownDaemon} + t={t} /> ); } @@ -505,6 +511,7 @@ function AgentPanelBody({ connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const { t } = useTranslation(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( @@ -601,7 +608,7 @@ function AgentPanelBody({ return ( - Agent not found + {t("agentPanel.states.notFound")} ); @@ -611,7 +618,7 @@ function AgentPanelBody({ return ( - Failed to load agent + {t("agentPanel.states.failedToLoad")} {lookupState.message} @@ -670,6 +677,7 @@ function ChatAgentContent({ connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const { t } = useTranslation(); const panelToast = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); @@ -797,12 +805,12 @@ function ChatAgentContent({ } if (!reconnectToastArmedRef.current) { reconnectToastArmedRef.current = true; - panelToast.api.show("Reconnecting...", { + panelToast.api.show(t("agentPanel.states.reconnecting"), { durationMs: null, testID: "agent-reconnecting-toast", }); } - }, [connectionStatus, panelToast]); + }, [connectionStatus, panelToast, t]); useEffect(() => { if (!isPaneFocused || !agentId || !isConnected || !hasSession) { @@ -1014,6 +1022,7 @@ function ChatAgentContent({ const nonReadyView = renderChatAgentNonReadyView({ viewState, effectiveAgent, + t, }); if (nonReadyView) return nonReadyView; invariant(agentId, "agent id is defined when agent content is ready"); @@ -1091,6 +1100,7 @@ function ChatAgentReadyContent({ attentionController: ReturnType; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const { t } = useTranslation(); const agentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1161,8 +1171,8 @@ function ChatAgentReadyContent({ {isArchivingCurrentAgent ? ( - Archiving agent... - Please wait while we archive this agent. + {t("agentPanel.states.archivingTitle")} + {t("agentPanel.states.archivingSubtitle")} ) : null} @@ -1470,22 +1480,22 @@ function AgentSessionUnavailableState({ connectionStatus, lastError, isUnknownDaemon = false, + t, }: { serverLabel: string; connectionStatus: HostRuntimeConnectionStatus; lastError: string | null; isUnknownDaemon?: boolean; + t: TFunction; }) { if (isUnknownDaemon) { return ( - Cannot open this agent because {serverLabel} is not configured on this device. - - - Add the host in Settings or open an agent on a configured server to continue. + {t("agentPanel.unavailable.unknownHost", { serverLabel })} + {t("agentPanel.unavailable.addHost")} ); @@ -1502,20 +1512,22 @@ function AgentSessionUnavailableState({ {isPreparingSession - ? `Preparing ${serverLabel} session...` - : `Connecting to ${serverLabel}...`} + ? t("agentPanel.unavailable.preparingSession", { serverLabel }) + : t("agentPanel.unavailable.connecting", { serverLabel })} {isPreparingSession - ? "We will show this agent in a moment." - : "We will show this agent once the host is online."} + ? t("agentPanel.unavailable.showSoon") + : t("agentPanel.unavailable.showWhenOnline")} ) : ( <> - Reconnecting to {serverLabel}... + + {t("agentPanel.unavailable.reconnectingTo", { serverLabel })} + - We will show this agent again as soon as the host is reachable. + {t("agentPanel.unavailable.showAgainWhenReachable")} {lastError ? {lastError} : null} diff --git a/packages/app/src/panels/draft-panel-descriptor.ts b/packages/app/src/panels/draft-panel-descriptor.ts index a000b2322..15be856af 100644 --- a/packages/app/src/panels/draft-panel-descriptor.ts +++ b/packages/app/src/panels/draft-panel-descriptor.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import { i18n } from "@/i18n/i18next"; import type { PanelDescriptor, PanelIconProps } from "@/panels/panel-registry"; export function buildDraftPanelDescriptor(input: { @@ -7,11 +8,12 @@ export function buildDraftPanelDescriptor(input: { icon: ComponentType; }): PanelDescriptor { const { icon, isCreating, pendingPrompt } = input; - const creatingLabel = pendingPrompt?.trim() || "New Agent"; + const newAgentLabel = i18n.t("panels.draft.newAgent"); + const creatingLabel = pendingPrompt?.trim() || newAgentLabel; if (isCreating) { return { label: creatingLabel, - subtitle: "Creating agent", + subtitle: i18n.t("panels.draft.creatingAgent"), titleState: "ready", icon, statusBucket: "running", @@ -19,8 +21,8 @@ export function buildDraftPanelDescriptor(input: { } return { - label: "New Agent", - subtitle: "New Agent", + label: newAgentLabel, + subtitle: newAgentLabel, titleState: "ready", icon, statusBucket: null, diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 303bfff72..920b2dff7 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -1,6 +1,7 @@ import { Text, View } from "react-native"; import { FileText } from "lucide-react-native"; import invariant from "tiny-invariant"; +import { useTranslation } from "react-i18next"; import { FilePane } from "@/components/file-pane"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; @@ -25,6 +26,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { } function FilePanel() { + const { t } = useTranslation(); const { serverId, workspaceId, target } = usePaneContext(); const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); const workspaceDirectory = workspaceAuthority?.ok @@ -34,7 +36,7 @@ function FilePanel() { if (!workspaceDirectory) { return ( - Workspace execution directory not found. + {t("panels.file.executionDirectoryMissing")} ); } diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 64829d6c3..255cf35a2 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, @@ -25,6 +26,7 @@ function useSetupPanelDescriptor( target: { kind: "setup"; workspaceId: string }, context: { serverId: string; workspaceId: string }, ): PanelDescriptor { + const { t } = useTranslation(); const key = buildWorkspaceTabPersistenceKey({ serverId: context.serverId, workspaceId: target.workspaceId, @@ -33,8 +35,8 @@ function useSetupPanelDescriptor( if (snapshot?.status === "completed") { return { - label: "Setup", - subtitle: "Setup completed", + label: t("workspace.setup.descriptor.label"), + subtitle: t("workspace.setup.descriptor.completed"), titleState: "ready", icon: CheckCircle2, statusBucket: null, @@ -43,8 +45,8 @@ function useSetupPanelDescriptor( if (snapshot?.status === "failed") { return { - label: "Setup", - subtitle: "Setup failed", + label: t("workspace.setup.descriptor.label"), + subtitle: t("workspace.setup.descriptor.failed"), titleState: "ready", icon: CircleAlert, statusBucket: null, @@ -52,8 +54,8 @@ function useSetupPanelDescriptor( } return { - label: "Setup", - subtitle: "Workspace setup", + label: t("workspace.setup.descriptor.label"), + subtitle: t("workspace.setup.descriptor.workspace"), titleState: "ready", icon: SquareTerminal, statusBucket: snapshot?.status === "running" ? "running" : null, @@ -108,11 +110,14 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]): return null; } -function resolveSetupStatusLabel(status: string | undefined): string { - if (status === "running") return "Running"; - if (status === "completed") return "Completed"; - if (status === "failed") return "Failed"; - return "Waiting for setup output"; +function resolveSetupStatusLabel( + status: string | undefined, + labels: Record, +): string { + if (status === "running") return labels.running; + if (status === "completed") return labels.completed; + if (status === "failed") return labels.failed; + return labels.waiting; } function resolveCommandLog( @@ -150,6 +155,7 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) { } function SetupPanel() { + const { t } = useTranslation(); const { serverId, target } = usePaneContext(); invariant(target.kind === "setup", "SetupPanel requires setup target"); @@ -214,7 +220,16 @@ function SetupPanel() { }, []); const autoExpandIndex = resolveAutoExpandIndex(commands); - const statusLabel = resolveSetupStatusLabel(snapshot?.status); + const statusLabels = useMemo( + () => ({ + running: t("workspace.setup.status.running"), + completed: t("workspace.setup.status.completed"), + failed: t("workspace.setup.status.failed"), + waiting: t("workspace.setup.status.waiting"), + }), + [t], + ); + const statusLabel = resolveSetupStatusLabel(snapshot?.status, statusLabels); return ( - Setting up workspace... + {t("workspace.setup.waiting")} ) : null} {!isWaiting && hasNoSetupCommands ? ( @@ -238,9 +253,9 @@ function SetupPanel() { - No setup commands ran for this workspace. + {t("workspace.setup.empty.noCommands")} ) : null} @@ -303,6 +318,7 @@ function SetupCommandRow({ errorMessage, onToggle, }: SetupCommandRowProps) { + const { t } = useTranslation(); const handlePress = useCallback(() => { if (!isExpandable) return; onToggle(command.index, isAutoExpanded); @@ -348,7 +364,7 @@ function SetupCommandRow({ showsVerticalScrollIndicator testID="workspace-setup-log" accessible - accessibilityLabel="Workspace setup log" + accessibilityLabel={t("workspace.setup.accessibility.log")} > {processedLog} @@ -359,9 +375,9 @@ function SetupCommandRow({ style={styles.logScrollContent} testID="workspace-setup-log" accessible - accessibilityLabel="Workspace setup log" + accessibilityLabel={t("workspace.setup.accessibility.log")} > - No output + {t("workspace.setup.log.noOutput")} )} {hasError && errorMessage ? ( @@ -394,6 +410,7 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) { } function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { + const { t } = useTranslation(); if (commands.length !== 0 || log.trim().length === 0) return null; return ( {log} diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 46dc662dd..b760fac21 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { Terminal } from "lucide-react-native"; import { Text, View } from "react-native"; import invariant from "tiny-invariant"; @@ -34,6 +35,7 @@ function useTerminalPanelDescriptor( target: { kind: "terminal"; terminalId: string }, context: { serverId: string; workspaceId: string }, ): PanelDescriptor { + const { t } = useTranslation(); const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!; const workspaceDirectory = workspaceAuthority.ok @@ -61,8 +63,10 @@ function useTerminalPanelDescriptor( terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; return { - label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal", - subtitle: "Terminal", + label: + trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? + t("workspace.tabs.fallback.terminal"), + subtitle: t("workspace.tabs.fallback.terminal"), titleState: "ready", icon: Terminal, statusBucket: null, diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index ec691433d..d000edb00 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; +import { i18n } from "@/i18n/i18next"; import { buildProviderSelectorProviders, buildSelectableProviderSelectorProviders, @@ -318,4 +319,54 @@ describe("combined model selector data", () => { }), ).toEqual({ ok: true }); }); + + it("uses the active app language for utility labels", async () => { + await i18n.changeLanguage("zh-CN"); + try { + const providers = buildSelectableProviderSelectorProviders([ + snapshotEntry({ + provider: "deepseek-tui", + label: "DeepSeek TUI", + models: [], + }), + snapshotEntry({ + provider: "unavailable-provider", + status: "unavailable", + models: [], + }), + ]); + + expect(getAllModelLabels(providers)).toContain("默认"); + expect(providers[1]?.modelSelection).toEqual({ + kind: "error", + message: "不可用", + }); + expect( + resolveSubmissionReadiness({ + text: "", + allowsEmptyAutoSubmit: false, + providerCount: 1, + selection: { + provider: "codex", + modelId: "gpt-5.4", + availableModels: [codexModel], + isModelLoading: false, + }, + autoSubmitConfig: null, + workspaceDirectory: "/repo", + hasClient: true, + }), + ).toEqual({ ok: false, reason: "初始 prompt 必填" }); + } finally { + await i18n.changeLanguage("en"); + } + }); }); + +function getAllModelLabels(providers: ReturnType) { + return providers.flatMap((provider) => + provider.modelSelection.kind === "models" + ? provider.modelSelection.rows.map((row) => row.modelLabel) + : [], + ); +} diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 291d1650a..bad65c7db 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -7,6 +7,7 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; +import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean }; @@ -61,7 +62,7 @@ function buildSyntheticDefaultRow( provider, providerLabel, modelId: "", - modelLabel: "Default", + modelLabel: i18n.t("providerSelection.defaultModel"), description: undefined, isDefault: true, }; @@ -96,7 +97,11 @@ function buildEntryModelSelection( } return { kind: "error", - message: entry.error ?? (entry.status === "unavailable" ? "Unavailable" : "Unknown error"), + message: + entry.error ?? + (entry.status === "unavailable" + ? i18n.t("providerSelection.unavailable") + : i18n.t("providerSelection.unknownError")), }; } @@ -152,21 +157,23 @@ export function resolveSelectedModelLabel(input: { }): string { const selectedProvider = input.selectedProvider.trim(); if (!selectedProvider) { - return "Select model"; + return i18n.t("providerSelection.selectModel"); } const provider = input.providers.find((entry) => entry.id === selectedProvider); if (!provider) { - return input.isLoading ? "Loading..." : "Select model"; + return input.isLoading + ? i18n.t("providerSelection.loading") + : i18n.t("providerSelection.selectModel"); } if (provider.modelSelection.kind === "loading") { - return "Loading..."; + return i18n.t("providerSelection.loading"); } if (provider.modelSelection.kind === "error") { - return "Error"; + return i18n.t("providerSelection.error"); } if (provider.modelSelection.kind !== "models") { - return "Select model"; + return i18n.t("providerSelection.selectModel"); } const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel); @@ -175,7 +182,7 @@ export function resolveSelectedModelLabel(input: { model?.modelLabel ?? defaultModel?.modelLabel ?? provider.modelSelection.rows[0]?.modelLabel ?? - "Select model" + i18n.t("providerSelection.selectModel") ); } @@ -280,26 +287,26 @@ export function resolveSubmissionReadiness(input: { hasClient: boolean; }): ProviderSelectionReadiness { if (!input.allowsEmptyAutoSubmit && !input.text.trim()) { - return { ok: false, reason: "Initial prompt is required" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.initialPromptRequired") }; } if (input.providerCount === 0) { - return { ok: false, reason: "No available providers on the selected host" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.noProviders") }; } if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) { - return { ok: false, reason: "Select a model" }; + return { ok: false, reason: i18n.t("providerSelection.selectModel") }; } if (input.selection.isModelLoading) { - return { ok: false, reason: "Model defaults are still loading" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.modelDefaultsLoading") }; } const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId); if (!hasSelectedModel && input.selection.availableModels.length > 0) { - return { ok: false, reason: "No model is available for the selected provider" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.noModelAvailable") }; } if (!input.workspaceDirectory) { - return { ok: false, reason: "Workspace directory not found" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.workspaceDirectoryNotFound") }; } if (!input.hasClient) { - return { ok: false, reason: "Host is not connected" }; + return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") }; } return { ok: true }; } diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index f773e63d4..acb57e78e 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import "@/test/window-local-storage"; +import { i18n as testI18n } from "@/i18n/i18next"; import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +20,8 @@ import { type InlineReviewActions, } from "./index"; +void testI18n; + const { theme, pressablePropsByLabel } = vi.hoisted(() => { Object.assign(globalThis, { __DEV__: false }); return { diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index ffc2764eb..fea38d388 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react-native"; import { Pressable, @@ -294,6 +295,7 @@ export function InlineReviewGutterCell({ style?: StyleProp; actionTestID?: string; }) { + const { t } = useTranslation(); const canComment = Boolean(reviewTarget); const hasComments = comments.length > 0; const [isGutterHovered, setIsGutterHovered] = useState(false); @@ -341,7 +343,7 @@ export function InlineReviewGutterCell({ return ( void; onDeleteComment: (id: string) => void; }) { + const { t } = useTranslation(); const handleEdit = useCallback( () => onEditComment(reviewTarget, comment), [onEditComment, reviewTarget, comment], @@ -457,7 +460,7 @@ function CommentRow({ void; testID?: string; }) { + const { t } = useTranslation(); const inputRef = useRef(null); const focus = useWorkspaceFocusRestoration(); const canShowKeyboardHints = useCanShowReviewKeyboardHints(); @@ -581,9 +585,9 @@ export function InlineReviewEditor({ diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 32c01adcf..b657cc330 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -151,6 +152,8 @@ function RefPickerTrigger({ badgePressableStyle, selectedItem, triggerLabel, + accessibilityLabel, + tooltipLabel, iconColor, iconSize, }: { @@ -160,6 +163,8 @@ function RefPickerTrigger({ badgePressableStyle: React.ComponentProps["style"]; selectedItem: PickerItem | null; triggerLabel: string; + accessibilityLabel: string; + tooltipLabel: string; iconColor: string; iconSize: number; }) { @@ -173,7 +178,7 @@ function RefPickerTrigger({ disabled={disabled} style={badgePressableStyle} accessibilityRole="button" - accessibilityLabel="Starting ref" + accessibilityLabel={accessibilityLabel} > - Choose where to start from + {tooltipLabel} ); @@ -253,13 +258,17 @@ function ProjectPickerTrigger({ } function CheckoutHintBadge({ - prNumber, + label, + acceptLabel, + dismissLabel, onAccept, onDismiss, iconColor, iconSize, }: { - prNumber: number; + label: string; + acceptLabel: string; + dismissLabel: string; onAccept: () => void; onDismiss: () => void; iconColor: string; @@ -268,14 +277,14 @@ function CheckoutHintBadge({ return ( - Check out PR #{prNumber}? + {label} @@ -284,7 +293,7 @@ function CheckoutHintBadge({ onPress={onDismiss} style={styles.checkoutHintAction} accessibilityRole="button" - accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`} + accessibilityLabel={dismissLabel} > @@ -585,10 +594,11 @@ async function createAndMergeWorkspace(input: { workspaces: ReturnType[], ) => void; serverId: string; + createFailedMessage: string; }): Promise> { const payload = await input.client.createPaseoWorktree(input.createInput); if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? "Failed to create worktree"); + throw new Error(payload.error ?? input.createFailedMessage); } const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); const workspaceForInitialMerge = input.createInput.firstAgentContext @@ -608,17 +618,21 @@ interface CreateChatAgentInput { }) => Promise>; serverId: string; draftKey: string; + labels: { + composerStateRequired: string; + selectModel: string; + }; } async function runCreateChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; const { text, attachments, cwd } = payload; if (!composerState) { - throw new Error("Composer state is required"); + throw new Error(input.labels.composerStateRequired); } const provider = composerState.selectedProvider; if (!provider) { - throw new Error("Select a model"); + throw new Error(input.labels.selectModel); } const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); const ensuredWorkspace = await ensureWorkspace({ @@ -755,6 +769,7 @@ export function NewWorkspaceScreen({ displayName: displayNameProp, }: NewWorkspaceScreenProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); @@ -816,10 +831,10 @@ export function NewWorkspaceScreen({ const withConnectedClient = useCallback(() => { if (!client || !isConnected) { - throw new Error("Host is not connected"); + throw new Error(t("newWorkspace.errors.hostDisconnected")); } return client; - }, [client, isConnected]); + }, [client, isConnected, t]); const clientReady = isConnected && Boolean(client); const hasSelectedSourceDirectory = selectedSourceDirectory !== null; @@ -1034,11 +1049,12 @@ export function NewWorkspaceScreen({ createInput: buildCreateWorktreeInput(input), mergeWorkspaces, serverId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), }); setCreatedWorkspace(normalizedWorkspace); return normalizedWorkspace; }, - [buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient], + [buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, t, withConnectedClient], ); const handleSubmitNewWorkspace = useCallback( @@ -1064,6 +1080,10 @@ export function NewWorkspaceScreen({ ensureWorkspace, serverId, draftKey, + labels: { + composerStateRequired: t("newWorkspace.errors.composerStateRequired"), + selectModel: t("newWorkspace.errors.selectModel"), + }, }); } catch (error) { const message = toErrorMessage(error); @@ -1072,7 +1092,7 @@ export function NewWorkspaceScreen({ toast.error(message); } }, - [composerState, draftKey, ensureWorkspace, serverId, toast], + [composerState, draftKey, ensureWorkspace, serverId, t, toast], ); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); @@ -1105,7 +1125,9 @@ export function NewWorkspaceScreen({ : `new-workspace-ref-picker-pr-${item.item.number}`; const description = - !isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined; + !isBranch && item.item.baseRefName + ? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName }) + : undefined; return ( ); }, - [isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm], + [isPending, itemById, t, theme.colors.foregroundMuted, theme.iconSize.sm], ); const renderProjectOption = useCallback( @@ -1175,8 +1197,8 @@ export function NewWorkspaceScreen({ const pickerEmptyText = branchSuggestionsQuery.isFetching || githubPrSearchQuery.isFetching - ? "Searching..." - : "No matching refs."; + ? t("newWorkspace.refPicker.searching") + : t("newWorkspace.refPicker.noMatchingRefs"); const composerFooter = useMemo( () => ( @@ -1220,6 +1242,8 @@ export function NewWorkspaceScreen({ badgePressableStyle={badgePressableStyle} selectedItem={selectedItem} triggerLabel={triggerLabel} + accessibilityLabel={t("newWorkspace.refPicker.startingRef")} + tooltipLabel={t("newWorkspace.refPicker.chooseStart")} iconColor={theme.colors.foregroundMuted} iconSize={theme.iconSize.sm} /> @@ -1228,8 +1252,8 @@ export function NewWorkspaceScreen({ value={selectedOptionId} onSelect={handleSelectOption} searchable - searchPlaceholder="Search branches and PRs" - title="Start from" + searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")} + title={t("newWorkspace.refPicker.title")} open={pickerOpen} onOpenChange={handlePickerOpenChange} onSearchQueryChange={setPickerSearchQuery} @@ -1244,7 +1268,15 @@ export function NewWorkspaceScreen({ ) : null} {checkoutHintPrAttachment ? ( - New workspace + {t("newWorkspace.title")} s.openDesktopAgentList); const openProjectPicker = useOpenProjectPicker(serverId); @@ -76,31 +78,31 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { {isLocalDaemon ? ( diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 028a552d3..b55ed605c 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -49,52 +51,40 @@ const SCRIPT_SERVICE_TYPE = "service"; const ICON_SIZE = 14; interface MetadataPromptField { - title: string; - placeholder: string; + titleKey: string; + placeholderKey: string; sectionTestID: string; inputTestID: string; } const METADATA_PROMPT_FIELDS: Record = { agentTitle: { - title: "Agent titles", - placeholder: "Keep titles imperative and under 40 characters", + titleKey: "settings.project.metadata.agentTitle", + placeholderKey: "settings.project.metadata.agentTitlePlaceholder", sectionTestID: "metadata-prompt-agent-title-section", inputTestID: "metadata-prompt-agent-title-input", }, branchName: { - title: "Branch names", - placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches", + titleKey: "settings.project.metadata.branchName", + placeholderKey: "settings.project.metadata.branchNamePlaceholder", sectionTestID: "metadata-prompt-branch-name-section", inputTestID: "metadata-prompt-branch-name-input", }, commitMessage: { - title: "Commit messages", - placeholder: "Use Conventional Commits with a scope", + titleKey: "settings.project.metadata.commitMessage", + placeholderKey: "settings.project.metadata.commitMessagePlaceholder", sectionTestID: "metadata-prompt-commit-message-section", inputTestID: "metadata-prompt-commit-message-input", }, pullRequest: { - title: "Pull requests", - placeholder: "Lead with a one-paragraph summary, include a Test plan section", + titleKey: "settings.project.metadata.pullRequest", + placeholderKey: "settings.project.metadata.pullRequestPlaceholder", sectionTestID: "metadata-prompt-pull-request-section", inputTestID: "metadata-prompt-pull-request-input", }, }; -const WORKTREE_GROUP_INFO = - "Commands that run when a worktree is created or torn down for this project"; const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees"; -const WORKTREE_DOCS_TOOLTIP = - "See docs for more details and the environment variables available to these commands"; -const SCRIPTS_GROUP_INFO = - "Long-running services and one-off commands you can launch from any agent in this project"; -const METADATA_GROUP_INFO = - "Project-specific instructions injected into the AI prompts Paseo uses to generate metadata — use them to enforce your team's conventions like branch naming, commit style, or PR format"; - -const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host."; - -const HOST_SWITCHER_LABEL = "Switch host"; type ReadProjectConfigData = Awaited>; @@ -158,34 +148,36 @@ function navigateBackToProjects() { } function NoEditableTarget() { + const { t } = useTranslation(); return ( - {NO_TARGET_MESSAGE} + {t("settings.project.noEditableTarget")} ); } function BackToProjectsButton() { + const { t } = useTranslation(); return ( ); } @@ -370,12 +362,18 @@ interface ReadFailureCalloutProps { } function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) { - const { testID, title, description } = resolveReadFailureCopy({ kind, error, hasMultipleHosts }); + const { t } = useTranslation(); + const { testID, title, description } = resolveReadFailureCopy({ + kind, + error, + hasMultipleHosts, + t, + }); return ( @@ -386,35 +384,36 @@ function resolveReadFailureCopy(input: { kind: ReadFailureCalloutProps["kind"]; error: unknown; hasMultipleHosts: boolean; + t: TFunction; }): { testID: string; title: string; description: string } { if (input.kind === "invalid_project_config") { return { testID: "invalid-callout", - title: "paseo.json couldn't be parsed", - description: "Fix the file on disk, then reload.", + title: input.t("settings.project.readFailures.invalidTitle"), + description: input.t("settings.project.readFailures.invalidDescription"), }; } if (input.kind === "project_not_found") { return { testID: "project-not-found-callout", - title: "This host doesn't have this project", + title: input.t("settings.project.readFailures.missingTitle"), description: input.hasMultipleHosts - ? "Switch to another host above, or reload." - : "The selected host has no record of this project.", + ? input.t("settings.project.readFailures.missingWithHosts") + : input.t("settings.project.readFailures.missingSingleHost"), }; } if (input.kind === "transport") { const detail = errorToDetail(input.error); return { testID: "read-transport-callout", - title: "Couldn't load paseo.json", - description: detail ?? "The host didn't respond.", + title: input.t("settings.project.readFailures.transportTitle"), + description: detail ?? input.t("settings.project.readFailures.transportFallback"), }; } return { testID: "read-failed-callout", - title: "Couldn't load paseo.json", - description: "Reload to try again.", + title: input.t("settings.project.readFailures.failedTitle"), + description: input.t("settings.project.readFailures.failedDescription"), }; } @@ -441,6 +440,7 @@ function ProjectConfigForm({ client, onReload, }: ProjectConfigFormProps) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const toast = useToast(); @@ -470,7 +470,7 @@ function ProjectConfigForm({ }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.show("Project saved", { variant: "success" }); + toast.show(t("settings.project.actions.saved"), { variant: "success" }); } else { setWriteError(result.error); } @@ -513,10 +513,12 @@ function ProjectConfigForm({ const handleRemoveScript = useCallback( async (script: ProjectScriptDraft) => { const ok = await confirmDialog({ - title: "Remove script?", - message: `Remove ${script.name || "this script"}?`, - confirmLabel: "Remove", - cancelLabel: "Cancel", + title: t("settings.project.scripts.removeTitle"), + message: t("settings.project.scripts.removeMessage", { + name: script.name || t("settings.project.scripts.removeFallbackName"), + }), + confirmLabel: t("settings.project.scripts.actions.remove"), + cancelLabel: t("settings.project.actions.cancel"), destructive: true, }); if (!ok) return; @@ -525,7 +527,7 @@ function ProjectConfigForm({ scripts: d.scripts.filter((entry) => entry.id !== script.id), })); }, - [updateDraft], + [t, updateDraft], ); const handleEditScript = useCallback((script: ProjectScriptDraft) => { @@ -587,8 +589,8 @@ function ProjectConfigForm({ const editingScript = draft.scripts.find((entry) => entry.id === editingScriptId); const hasInvalidScripts = useMemo( - () => draft.scripts.some((script) => validateScript(script).hasErrors), - [draft.scripts], + () => draft.scripts.some((script) => validateScript(script, t).hasErrors), + [draft.scripts, t], ); const scriptsTrailing = useMemo( @@ -598,36 +600,36 @@ function ProjectConfigForm({ hitSlop={8} style={settingsStyles.sectionHeaderLink} accessibilityRole="button" - accessibilityLabel="Add script" + accessibilityLabel={t("settings.project.scripts.actions.add")} testID="scripts-add-button" > ), - [handleAddScript], + [handleAddScript, t], ); const setupDocsLink = useMemo( () => ( ), - [], + [t], ); const teardownDocsLink = useMemo( () => ( ), - [], + [t], ); const isStale = writeError?.code === "stale_project_config"; @@ -637,14 +639,18 @@ function ProjectConfigForm({ return ( - + {draft.scripts.length === 0 ? ( - No scripts yet. + {t("settings.project.scripts.empty")} ) : ( draft.scripts.map((script, index) => ( @@ -692,7 +698,11 @@ function ProjectConfigForm({ - + {METADATA_PROMPT_KEYS.map((key, index) => ( @@ -729,8 +739,8 @@ function ProjectConfigForm({ @@ -755,14 +765,16 @@ function ProjectConfigForm({ @@ -788,6 +800,7 @@ interface ProjectNameEditorProps { } function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const toast = useToast(); const [isEditing, setIsEditing] = useState(false); @@ -798,10 +811,11 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setIsEditing(false); - toast.show("Project renamed", { variant: "success" }); + toast.show(t("settings.project.rename.renamedToast"), { variant: "success" }); }, onError: (error) => { - const message = error instanceof Error ? error.message : "Couldn't rename project"; + const message = + error instanceof Error ? error.message : t("settings.project.rename.errorFallback"); toast.show(message, { variant: "error" }); }, }); @@ -838,7 +852,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { - Reset + {t("settings.project.rename.reset")} ) : null} @@ -865,7 +879,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { @@ -1018,19 +1033,21 @@ interface MetadataPromptSectionProps { } function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) { + const { t } = useTranslation(); const meta = METADATA_PROMPT_FIELDS[promptKey]; + const title = t(meta.titleKey); const handleChange = useCallback( (text: string) => onChange(promptKey, text), [onChange, promptKey], ); return ( - + ); @@ -1044,6 +1061,7 @@ interface ScriptRowProps { } function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { + const { t } = useTranslation(); const handleEdit = useCallback(() => onEdit(script), [onEdit, script]); const handleRemove = useCallback(() => onRemove(script), [onRemove, script]); const rowStyle = isFirst ? styles.scriptRow : styles.scriptRowWithBorder; @@ -1052,15 +1070,15 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { - {script.name || "Untitled script"} + {script.name || t("settings.project.scripts.untitled")} - {scriptHint(script)} + {scriptHint(script, t)} @@ -1068,14 +1086,14 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { - Edit + {t("settings.project.scripts.actions.edit")} - Remove + {t("settings.project.scripts.actions.remove")} @@ -1083,10 +1101,10 @@ function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { ); } -function scriptHint(script: ProjectScriptDraft): string { +function scriptHint(script: ProjectScriptDraft, t: TFunction): string { const pieces: string[] = []; if (script.type) pieces.push(script.type); - if (script.portText) pieces.push(`port ${script.portText}`); + if (script.portText) pieces.push(t("settings.project.scripts.port", { port: script.portText })); if (script.commandText) pieces.push(script.commandText.split("\n")[0] ?? ""); return pieces.join(" · "); } @@ -1097,9 +1115,11 @@ interface ScriptValidation { commandError: string | null; } -function validateScript(script: ProjectScriptDraft): ScriptValidation { - const nameError = script.name.trim().length === 0 ? "Name is required" : null; - const commandError = script.commandText.trim().length === 0 ? "Command is required" : null; +function validateScript(script: ProjectScriptDraft, t: TFunction): ScriptValidation { + const nameError = + script.name.trim().length === 0 ? t("settings.project.scripts.nameRequired") : null; + const commandError = + script.commandText.trim().length === 0 ? t("settings.project.scripts.commandRequired") : null; return { hasErrors: Boolean(nameError || commandError), nameError, @@ -1123,6 +1143,7 @@ const ALL_TOUCHED: ScriptFieldsTouched = { name: true, command: true }; const NONE_TOUCHED: ScriptFieldsTouched = { name: false, command: false }; function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModalProps) { + const { t } = useTranslation(); const [touched, setTouched] = useState(NONE_TOUCHED); useEffect(() => { @@ -1149,7 +1170,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal const handleNameBlur = useCallback(() => markTouched("name"), [markTouched]); const handleCommandBlur = useCallback(() => markTouched("command"), [markTouched]); - const validation = validateScript(script); + const validation = validateScript(script, t); const handleSavePress = useCallback(() => { if (validation.hasErrors) { @@ -1163,8 +1184,12 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal const showCommandError = touched.command && validation.commandError; const isService = script.type === SCRIPT_SERVICE_TYPE; const sheetHeader = useMemo( - () => ({ title: script.name ? `Edit ${script.name}` : "New script" }), - [script.name], + () => ({ + title: script.name + ? t("settings.project.scripts.editScript", { name: script.name }) + : t("settings.project.scripts.newScript"), + }), + [script.name, t], ); return ( @@ -1176,10 +1201,10 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal desktopMaxWidth={560} > - Name + {t("settings.project.scripts.name")} - Command + {t("settings.project.scripts.command")} - Run as a service - - Paseo supervises the process and assigns a port via $PASEO_PORT + + {t("settings.project.scripts.runAsService")} + {t("settings.project.scripts.serviceHint")} diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 31dfa4773..17a5a2cb1 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -125,6 +125,19 @@ vi.mock("expo-router", () => ({ router: { navigate }, })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === "sidebar.project.empty.title") return "No projects yet"; + if (key === "settings.projectList.hostLoadFailed") { + return `Couldn't load projects from host ${values?.hostName}: ${values?.message}`; + } + if (key === "settings.projectList.editProject") return `Edit ${values?.projectName}`; + return key; + }, + }), +})); + vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: ({ size }: { size?: string | number }) => React.createElement("span", { diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index d736a352d..2c66d47e2 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; @@ -16,6 +17,7 @@ interface ProjectsScreenProps { } export default function ProjectsScreen({ view }: ProjectsScreenProps) { + const { t } = useTranslation(); const { projects, hostErrors, isLoading } = useProjects(); const selectedProjectKey = view.kind === "project" ? view.projectKey : null; const iconTargets = useMemo( @@ -49,7 +51,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { if (projects.length === 0) { return ( - No projects yet + {t("sidebar.project.empty.title")} ); } @@ -73,11 +75,15 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { } function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) { + const { t } = useTranslation(); return ( {errors.map((error) => ( - {`Couldn't load projects from host ${error.serverName}: ${error.message}`} + {t("settings.projectList.hostLoadFailed", { + hostName: error.serverName, + message: error.message, + })} ))} @@ -92,6 +98,7 @@ interface ProjectRowProps { } function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const { projectKey, projectName } = project; @@ -116,7 +123,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro style={rowStyle} onPress={handleNavigate} accessibilityRole="button" - accessibilityLabel={`Edit ${projectName}`} + accessibilityLabel={t("settings.projectList.editProject", { projectName })} testID={`project-row-${projectKey}`} data-selected={isSelected ? "true" : "false"} > diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index f097cb5f8..fd26b6895 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -4,6 +4,7 @@ import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronLeft } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { MenuHeader } from "@/components/headers/menu-header"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -23,6 +24,7 @@ export function SessionsScreen({ serverId }: { serverId: string }) { function SessionsScreenContent({ serverId }: { serverId: string }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } = useAgentHistory({ serverId, @@ -56,16 +58,16 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { hasMore ? ( ) : null, - [hasMore, loadMore, isLoadingMore], + [hasMore, loadMore, isLoadingMore, t], ); return ( - + {isInitialLoad ? ( @@ -73,9 +75,9 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { ) : null} {!isInitialLoad && sortedAgents.length === 0 ? ( - No sessions yet + {t("sessions.empty")} ) : null} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 8339c27db..a8781a91f 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -21,6 +21,8 @@ import { useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Buffer } from "buffer"; import { ArrowLeft, @@ -92,6 +94,7 @@ import { resolveAppVersion } from "@/utils/app-version"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; +import { LANGUAGE_OPTIONS, type AppLanguage } from "@/i18n/locales"; import { HostConnectionsPage, HostAgentsPage, @@ -127,34 +130,44 @@ export type SettingsView = interface SidebarSectionItem { id: SettingsSectionSlug; - label: string; + labelKey: string; icon: ComponentType<{ size: number; color: string }>; desktopOnly?: boolean; } const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ - { id: "general", label: "General", icon: Settings }, - { id: "daemon", label: "Daemon", icon: Server, desktopOnly: true }, - { id: "appearance", label: "Appearance", icon: Palette }, - { id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true }, - { id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true }, - { id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true }, - { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, - { id: "about", label: "About", icon: Info }, + { id: "general", labelKey: "settings.sections.general", icon: Settings }, + { id: "daemon", labelKey: "settings.sections.daemon", icon: Server, desktopOnly: true }, + { id: "appearance", labelKey: "settings.sections.appearance", icon: Palette }, + { id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true }, + { + id: "integrations", + labelKey: "settings.sections.integrations", + icon: Puzzle, + desktopOnly: true, + }, + { + id: "permissions", + labelKey: "settings.sections.permissions", + icon: Shield, + desktopOnly: true, + }, + { id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope }, + { id: "about", labelKey: "settings.sections.about", icon: Info }, ]; interface HostSectionItem { id: HostSectionSlug; - label: string; + labelKey: string; icon: ComponentType<{ size: number; color: string }>; } const HOST_SECTION_ITEMS: HostSectionItem[] = [ - { id: "connections", label: "Connections", icon: Network }, - { id: "agents", label: "Agents", icon: Bot }, - { id: "workspaces", label: "Workspaces", icon: FolderGit2 }, - { id: "providers", label: "Providers", icon: Boxes }, - { id: "host", label: "Host", icon: Server }, + { id: "connections", labelKey: "settings.hostSections.connections", icon: Network }, + { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, + { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, + { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, + { id: "host", labelKey: "settings.hostSections.host", icon: Server }, ]; function renderHostSettingsContent( @@ -197,21 +210,21 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; -const SEND_BEHAVIOR_OPTIONS = [ - { value: "interrupt" as const, label: "Interrupt" }, - { value: "queue" as const, label: "Queue" }, -]; +function getSendBehaviorOptions(t: TFunction) { + return [ + { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, + { value: "queue" as const, label: t("settings.general.defaultSend.options.queue") }, + ]; +} -const RELEASE_CHANNEL_OPTIONS = [ - { value: "stable" as const, label: "Stable" }, - { value: "beta" as const, label: "Beta" }, -]; - -const SERVICE_URL_BEHAVIOR_LABELS: Record = { - ask: "Ask", - "in-app": "In Paseo", - external: "External browser", -}; +function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string { + const labels: Record = { + ask: t("settings.general.serviceUrls.options.ask"), + "in-app": t("settings.general.serviceUrls.options.inApp"), + external: t("settings.general.serviceUrls.options.external"), + }; + return labels[value]; +} const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"]; @@ -224,17 +237,20 @@ interface GeneralSectionProps { isDesktopApp: boolean; handleSendBehaviorChange: (behavior: SendBehavior) => void; handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void; + handleLanguageChange: (language: AppLanguage) => void; handleTerminalScrollbackLinesChange: (lines: number) => void; } interface ServiceUrlBehaviorMenuItemProps { value: ServiceUrlBehavior; + label: string; selected: boolean; onChange: (value: ServiceUrlBehavior) => void; } function ServiceUrlBehaviorMenuItem({ value, + label, selected, onChange, }: ServiceUrlBehaviorMenuItemProps) { @@ -243,7 +259,28 @@ function ServiceUrlBehaviorMenuItem({ }, [onChange, value]); return ( - {SERVICE_URL_BEHAVIOR_LABELS[value]} + {label} + + ); +} + +interface LanguageMenuItemProps { + value: AppLanguage; + selected: boolean; + onChange: (value: AppLanguage) => void; +} + +function LanguageMenuItem({ value, selected, onChange }: LanguageMenuItemProps) { + const { t } = useTranslation(); + const handleSelect = useCallback(() => { + onChange(value); + }, [onChange, value]); + const option = LANGUAGE_OPTIONS.find((entry) => entry.value === value); + const label = option ? t(option.labelKey) : value; + + return ( + + {label} ); } @@ -253,10 +290,19 @@ function GeneralSection({ isDesktopApp, handleSendBehaviorChange, handleServiceUrlBehaviorChange, + handleLanguageChange, handleTerminalScrollbackLinesChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const iconColor = theme.colors.foregroundMuted; + const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); + const selectedLanguageOption = LANGUAGE_OPTIONS.find( + (option) => option.value === settings.language, + ); + const selectedLanguageLabel = selectedLanguageOption + ? t(selectedLanguageOption.labelKey) + : settings.language; const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( String(settings.terminalScrollbackLines), ); @@ -283,32 +329,60 @@ function GeneralSection({ }, [settings.terminalScrollbackLines]); return ( - + - Default send + {t("settings.general.defaultSend.label")} - What happens when you press Enter while the agent is running + {t("settings.general.defaultSend.description")} + + + {t("settings.general.language.label")} + {t("settings.general.language.description")} + + + + {selectedLanguageLabel} + + + + {LANGUAGE_OPTIONS.map((option) => ( + + ))} + + + {isDesktopApp ? ( - Service URLs - Where to open URLs from running scripts + {t("settings.general.serviceUrls.label")} + + {t("settings.general.serviceUrls.description")} + - {SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]} + {getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)} @@ -317,6 +391,7 @@ function GeneralSection({ @@ -327,8 +402,12 @@ function GeneralSection({ ) : null} - Terminal scrollback - Lines kept in the built-in terminal buffer + + {t("settings.general.terminalScrollback.label")} + + + {t("settings.general.terminalScrollback.description")} + @@ -360,15 +439,16 @@ function DiagnosticsSection({ playbackTestResult, handlePlaybackTest, }: DiagnosticsSectionProps) { + const { t } = useTranslation(); const handlePlayPress = useCallback(() => { void handlePlaybackTest(); }, [handlePlaybackTest]); return ( - + - Test audio + {t("settings.diagnostics.testAudio")} {playbackTestResult ? ( {playbackTestResult} ) : null} @@ -379,7 +459,9 @@ function DiagnosticsSection({ onPress={handlePlayPress} disabled={!voiceAudioEngine || isPlaybackTestRunning} > - {isPlaybackTestRunning ? "Playing..." : "Play test"} + {isPlaybackTestRunning + ? t("settings.diagnostics.playing") + : t("settings.diagnostics.playTest")} @@ -394,14 +476,15 @@ interface AboutSectionProps { } function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) { + const { t } = useTranslation(); return ( <> - + - App version - This device + {t("settings.about.appVersion")} + {t("settings.about.thisDevice")} {appVersionText} @@ -423,12 +506,13 @@ function normalizeVersion(version: string | null | undefined): string | null { } function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) { + const { t } = useTranslation(); const hosts = useHosts(); if (hosts.length === 0) { return null; } return ( - + {hosts.map((host, index) => ( state.sessions[host.serverId]?.serverInfo?.version ?? null, @@ -469,7 +554,7 @@ function HostVersionRow({ let valueText: string; if (!isConnected) { - valueText = "Offline"; + valueText = t("settings.about.offline"); } else if (normalizedHost) { valueText = formatVersionWithPrefix(normalizedHost); } else { @@ -488,7 +573,7 @@ function HostVersionRow({ {host.label} {isMismatch ? ( - Version differs from this device + {t("settings.about.versionDiffers")} ) : null} {valueText} @@ -497,15 +582,21 @@ function HostVersionRow({ } function getUpdateButtonLabel( + t: TFunction, isInstalling: boolean, latestVersion: string | null | undefined, ): string { - if (isInstalling) return "Installing..."; - if (latestVersion) return `Update to ${formatVersionWithPrefix(latestVersion)}`; - return "Update"; + if (isInstalling) return t("settings.about.updates.installing"); + if (latestVersion) { + return t("settings.about.updates.updateTo", { + version: formatVersionWithPrefix(latestVersion), + }); + } + return t("settings.about.updates.update"); } function DesktopAppUpdateRow() { + const { t } = useTranslation(); const { settings, updateSettings } = useSettings(); const { isDesktopApp, @@ -541,6 +632,13 @@ function DesktopAppUpdateRow() { }, [updateSettings], ); + const releaseChannelOptions = useMemo( + () => [ + { value: "stable" as const, label: t("settings.about.releaseChannel.stable") }, + { value: "beta" as const, label: t("settings.about.releaseChannel.beta") }, + ], + [t], + ); const handleInstallUpdate = useCallback(() => { if (!isDesktopApp) { @@ -548,10 +646,10 @@ function DesktopAppUpdateRow() { } void confirmDialog({ - title: "Install desktop update", - message: "This updates Paseo on this computer", - confirmLabel: "Install update", - cancelLabel: "Cancel", + title: t("settings.about.updates.installTitle"), + message: t("settings.about.updates.installMessage"), + confirmLabel: t("settings.about.updates.installConfirm"), + cancelLabel: t("common.actions.cancel"), }) .then((confirmed) => { if (!confirmed) { @@ -562,9 +660,12 @@ function DesktopAppUpdateRow() { }) .catch((error) => { console.error("[Settings] Failed to open app update confirmation", error); - Alert.alert("Error", "Unable to open the update confirmation dialog."); + Alert.alert( + t("settings.about.updates.alertTitle"), + t("settings.about.updates.alertMessage"), + ); }); - }, [installUpdate, isDesktopApp]); + }, [installUpdate, isDesktopApp, t]); if (!isDesktopApp) { return null; @@ -574,25 +675,27 @@ function DesktopAppUpdateRow() { <> - Release channel + {t("settings.about.releaseChannel.label")} - Switch to Beta to get updates sooner and help shape them + {t("settings.about.releaseChannel.description")} - App updates + {t("settings.about.updates.label")} {statusText} {availableUpdate?.latestVersion ? ( - Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} + {t("settings.about.updates.readyToInstall", { + version: formatVersionWithPrefix(availableUpdate.latestVersion), + })} ) : null} {errorMessage ? {errorMessage} : null} @@ -604,7 +707,7 @@ function DesktopAppUpdateRow() { onPress={handleCheckForUpdates} disabled={isChecking || isInstalling} > - {isChecking ? "Checking..." : "Check"} + {isChecking ? t("settings.about.updates.checking") : t("settings.about.updates.check")} @@ -760,6 +863,7 @@ interface SidebarProjectsButtonProps { function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); const labelStyle = useMemo( () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], @@ -778,7 +882,7 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted} /> - Projects + {t("settings.projects")} ); @@ -805,6 +909,7 @@ function HostPickerOption({ onPress, }: HostPickerOptionProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const leadingSlot = useMemo( () => , [theme.iconSize.sm, theme.colors.foregroundMuted], @@ -815,10 +920,10 @@ function HostPickerOption({ () => isLocal ? ( - Local + {t("settings.hostPicker.local")} ) : undefined, - [isLocal], + [isLocal, t], ); return ( void }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const leadingSlot = useMemo( () => , [theme.iconSize.sm, theme.colors.foregroundMuted], ); return ( (null); const activeHost = @@ -879,8 +986,8 @@ function HostPicker({ const options = useMemo(() => { const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label })); - return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }]; - }, [sortedHosts]); + return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: t("settings.addHost") }]; + }, [sortedHosts, t]); const handleSelect = useCallback( (id: string) => { @@ -938,12 +1045,12 @@ function HostPicker({ style={triggerStyle} onPress={handleOpen} accessibilityRole="button" - accessibilityLabel="Switch host" + accessibilityLabel={t("settings.hostPicker.switchHost")} testID="settings-host-picker" > - {activeHost?.label ?? "Host"} + {activeHost?.label ?? t("settings.groups.host")} @@ -953,7 +1060,7 @@ function HostPicker({ onSelect={handleSelect} renderOption={renderOption} searchable={false} - title="Switch host" + title={t("settings.hostPicker.switchHost")} desktopMinWidth={240} open={isOpen} onOpenChange={setIsOpen} @@ -987,6 +1094,7 @@ function SettingsSidebar({ layout, }: SettingsSidebarProps) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); @@ -1012,12 +1120,12 @@ function SettingsSidebar({ const sidebarBody = ( <> - App + {t("settings.groups.app")} {items.map((item) => ( {hasHosts ? ( - Host + {t("settings.groups.host")} - Add host + {t("settings.addHost")} @@ -1078,7 +1186,7 @@ function SettingsSidebar({ {padding.top > 0 ? : null} @@ -1105,6 +1213,7 @@ export interface SettingsScreenProps { export default function SettingsScreen({ view }: SettingsScreenProps) { const router = useRouter(); const { theme } = useUnistyles(); + const { t } = useTranslation(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); @@ -1167,6 +1276,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { [updateSettings], ); + const handleLanguageChange = useCallback( + (language: AppLanguage) => { + void updateSettings({ language }); + }, + [updateSettings], + ); + const handleTerminalScrollbackLinesChange = useCallback( (terminalScrollbackLines: number) => { void updateSettings({ terminalScrollbackLines }); @@ -1197,11 +1313,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("[Settings] Playback test failed", error); - setPlaybackTestResult(`Playback failed: ${message}`); + setPlaybackTestResult(t("settings.diagnostics.playbackFailed", { message })); } finally { setIsPlaybackTestRunning(false); } - }, [isPlaybackTestRunning, voiceAudioEngine]); + }, [isPlaybackTestRunning, t, voiceAudioEngine]); const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); @@ -1341,15 +1457,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; - return { title: item.label, Icon: item.icon }; + return { title: t(item.labelKey), Icon: item.icon }; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; - return { title: item.label, Icon: item.icon }; + return { title: t(item.labelKey), Icon: item.icon }; } if (view.kind === "project" || view.kind === "projects") { - return { title: "Projects", Icon: FolderGit2 }; + return { title: t("settings.projects"), Icon: FolderGit2 }; } return null; })(); @@ -1373,6 +1489,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { isDesktopApp={isDesktopApp} handleSendBehaviorChange={handleSendBehaviorChange} handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange} + handleLanguageChange={handleLanguageChange} handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange} /> ); @@ -1411,7 +1528,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { if (settingsLoading) { return ( - Loading settings... + {t("settings.loading")} ); } @@ -1444,7 +1561,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { if (isCompactLayout && view.kind === "root") { return ( - + buildUnifiedRows(), []); const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]); const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]); @@ -149,7 +151,7 @@ export function AppearancePreview({ overrides }: AppearancePreviewProps) { return ( diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 1e2f3e024..b21dc70c4 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Text, TextInput, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native"; @@ -49,16 +51,18 @@ const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -// Stored value -> displayed label. `auto` reads as "System" for the app theme. -const THEME_LABELS: Record = { - light: "Light", - dark: "Dark", - zinc: "Zinc", - midnight: "Midnight", - claude: "Claude", - ghostty: "Ghostty", - auto: "System", -}; +function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string { + const labelKeys: Record = { + light: "settings.appearance.theme.options.light", + dark: "settings.appearance.theme.options.dark", + zinc: "settings.appearance.theme.options.zinc", + midnight: "settings.appearance.theme.options.midnight", + claude: "settings.appearance.theme.options.claude", + ghostty: "settings.appearance.theme.options.ghostty", + auto: "settings.appearance.theme.options.auto", + }; + return t(labelKeys[value]); +} const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"]; const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [ @@ -72,13 +76,10 @@ const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [ // those read as a bug, so show a human label in the placeholder instead. const BARE_DEFAULT_STACKS: ReadonlySet = new Set(["normal", "monospace"]); -function resolveDefaultStackPlaceholder(stack: string): string { - return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack; +function resolveDefaultStackPlaceholder(t: TFunction, stack: string): string { + return BARE_DEFAULT_STACKS.has(stack) ? t("settings.appearance.fonts.systemDefault") : stack; } -const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK); -const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK); - // Local size string (digits only) -> preview override number. Empty/invalid // yields undefined so the preview falls back to the committed theme value. function sizeDraftToOverride(value: string): number | undefined { @@ -128,13 +129,14 @@ interface ThemeMenuItemProps { } function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) { + const { t } = useTranslation(); const handleSelect = useCallback(() => { onChange(themeValue); }, [onChange, themeValue]); const leading = useMemo(() => , [themeValue]); return ( - {THEME_LABELS[themeValue]} + {getThemeLabel(t, themeValue)} ); } @@ -145,18 +147,22 @@ interface ThemeRowProps { } function ThemeRow({ value, onChange }: ThemeRowProps) { + const { t } = useTranslation(); + const selectedLabel = getThemeLabel(t, value); return ( - Theme + {t("settings.appearance.theme.title")} - {THEME_LABELS[value]} + {selectedLabel} @@ -316,18 +322,26 @@ interface SyntaxRowProps { } function SyntaxRow({ value, onChange }: SyntaxRowProps) { + const { t } = useTranslation(); + const selectedLabel = syntaxLabelForId(value); return ( - Highlight theme - Colors for code, independent of the app theme + + {t("settings.appearance.syntax.highlightTheme")} + + + {t("settings.appearance.syntax.highlightThemeHint")} + - {syntaxLabelForId(value)} + {selectedLabel} @@ -350,8 +364,11 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) { // --------------------------------------------------------------------------- export function AppearanceSection() { + const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); const showFontFamilyRows = !isNative; + const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK); + const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK); const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily); const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily); @@ -455,19 +472,19 @@ export function AppearanceSection() { return ( - + - + {showFontFamilyRows ? ( ) : null} {showFontFamilyRows ? ( ) : null} - + diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 2680838fd..e9ed45337 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,5 +1,7 @@ import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; +import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -32,15 +34,12 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatLatency } from "@/utils/latency"; -const RESTART_CONFIRMATION_MESSAGE = - "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; - -function formatHostConnectionLabel(connection: HostConnection): string { +function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string { if (connection.type === "relay") { - return `Relay (${connection.relayEndpoint})`; + return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`; } if (connection.type === "directSocket" || connection.type === "directPipe") { - return `Local (${connection.path})`; + return `${t("settings.host.badges.local")} (${connection.path})`; } return `TCP (${connection.endpoint})`; } @@ -48,18 +47,19 @@ function formatHostConnectionLabel(connection: HostConnection): string { function formatActiveConnectionBadge( activeConnection: { type: HostConnection["type"]; display: string } | null, theme: ReturnType["theme"], + t: TFunction, ): { icon: React.ReactNode; text: string } | null { if (!activeConnection) return null; if (activeConnection.type === "relay") { return { icon: , - text: "Relay", + text: t("settings.host.badges.relay"), }; } if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { return { icon: , - text: "Local", + text: t("settings.host.badges.local"), }; } return { @@ -74,24 +74,24 @@ function formatDaemonVersionBadge(version: string | null): string | null { return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; } -const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; - function useHostProfile(serverId: string): HostProfile | null { const daemons = useHosts(); return daemons.find((entry) => entry.serverId === serverId) ?? null; } function HostNotFound() { + const { t } = useTranslation(); return ( - Host not found + {t("settings.host.notFound")} ); } function HostStatusBadges({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); const daemonVersion = useSessionStore( @@ -122,7 +122,7 @@ function HostStatusBadges({ serverId }: { serverId: string }) { } else { statusPillBg = "rgba(161, 161, 170, 0.1)"; } - const connectionBadge = formatActiveConnectionBadge(activeConnection, theme); + const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const statusPillStyle = useMemo( @@ -170,6 +170,7 @@ function HostConnectionError({ serverId }: { serverId: string }) { } export function HostConnectionsPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const host = useHostProfile(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); @@ -182,7 +183,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) { {isLocalDaemon ? ( - + ) : null} @@ -191,6 +192,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) { } export function HostAgentsPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const host = useHostProfile(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -201,13 +203,13 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { return ( {isConnected ? ( - + ) : ( - Connect to this host to manage agents + {t("settings.host.agents.unavailable")} )} @@ -215,6 +217,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { } export function HostWorkspacesPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const host = useHostProfile(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -225,12 +228,12 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) { return ( {isConnected ? ( - + ) : ( - Connect to this host to manage workspaces + {t("settings.host.workspaces.unavailable")} )} @@ -284,6 +287,7 @@ export function HostSettingsPage({ } export function HostRenameButton({ host }: { host: HostProfile }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const { renameHost } = useHostMutations(); const [isEditing, setIsEditing] = useState(false); @@ -307,7 +311,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) { hitSlop={8} style={styles.identityEditButton} accessibilityRole="button" - accessibilityLabel="Edit label" + accessibilityLabel={t("settings.host.daemon.rename.editLabel")} testID="host-page-label-edit-button" > @@ -315,10 +319,10 @@ export function HostRenameButton({ host }: { host: HostProfile }) { (null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); + const removeConnectionHeader = useMemo( + () => ({ title: t("settings.host.connections.removeTitle") }), + [t], + ); - const handleRequestRemove = useCallback((connection: HostConnection) => { - setPendingRemoveConnection({ - connectionId: connection.id, - title: formatHostConnectionLabel(connection), - }); - }, []); + const handleRequestRemove = useCallback( + (connection: HostConnection) => { + setPendingRemoveConnection({ + connectionId: connection.id, + title: formatHostConnectionLabel(connection, t), + }); + }, + [t], + ); const handleCloseConfirm = useCallback(() => { if (isRemovingConnection) return; @@ -361,13 +373,16 @@ function ConnectionsSection({ host }: { host: HostProfile }) { .then(() => setPendingRemoveConnection(null)) .catch((error) => { console.error("[HostPage] Failed to remove connection", error); - Alert.alert("Error", "Unable to remove connection"); + Alert.alert( + t("settings.host.connections.removeErrorTitle"), + t("settings.host.connections.removeErrorMessage"), + ); }) .finally(() => setIsRemovingConnection(false)); - }, [pendingRemoveConnection, removeConnection, host.serverId]); + }, [pendingRemoveConnection, removeConnection, host.serverId, t]); return ( - + {host.connections.map((conn, index) => { const probe = probeByConnectionId.get(conn.id); @@ -387,13 +402,15 @@ function ConnectionsSection({ host }: { host: HostProfile }) { {pendingRemoveConnection ? ( - Remove {pendingRemoveConnection.title}? This cannot be undone. + {t("settings.host.connections.removeMessage", { + name: pendingRemoveConnection.title, + })} @@ -437,12 +454,13 @@ function ConnectionRow({ latencyError: boolean; onRemove: (connection: HostConnection) => void; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); - const title = formatHostConnectionLabel(connection); + const title = formatHostConnectionLabel(connection, t); const latencyText = (() => { if (latencyLoading) return "..."; - if (latencyError) return "Timeout"; + if (latencyError) return t("settings.host.connections.timeout"); if (latencyMs != null) return formatLatency(latencyMs); return "—"; })(); @@ -479,7 +497,7 @@ function ConnectionRow({ textStyle={destructiveTextStyle} onPress={handlePressRemove} > - Remove + {t("settings.host.connections.removeAction")} ); @@ -491,6 +509,7 @@ const delay = (ms: number) => }); function RestartDaemonCard({ host }: { host: HostProfile }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); @@ -533,34 +552,34 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { setIsRestarting(false); if (!reconnected) { Alert.alert( - "Unable to reconnect", - `${host.label} did not come back online. Please verify it restarted.`, + t("settings.host.daemon.restart.unableToReconnectTitle"), + t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }), ); } } - }, [host.label, isHostConnected, waitForCondition]); + }, [host.label, isHostConnected, t, waitForCondition]); const handleRestart = useCallback(() => { if (!daemonClient) { Alert.alert( - "Host unavailable", - "This host is not connected. Wait for it to come online before restarting.", + t("settings.host.daemon.restart.unavailableTitle"), + t("settings.host.daemon.restart.unavailableMessage"), ); return; } if (!isHostConnected()) { Alert.alert( - "Host offline", - "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.", + t("settings.host.daemon.restart.offlineTitle"), + t("settings.host.daemon.restart.offlineMessage"), ); return; } void confirmDialog({ - title: `Restart ${host.label}`, - message: RESTART_CONFIRMATION_MESSAGE, - confirmLabel: "Restart", - cancelLabel: "Cancel", + title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }), + message: t("settings.host.daemon.restart.confirmMessage"), + confirmLabel: t("settings.host.daemon.restart.confirm"), + cancelLabel: t("common.actions.cancel"), destructive: true, }) .then((confirmed) => { @@ -573,8 +592,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { if (!isMountedRef.current) return; setIsRestarting(false); Alert.alert( - "Error", - "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.", + t("settings.host.daemon.restart.requestFailedTitle"), + t("settings.host.daemon.restart.requestFailedMessage"), ); }); void waitForDaemonRestart(); @@ -582,9 +601,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }) .catch((error) => { console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error); - Alert.alert("Error", "Unable to open the restart confirmation dialog."); + Alert.alert( + t("settings.host.daemon.restart.requestFailedTitle"), + t("settings.host.daemon.restart.dialogFailedMessage"), + ); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, waitForDaemonRestart]); + }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); const restartIcon = useMemo( () => , @@ -595,10 +617,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { - Restart daemon - - Restarts the daemon process. The app will reconnect automatically - + {t("settings.host.daemon.restart.title")} + {t("settings.host.daemon.restart.hint")} @@ -616,6 +638,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { } function InjectPaseoToolsCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); @@ -636,15 +659,17 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { - Enable Paseo tools + + {t("settings.host.orchestration.enableTools.title")} + - Agents will be able to manage worktrees, agents and schedules + {t("settings.host.orchestration.enableTools.hint")} @@ -691,13 +716,17 @@ function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) { } function AppendSystemPromptCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); const persistedPrompt = config?.appendSystemPrompt ?? ""; const [draft, setDraft] = useState(persistedPrompt); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); - const header = useMemo(() => ({ title: "Append system prompt" }), []); + const header = useMemo( + () => ({ title: t("settings.host.orchestration.systemPrompt.sheetTitle") }), + [t], + ); useEffect(() => { setDraft(persistedPrompt); @@ -740,8 +769,12 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) { - System prompt - Adds a system prompt to all agents + + {t("settings.host.orchestration.systemPrompt.title")} + + + {t("settings.host.orchestration.systemPrompt.hint")} + @@ -764,10 +797,10 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) { > @@ -796,6 +831,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) { } function PairDeviceRow() { + const { t } = useTranslation(); const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -811,10 +847,8 @@ function PairDeviceRow() { testID="host-page-pair-device-row" > - Pair a device - - Scan a QR code or copy a link to connect your phone to this host - + {t("settings.host.pairDevices.rowTitle")} + {t("settings.host.pairDevices.rowHint")} @@ -837,6 +871,7 @@ function RemoveHostSection({ isLocalDaemon: boolean; onRemoved?: () => void; }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const { removeHost } = useHostMutations(); const { updateSettings } = useDesktopSettings(); @@ -844,6 +879,14 @@ function RemoveHostSection({ const [isConfirming, setIsConfirming] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const daemonStatus = daemonStatusData?.status ?? null; + const removeHostHeader = useMemo( + () => ({ + title: isLocalDaemon + ? t("settings.host.daemon.remove.localConfirmTitle") + : t("settings.host.daemon.remove.title"), + }), + [isLocalDaemon, t], + ); const destructiveTextStyle = useMemo( () => ({ color: theme.colors.destructive }), @@ -903,8 +946,10 @@ function RemoveHostSection({ .catch((error) => { console.error("[HostPage] Failed to remove host", error); Alert.alert( - "Error", - isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host", + t("settings.host.daemon.remove.errorTitle"), + isLocalDaemon + ? t("settings.host.daemon.remove.localErrorMessage") + : t("settings.host.daemon.remove.errorMessage"), ); }) .finally(() => setIsRemoving(false)); @@ -916,35 +961,34 @@ function RemoveHostSection({ removeHost, rollbackLocalhostRemoval, setStatus, + t, updateSettings, ]); - const confirmationHeader = useMemo( - () => ({ - title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host", - }), - [isLocalDaemon], - ); - const removeIcon = useMemo( () => , [theme.iconSize.sm, theme.colors.destructive], ); return ( - + - {isLocalDaemon ? "Remove localhost connection" : "Remove host"} + {isLocalDaemon + ? t("settings.host.daemon.remove.localTitle") + : t("settings.host.daemon.remove.title")} {isLocalDaemon - ? "Removes localhost from this device and stops the built-in daemon" - : "Removes this host and its saved connections from this device"} + ? t("settings.host.daemon.remove.localHint") + : t("settings.host.daemon.remove.hint")} {isConfirming ? ( {isLocalDaemon - ? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected." - : `Remove ${host.label}? This will delete its saved connections.`} + ? t("settings.host.daemon.remove.localConfirmMessage") + : t("settings.host.daemon.remove.confirmMessage", { name: host.label })} diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 57cabe413..eaa4bf08e 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; @@ -32,6 +33,7 @@ function ShortcutSequence({ chord: string[] | null; heldModifiers: string | null; }) { + const { t } = useTranslation(); const displayChord = useMemo(() => { const combos = [...(chord ?? [])]; if (heldModifiers) { @@ -41,7 +43,7 @@ function ShortcutSequence({ }, [chord, heldModifiers]); if ((!chord || chord.length === 0) && !heldModifiers) { - return Press shortcut...; + return {t("settings.shortcuts.capturePrompt")}; } return ; @@ -119,6 +121,7 @@ function ShortcutRow({ onCancel: () => void; onReset: () => void; }) { + const { t } = useTranslation(); const displayChord = useMemo( () => (overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys]), [overrideCombo, row.keys], @@ -127,7 +130,7 @@ function ShortcutRow({ return ( - {row.label} + {t(row.labelKey)} {isCapturing ? ( @@ -138,17 +141,19 @@ function ShortcutRow({ <> {isCapturing && capturedCombos.length > 0 ? ( ) : null} )} {overrideCombo !== undefined && !isCapturing && ( )} @@ -157,6 +162,7 @@ function ShortcutRow({ } export function KeyboardShortcutsSection() { + const { t } = useTranslation(); const [capturingBindingId, setCapturingBindingId] = useState(null); const [capturedCombos, setCapturedCombos] = useState([]); const [heldModifiers, setHeldModifiers] = useState(null); @@ -244,9 +250,9 @@ export function KeyboardShortcutsSection() { if (isNative) { return ( - + - Keyboard shortcuts are only available on desktop + {t("settings.shortcuts.unavailableOnMobile")} ); @@ -254,7 +260,7 @@ export function KeyboardShortcutsSection() { const resetAllButton = hasOverrides ? ( ) : undefined; @@ -264,7 +270,7 @@ export function KeyboardShortcutsSection() { return ( diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 59aa6cd10..054ebf58e 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -100,6 +100,25 @@ vi.mock("lucide-react-native", () => { }; }); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === "settings.providers.providerDetails") return `${values?.name} provider details`; + if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`; + if (key === "settings.providers.statuses.disabled") return "Disabled"; + if (key === "settings.providers.statuses.available") return "Available"; + if (key === "settings.providers.statuses.loading") return "Loading"; + if (key === "settings.providers.statuses.error") return "Error"; + if (key === "settings.providers.statuses.notInstalled") return "Not installed"; + if (key === "settings.providers.models.one") return "1 model"; + if (key === "settings.providers.models.many") return `${values?.count} models`; + if (key === "settings.providers.addErrorTitle") return "Unable to add provider"; + if (key === "settings.providers.updateErrorTitle") return "Unable to update provider"; + return key; + }, + }), +})); + vi.mock("@/components/ui/switch", () => ({ Switch: ({ value, diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 0967f5ead..63a393a4e 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -1,4 +1,6 @@ import { useCallback, useMemo, useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; @@ -29,18 +31,32 @@ interface ProviderStatus { modelCount: number | null; } -function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus { - if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null }; - if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null }; - if (status === "error") return { tone: "danger", label: "Error", modelCount: null }; +function getProviderStatus( + status: string, + enabled: boolean, + modelCount: number, + t: TFunction, +): ProviderStatus { + if (!enabled) + return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null }; + if (status === "loading") { + return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null }; + } + if (status === "error") { + return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null }; + } if (status === "ready") { return { tone: "success", - label: "Available", + label: t("settings.providers.statuses.available"), modelCount: modelCount > 0 ? modelCount : null, }; } - return { tone: "warning", label: "Not installed", modelCount: null }; + return { + tone: "warning", + label: t("settings.providers.statuses.notInstalled"), + modelCount: null, + }; } interface ProviderRowProps { @@ -62,6 +78,7 @@ function ProviderRow({ onPress, onToggleEnabled, }: ProviderRowProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(def.id); const providerError = @@ -72,7 +89,7 @@ function ProviderRow({ ? entry.error.trim() : null; const modelCount = entry.models?.length ?? 0; - const providerStatus = getProviderStatus(entry.status, enabled, modelCount); + const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t); const handlePress = useCallback(() => { onPress(def.id); @@ -99,7 +116,7 @@ function ProviderRow({ style={rowStyle} onPress={handlePress} accessibilityRole="button" - accessibilityLabel={`${def.label} provider details`} + accessibilityLabel={t("settings.providers.providerDetails", { name: def.label })} > {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( <> @@ -128,7 +145,7 @@ function ProviderRow({ value={enabled} onValueChange={handleToggleValueChange} disabled={isToggling} - accessibilityLabel={`Enable ${def.label}`} + accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })} /> )} @@ -150,6 +167,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType["t } function StatusIndicator({ status }: { status: ProviderStatus }) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const dotStyle = useMemo( () => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }], @@ -168,7 +186,9 @@ function StatusIndicator({ status }: { status: ProviderStatus }) { <> · - {status.modelCount === 1 ? "1 model" : `${status.modelCount} models`} + {status.modelCount === 1 + ? t("settings.providers.models.one") + : t("settings.providers.models.many", { count: status.modelCount })} ) : null} @@ -181,6 +201,7 @@ export interface ProvidersSectionProps { } export function ProvidersSection({ serverId }: ProvidersSectionProps) { + const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { patchConfig } = useDaemonConfig(serverId); @@ -205,14 +226,14 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { await patchConfig({ providers: { [providerId]: { enabled } } }); } catch (error) { Alert.alert( - "Unable to update provider", + t("settings.providers.updateErrorTitle"), error instanceof Error ? error.message : String(error), ); } finally { setPendingProviderId((current) => (current === providerId ? null : current)); } }, - [patchConfig], + [patchConfig, t], ); const handleInstall = useCallback( @@ -224,31 +245,31 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { await refresh([entry.id]); } catch (error) { Alert.alert( - "Unable to add provider", + t("settings.providers.addErrorTitle"), error instanceof Error ? error.message : String(error), ); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh], + [installingProviderId, patchConfig, refresh, t], ); return ( <> {!hasServer || !isConnected ? ( - Connect to this host to see providers + {t("settings.providers.unavailable")} ) : null} {hasServer && isConnected && isLoading ? ( - Loading... + {t("settings.providers.loading")} ) : null} {hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? ( @@ -275,7 +296,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { {hasServer && isConnected ? ( diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/screens/settings/settings-group.tsx index 175e60c51..036ab7bac 100644 --- a/packages/app/src/screens/settings/settings-group.tsx +++ b/packages/app/src/screens/settings/settings-group.tsx @@ -1,5 +1,6 @@ import { useMemo, type ReactNode } from "react"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { useTranslation } from "react-i18next"; import { Info } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -27,6 +28,7 @@ export function SettingsGroup({ style, children, }: SettingsGroupProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const groupStyle = useMemo(() => [styles.group, style], [style]); return ( @@ -39,7 +41,7 @@ export function SettingsGroup({ ({ })); export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { + const { t } = useTranslation(); const { theme } = useUnistyles(); const webScrollbarStyle = useWebScrollbarStyle(); const errorScrollViewStyle = useMemo( @@ -339,7 +341,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps } const message = error instanceof Error ? error.message : String(error); setDaemonLogs(null); - setLogsError(`Unable to load daemon logs: ${message}`); + setLogsError(t("startup.logs.loadFailed", { message })); }) .finally(() => { if (!isCancelled) { @@ -350,11 +352,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps return () => { isCancelled = true; }; - }, [isError]); + }, [isError, t]); const logsText = useMemo(() => { if (isLoadingLogs) { - return "Loading daemon logs..."; + return t("startup.logs.loading"); } if (daemonLogs?.contents) { return daemonLogs.contents; @@ -362,8 +364,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps if (logsError) { return logsError; } - return "No daemon logs available."; - }, [daemonLogs?.contents, isLoadingLogs, logsError]); + return t("startup.logs.unavailable"); + }, [daemonLogs?.contents, isLoadingLogs, logsError, t]); const handleCopyLogs = useCallback(() => { const payload = daemonLogs?.logPath @@ -409,13 +411,10 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps - Something went wrong + {t("startup.errorTitle")} - - The local server failed to start. If this keeps happening, please report the issue on - GitHub and include the logs below. - + {t("startup.errorDescription")} {bootstrapState.splashError} diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index f1cfd1449..0890f9af9 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useTranslation } from "react-i18next"; import { buildTerminalsQueryKey, canCreateWorkspaceTerminal, @@ -51,6 +52,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { onWorkspacePathUnavailable, onTerminalCreateQueued, } = input; + const { t } = useTranslation(); const queryClient = useQueryClient(); const [pendingCreateInput, setPendingCreateInput] = useState( null, @@ -69,7 +71,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { enabled: canCreateNow, queryFn: async () => { if (!client || !workspaceDirectory) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } return await client.listTerminals(workspaceDirectory); }, @@ -106,7 +108,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { const createMutation = useMutation({ mutationFn: async (_input?: PendingTerminalCreateInput) => { if (!client || !workspaceDirectory) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } return await client.createTerminal(workspaceDirectory); }, @@ -134,7 +136,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { const killMutation = useMutation({ mutationFn: async (terminalId: string) => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const payload = await client.killTerminal(terminalId); if (!payload.success) { diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx index 025ac2316..8141d508f 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { type QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ListTerminalsResponse } from "@getpaseo/protocol/messages"; +import { useTranslation } from "react-i18next"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { useSessionStore } from "@/stores/session-store"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -31,6 +32,7 @@ export function useWorkspaceTabRename( input: UseWorkspaceTabRenameInput, ): UseWorkspaceTabRenameResult { const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input; + const { t } = useTranslation(); const [renamingTab, setRenamingTab] = useState(null); const handleRenameTab = useCallback( @@ -57,7 +59,7 @@ export function useWorkspaceTabRename( async (nextTitle: string) => { if (!renamingTab) return; if (!client) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } const trimmed = nextTitle.trim(); if (renamingTab.kind === "terminal") { @@ -79,7 +81,7 @@ export function useWorkspaceTabRename( queryKey: ["allAgents", normalizedServerId], }); }, - [client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey], + [client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey, t], ); const handleRenameModalClose = useCallback(() => { @@ -105,7 +107,11 @@ export function WorkspaceTabRenameModal({ onClose, onSubmit, }: WorkspaceTabRenameModalProps) { - const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent"; + const { t } = useTranslation(); + const title = + renamingTab?.kind === "terminal" + ? t("workspace.tabs.menu.renameTerminal") + : t("workspace.tabs.menu.renameAgent"); const initialValue = renamingTab?.currentTitle ?? ""; const testID = renamingTab ? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}` @@ -115,7 +121,7 @@ export function WorkspaceTabRenameModal({ visible={renamingTab !== null} title={title} initialValue={initialValue} - submitLabel="Rename" + submitLabel={t("workspace.tabs.menu.rename")} maxLength={200} onClose={onClose} onSubmit={onSubmit} diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index a218fe1c7..fc986de41 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -1,5 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { i18n } from "@/i18n/i18next"; export interface BulkClosableTabGroups { agentTabs: Array<{ tabId: string; agentId: string }>; @@ -7,6 +8,31 @@ export interface BulkClosableTabGroups { otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>; } +export interface BulkCloseConfirmationLabels { + all: (input: { agents: number; terminals: number; tabs: number }) => string; + agentsAndTerminals: (input: { agents: number; terminals: number }) => string; + terminalsAndTabs: (input: { terminals: number; tabs: number }) => string; + agentsAndTabs: (input: { agents: number; tabs: number }) => string; + terminals: (input: { terminals: number }) => string; + tabs: (input: { tabs: number }) => string; + agents: (input: { agents: number }) => string; +} + +export const DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS: BulkCloseConfirmationLabels = { + all: ({ agents, terminals, tabs }) => + `This will archive ${agents} agent(s), close ${terminals} terminal(s), and close ${tabs} tab(s). Any running process in a closed terminal will be stopped immediately.`, + agentsAndTerminals: ({ agents, terminals }) => + `This will archive ${agents} agent(s) and close ${terminals} terminal(s). Any running process in a closed terminal will be stopped immediately.`, + terminalsAndTabs: ({ terminals, tabs }) => + `This will close ${terminals} terminal(s) and close ${tabs} tab(s). Any running process in a closed terminal will be stopped immediately.`, + agentsAndTabs: ({ agents, tabs }) => + `This will archive ${agents} agent(s) and close ${tabs} tab(s).`, + terminals: ({ terminals }) => + `This will close ${terminals} terminal(s). Any running process in a closed terminal will be stopped immediately.`, + tabs: ({ tabs }) => `This will close ${tabs} tab(s).`, + agents: ({ agents }) => `This will archive ${agents} agent(s).`, +}; + interface CloseWorkspaceTabWithCleanupInput { tabId: string; target?: WorkspaceTabDescriptor["target"]; @@ -43,27 +69,43 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl return groups; } -export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): string { +export function buildBulkCloseConfirmationMessage( + input: BulkClosableTabGroups, + labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS, +): string { const { agentTabs, terminalTabs, otherTabs } = input; if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) { - return `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`; + return labels.all({ + agents: agentTabs.length, + terminals: terminalTabs.length, + tabs: otherTabs.length, + }); } if (agentTabs.length > 0 && terminalTabs.length > 0) { - return `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`; + return labels.agentsAndTerminals({ + agents: agentTabs.length, + terminals: terminalTabs.length, + }); } if (terminalTabs.length > 0 && otherTabs.length > 0) { - return `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`; + return labels.terminalsAndTabs({ + terminals: terminalTabs.length, + tabs: otherTabs.length, + }); } if (agentTabs.length > 0 && otherTabs.length > 0) { - return `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`; + return labels.agentsAndTabs({ + agents: agentTabs.length, + tabs: otherTabs.length, + }); } if (terminalTabs.length > 0) { - return `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`; + return labels.terminals({ terminals: terminalTabs.length }); } if (otherTabs.length > 0) { - return `This will close ${otherTabs.length} tab(s).`; + return labels.tabs({ tabs: otherTabs.length }); } - return `This will archive ${agentTabs.length} agent(s).`; + return labels.agents({ agents: agentTabs.length }); } export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise { @@ -81,7 +123,7 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput) }); } else if (hasDestructiveTabs) { warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { - error: new Error("Daemon client not available"), + error: new Error(i18n.t("common.errors.daemonClientUnavailable")), }); } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index fb0155a45..77a692592 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -32,6 +32,7 @@ import { X, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { SortableInlineList } from "@/components/sortable-inline-list"; import type { DraggableListDragHandleProps, @@ -61,6 +62,7 @@ import { buildWorkspaceDesktopTabActions, type WorkspaceDesktopTabActions, type WorkspaceTabMenuEntry, + type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { Theme } from "@/styles/theme"; @@ -223,20 +225,23 @@ interface WorkspaceDesktopTabsRowProps { showPaneSplitActions?: boolean; } -function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { +function getFallbackTabLabel( + tab: WorkspaceTabDescriptor, + labels: { newAgent: string; setup: string; terminal: string; agent: string }, +): string { if (tab.target.kind === "draft") { - return "New Agent"; + return labels.newAgent; } if (tab.target.kind === "setup") { - return "Setup"; + return labels.setup; } if (tab.target.kind === "terminal") { - return "Terminal"; + return labels.terminal; } if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } - return "Agent"; + return labels.agent; } function useMiddleClickClose(onClose: () => void) { @@ -533,6 +538,7 @@ export function WorkspaceDesktopTabsRow({ tabDropPreviewIndex = null, showPaneSplitActions = true, }: WorkspaceDesktopTabsRowProps) { + const { t } = useTranslation(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTerminalKeys = useShortcutKeys("workspace-terminal-new"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); @@ -571,13 +577,38 @@ export function WorkspaceDesktopTabsRow({ [inlineAddButtonWidth, tabsActionsWidth], ); + const fallbackTabLabels = useMemo( + () => ({ + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + agent: t("workspace.tabs.fallback.agent"), + }), + [t], + ); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); const tabLabelLengths = useMemo( () => tabs.map((tab) => { - const label = getFallbackTabLabel(tab.tab); + const label = getFallbackTabLabel(tab.tab, fallbackTabLabels); return label.length; }), - [tabs], + [fallbackTabLabels, tabs], ); const { layout } = useWorkspaceTabLayout({ @@ -664,6 +695,7 @@ export function WorkspaceDesktopTabsRow({ setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={onNavigateTab} onCloseTab={onCloseTab} + labels={tabMenuLabels} dragHandleProps={dragHandleProps} showDropIndicatorBefore={showDropIndicatorBefore} showDropIndicatorAfter={showDropIndicatorAfter} @@ -687,6 +719,7 @@ export function WorkspaceDesktopTabsRow({ onReloadAgent, onRenameTab, setHoveredCloseTabKey, + tabMenuLabels, tabDropPreviewIndex, tabs.length, ], @@ -739,14 +772,14 @@ export function WorkspaceDesktopTabsRow({ testID="workspace-new-agent-tab" onPress={handleCreateAgentTab} accessibilityRole="button" - accessibilityLabel="New agent tab" + accessibilityLabel={t("workspace.tabs.actions.newAgent")} style={newTabActionButtonStyle} > - New agent tab + {t("workspace.tabs.actions.newAgent")} {newTabKeys ? ( ) : null} @@ -760,7 +793,9 @@ export function WorkspaceDesktopTabsRow({ disabled={terminalDisabled} accessibilityRole="button" accessibilityLabel={ - isWaitingOnTerminalReadiness ? "Preparing terminal tab" : "New terminal tab" + isWaitingOnTerminalReadiness + ? t("workspace.tabs.actions.preparingTerminal") + : t("workspace.tabs.actions.newTerminal") } style={newTerminalActionButtonStyle} > @@ -769,7 +804,9 @@ export function WorkspaceDesktopTabsRow({ - {isWaitingOnTerminalReadiness ? "Preparing terminal..." : "New terminal tab"} + {isWaitingOnTerminalReadiness + ? t("workspace.tabs.actions.preparingTerminalTooltip") + : t("workspace.tabs.actions.newTerminal")} {newTerminalKeys ? ( @@ -783,14 +820,16 @@ export function WorkspaceDesktopTabsRow({ testID="workspace-new-browser" onPress={handleCreateBrowser} accessibilityRole="button" - accessibilityLabel="New browser tab" + accessibilityLabel={t("workspace.tabs.actions.newBrowser")} style={newTabActionButtonStyle} > - New browser tab + + {t("workspace.tabs.actions.newBrowser")} + @@ -801,14 +840,16 @@ export function WorkspaceDesktopTabsRow({ - Split pane right + + {t("workspace.tabs.actions.splitRight")} + {splitRightKeys ? ( ) : null} @@ -819,14 +860,16 @@ export function WorkspaceDesktopTabsRow({ - Split pane down + + {t("workspace.tabs.actions.splitDown")} + {splitDownKeys ? ( ) : null} @@ -862,6 +905,7 @@ function ResolvedDesktopTabChip({ setHoveredCloseTabKey, onNavigateTab, onCloseTab, + labels, dragHandleProps, showDropIndicatorBefore, showDropIndicatorAfter, @@ -886,10 +930,12 @@ function ResolvedDesktopTabChip({ setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; + labels: WorkspaceTabMenuLabels; dragHandleProps: DraggableListDragHandleProps | undefined; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; }) { + const { t } = useTranslation(); const resolvedTab = useMemo( () => buildWorkspaceDesktopTabActions({ @@ -904,6 +950,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, + labels, }), [ index, @@ -914,6 +961,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToRight, onCopyAgentId, onCopyResumeCommand, + labels, onReloadAgent, onRenameTab, tabCount, @@ -928,7 +976,9 @@ function ResolvedDesktopTabChip({ > {(presentation) => { const tooltipLabel = - presentation.titleState === "loading" ? "Loading agent title" : presentation.label; + presentation.titleState === "loading" + ? t("workspace.tabs.loadingAgentTitle") + : presentation.label; return ( diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 184a71802..929871a3d 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -1,4 +1,5 @@ import { type ReactElement, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, @@ -83,6 +84,7 @@ export function WorkspaceOpenInEditorButton({ activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { + const { t } = useTranslation(); const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); @@ -157,7 +159,9 @@ export function WorkspaceOpenInEditorButton({ const openMutation = useMutation({ mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()), onError: (error: unknown) => { - toast.error(error instanceof Error ? error.message : "Failed to open workspace"); + toast.error( + error instanceof Error ? error.message : t("workspace.git.openInEditor.failedOpen"), + ); }, }); @@ -207,8 +211,13 @@ export function WorkspaceOpenInEditorButton({ accessibilityRole="button" accessibilityLabel={ activeFileName - ? `Open ${activeFileName} in ${primaryOption.label}` - : `Open workspace in ${primaryOption.label}` + ? t("workspace.git.openInEditor.openFileIn", { + fileName: activeFileName, + target: primaryOption.label, + }) + : t("workspace.git.openInEditor.openIn", { + target: primaryOption.label, + }) } > {openMutation.isPending ? ( @@ -220,7 +229,9 @@ export function WorkspaceOpenInEditorButton({ ) : ( {primaryOption.icon} - {!hideLabels && Open} + {!hideLabels && ( + {t("workspace.git.openInEditor.open")} + )} )} @@ -230,7 +241,7 @@ export function WorkspaceOpenInEditorButton({ testID="workspace-open-in-editor-caret" style={caretTriggerStyle} accessibilityRole="button" - accessibilityLabel="Choose editor" + accessibilityLabel={t("workspace.git.openInEditor.chooseEditor")} > diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index 4937274a2..f4c364563 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -1,5 +1,6 @@ import { Text, View } from "react-native"; import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -43,24 +44,26 @@ export function renderWorkspaceRouteGate(input: { function getWorkspaceHostStateTitle( state: Extract, + t: ReturnType["t"], ): string { if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") { - return "Connecting"; + return t("workspace.route.connecting"); } if (state.connectionStatus === "offline") { - return `${state.hostName} is offline`; + return t("workspace.route.hostOffline", { hostName: state.hostName }); } - return `Cannot reach ${state.hostName}`; + return t("workspace.route.cannotReachHost", { hostName: state.hostName }); } function WorkspaceConnecting({ hostName }: { hostName: string }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); return ( - Loading workspace + {t("workspace.route.loading")} {hostName} @@ -77,6 +80,7 @@ function WorkspaceUnreachable({ onManageHost: () => void; }) { const { theme } = useUnistyles(); + const { t } = useTranslation(); const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error"; return ( @@ -85,11 +89,13 @@ function WorkspaceUnreachable({ ) : null} - {getWorkspaceHostStateTitle(state)} + {getWorkspaceHostStateTitle(state, t)} {state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? state.hostName - : `Host status: ${formatConnectionStatus(state.connectionStatus)}`} + : t("workspace.route.hostStatus", { + status: formatConnectionStatus(state.connectionStatus), + })} {state.lastError ? ( @@ -107,10 +113,10 @@ function WorkspaceUnreachable({ {canRetry ? ( ) : null} @@ -119,15 +125,17 @@ function WorkspaceUnreachable({ } function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) { + const { t } = useTranslation(); + return ( - Workspace not found + {t("workspace.route.missing")} {hostName} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 584e002e0..fc6344b29 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -14,6 +14,7 @@ import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; +import { useTranslation } from "react-i18next"; import { DiffStat } from "@/components/diff-stat"; import { CopyX, @@ -129,6 +130,7 @@ import { import { buildWorkspaceTabMenuEntries, type WorkspaceTabMenuEntry, + type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -160,6 +162,7 @@ import { WorkspaceFocusProvider } from "@/workspace/focus"; import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed"; import { buildBulkCloseConfirmationMessage, + type BulkCloseConfirmationLabels, classifyBulkClosableTabs, closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; @@ -295,40 +298,58 @@ function useSyncWorkspaceActiveBrowser(input: { }, [desktopActiveBrowserId]); } -function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { +function getFallbackTabOptionLabel( + tab: WorkspaceTabDescriptor, + labels: { + newAgent: string; + setup: string; + terminal: string; + browser: string; + agent: string; + }, +): string { if (tab.target.kind === "draft") { - return "New Agent"; + return labels.newAgent; } if (tab.target.kind === "setup") { - return "Setup"; + return labels.setup; } if (tab.target.kind === "terminal") { - return "Terminal"; + return labels.terminal; } if (tab.target.kind === "browser") { - return "Browser"; + return labels.browser; } if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } - return "Agent"; + return labels.agent; } -function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { +function getFallbackTabOptionDescription( + tab: WorkspaceTabDescriptor, + labels: { + newAgent: string; + workspaceSetup: string; + agent: string; + terminal: string; + browser: string; + }, +): string { if (tab.target.kind === "draft") { - return "New Agent"; + return labels.newAgent; } if (tab.target.kind === "setup") { - return "Workspace setup"; + return labels.workspaceSetup; } if (tab.target.kind === "agent") { - return "Agent"; + return labels.agent; } if (tab.target.kind === "terminal") { - return "Terminal"; + return labels.terminal; } if (tab.target.kind === "browser") { - return "Browser"; + return labels.browser; } return tab.target.path; } @@ -383,6 +404,7 @@ function ResolvedMobileActiveTabTrigger({ normalizedServerId: string; normalizedWorkspaceId: string; }) { + const { t } = useTranslation(); return ( - {presentation.titleState === "loading" ? "Loading..." : presentation.label} + {presentation.titleState === "loading" + ? t("workspace.tabs.loading") + : presentation.label} )} @@ -411,13 +435,17 @@ function WorkspaceDocumentTitleEffect({ label: string; titleState: "ready" | "loading"; }) { + const { t } = useTranslation(); useEffect(() => { if (isNative || typeof document === "undefined") { return; } const resolvedLabel = label.trim(); - document.title = titleState === "loading" ? "Loading..." : resolvedLabel || "Workspace"; - }, [label, titleState]); + document.title = + titleState === "loading" + ? t("workspace.tabs.loading") + : resolvedLabel || t("workspace.tabs.fallback.workspace"); + }, [label, titleState, t]); return null; } @@ -444,12 +472,13 @@ function MobileTabTrailingAccessory({ presentationLabel: string; menuEntries: WorkspaceTabMenuEntry[]; }) { + const { t } = useTranslation(); return ( @@ -547,6 +576,23 @@ function MobileWorkspaceTabOption({ onCloseTabsBelow: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; }) { + const { t } = useTranslation(); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); const menuTestIDBase = `workspace-tab-menu-${buildDeterministicWorkspaceTabId(tab.target)}`; const menuEntries = buildWorkspaceTabMenuEntries({ surface: "mobile", @@ -562,9 +608,20 @@ function MobileWorkspaceTabOption({ onCloseTabsBefore: onCloseTabsAbove, onCloseTabsAfter: onCloseTabsBelow, onCloseOtherTabs, + labels: tabMenuLabels, }); - const fallbackLabel = getFallbackTabOptionLabel(tab); + const fallbackLabels = useMemo( + () => ({ + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + }), + [t], + ); + const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); const trailingAccessory = useMemo( () => ( (null); const tabIndexByKey = useMemo(() => { @@ -697,7 +755,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ ref={anchorRef} testID="workspace-tab-switcher-trigger" accessibilityRole="button" - accessibilityLabel={`Switch tabs (${tabs.length} open)`} + accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })} style={switcherTriggerStyle} onPress={handleOpenSwitcher} > @@ -716,8 +774,8 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ value={activeTabKey} onSelect={onSelectSwitcherTab} searchable={false} - title="Switch tab" - searchPlaceholder="Search tabs" + title={t("workspace.tabs.switcher.title")} + searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")} open={isOpen} onOpenChange={setIsOpen} anchorRef={anchorRef} @@ -927,6 +985,7 @@ function WorkspaceHeaderMenu({ onCopyBranchName, onOpenSetupTab, }: WorkspaceHeaderMenuProps) { + const { t } = useTranslation(); const renderTriggerIcon = useCallback( ({ hovered, open }: { hovered: boolean; open: boolean }) => ( @@ -940,7 +999,7 @@ function WorkspaceHeaderMenu({ testID="workspace-header-menu-trigger" style={isMobile ? styles.compactHeaderActionButton : styles.headerActionButton} accessibilityRole="button" - accessibilityLabel="Workspace actions" + accessibilityLabel={t("workspace.header.actions.workspaceActions")} > {renderTriggerIcon} @@ -950,7 +1009,7 @@ function WorkspaceHeaderMenu({ leading={menuNewAgentIcon} onSelect={onCreateDraftTab} > - New agent + {t("workspace.header.actions.newAgent")} - New terminal + {t("workspace.header.actions.newTerminal")} {showCreateBrowserTab ? ( - New browser tab + {t("workspace.header.actions.newBrowser")} ) : null} - Import session + {t("workspace.header.actions.importSession")} - Copy workspace path + {t("workspace.header.actions.copyPath")} {currentBranchName ? ( - Copy branch name + {t("workspace.header.actions.copyBranchName")} ) : null} {showWorkspaceSetup ? ( @@ -1002,7 +1061,7 @@ function WorkspaceHeaderMenu({ leading={menuSettingsIcon} onSelect={onOpenSetupTab} > - Show setup + {t("workspace.header.actions.showSetup")} ) : null} @@ -1452,6 +1511,10 @@ interface WorkspaceTerminalTabActionsInput { persistenceKey: string | null; focusWorkspacePane: (workspaceKey: string, paneId: string) => void; openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + labels: { + workspacePathUnavailable: string; + terminalQueued: string; + }; toast: { error: (message: string) => void; show: (message: string) => void; @@ -1469,6 +1532,7 @@ function useWorkspaceTerminalTabActions({ persistenceKey, focusWorkspacePane, openWorkspaceTabFocused, + labels, toast, }: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions { const handleTerminalCreated = useCallback( @@ -1493,11 +1557,11 @@ function useWorkspaceTerminalTabActions({ [openWorkspaceTabFocused, persistenceKey], ); const handleWorkspacePathUnavailable = useCallback(() => { - toast.error("Workspace path is not available yet"); - }, [toast]); + toast.error(labels.workspacePathUnavailable); + }, [labels.workspacePathUnavailable, toast]); const handleTerminalCreateQueued = useCallback(() => { - toast.show("Preparing workspace, opening terminal when ready..."); - }, [toast]); + toast.show(labels.terminalQueued); + }, [labels.terminalQueued, toast]); return { handleTerminalCreated, @@ -1515,6 +1579,7 @@ function useWorkspaceCheckoutStatus(input: { normalizedWorkspaceId: string; workspaceDirectory: string | null; }) { + const { t } = useTranslation(); const isCheckoutQueryEnabled = useMemo( () => canCreateWorkspaceTerminal({ @@ -1533,7 +1598,7 @@ function useWorkspaceCheckoutStatus(input: { enabled: isCheckoutQueryEnabled, queryFn: async () => { if (!input.client || !input.workspaceDirectory) { - throw new Error("Host is not connected"); + throw new Error(t("workspace.terminal.hostDisconnected")); } return await input.client.getCheckoutStatus(input.workspaceDirectory); }, @@ -1555,6 +1620,7 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, }: WorkspaceScreenContentProps) { + const { t } = useTranslation(); const _insets = useSafeAreaInsets(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); @@ -1642,6 +1708,10 @@ function WorkspaceScreenContent({ persistenceKey, focusWorkspacePane, openWorkspaceTabFocused, + labels: { + workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"), + terminalQueued: t("workspace.header.toasts.terminalQueued"), + }, toast, }); const queryClient = useQueryClient(); @@ -2280,17 +2350,58 @@ function WorkspaceScreenContent({ } return map; }, [uiTabs]); + const bulkCloseConfirmationLabels = useMemo( + () => ({ + all: ({ agents, terminals: terminalCount, tabs: tabCount }) => + t("workspace.tabs.confirmations.bulk.all", { + agents, + terminals: terminalCount, + tabs: tabCount, + }), + agentsAndTerminals: ({ agents, terminals: terminalCount }) => + t("workspace.tabs.confirmations.bulk.agentsAndTerminals", { + agents, + terminals: terminalCount, + }), + terminalsAndTabs: ({ terminals: terminalCount, tabs: tabCount }) => + t("workspace.tabs.confirmations.bulk.terminalsAndTabs", { + terminals: terminalCount, + tabs: tabCount, + }), + agentsAndTabs: ({ agents, tabs: tabCount }) => + t("workspace.tabs.confirmations.bulk.agentsAndTabs", { agents, tabs: tabCount }), + terminals: ({ terminals: terminalCount }) => + t("workspace.tabs.confirmations.bulk.terminals", { terminals: terminalCount }), + tabs: ({ tabs: tabCount }) => t("workspace.tabs.confirmations.bulk.tabs", { tabs: tabCount }), + agents: ({ agents }) => t("workspace.tabs.confirmations.bulk.agents", { agents }), + }), + [t], + ); + const explorerToggleLabel = isExplorerOpen + ? t("workspace.tabs.explorer.close") + : t("workspace.tabs.explorer.open"); const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]); + const tabFallbackLabels = useMemo( + () => ({ + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + }), + [t], + ); const tabSwitcherOptions = useMemo( () => tabs.map((tab) => ({ id: tab.key, - label: getFallbackTabOptionLabel(tab), - description: getFallbackTabOptionDescription(tab), + label: getFallbackTabOptionLabel(tab, tabFallbackLabels), + description: getFallbackTabOptionDescription(tab, tabFallbackLabels), })), - [tabs], + [tabFallbackLabels, tabs], ); const handleCreateDraftTab = useCallback( @@ -2366,10 +2477,10 @@ function WorkspaceScreenContent({ const { tabId, terminalId } = input; await closeTab(tabId, async () => { const confirmed = await confirmDialog({ - title: "Close terminal?", - message: "Any running process in this terminal will be stopped immediately.", - confirmLabel: "Close", - cancelLabel: "Cancel", + title: t("workspace.tabs.confirmations.closeTerminalTitle"), + message: t("workspace.tabs.confirmations.closeTerminalMessage"), + confirmLabel: t("workspace.tabs.confirmations.close"), + cancelLabel: t("workspace.tabs.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -2395,6 +2506,7 @@ function WorkspaceScreenContent({ killTerminalAsync, persistenceKey, removeTerminalFromCache, + t, ], ); @@ -2413,11 +2525,10 @@ function WorkspaceScreenContent({ if (isRunning && closePolicy.kind === "archive-on-close") { const confirmed = await confirmDialog({ - title: "Archive running agent?", - message: - "This agent is still running. Archiving it will stop the agent and close the tab.", - confirmLabel: "Archive", - cancelLabel: "Cancel", + title: t("workspace.tabs.confirmations.archiveRunningAgentTitle"), + message: t("workspace.tabs.confirmations.archiveRunningAgentMessage"), + confirmLabel: t("workspace.tabs.confirmations.archive"), + cancelLabel: t("workspace.tabs.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -2441,7 +2552,7 @@ function WorkspaceScreenContent({ void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {}); }); }, - [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], + [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t], ); const handleCloseDraftOrFileTab = useCallback( @@ -2481,12 +2592,12 @@ function WorkspaceScreenContent({ if (!agentId) return; try { await Clipboard.setStringAsync(agentId); - toast.copied("Agent ID"); + toast.copied(t("workspace.tabs.toasts.agentIdCopiedLabel")); } catch { - toast.error("Copy failed"); + toast.error(t("workspace.tabs.toasts.copyFailed")); } }, - [toast], + [toast, t], ); const handleCopyResumeCommand = useCallback( @@ -2497,7 +2608,7 @@ function WorkspaceScreenContent({ const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { - toast.error("Resume ID not available"); + toast.error(t("workspace.tabs.toasts.resumeIdUnavailable")); return; } @@ -2508,27 +2619,27 @@ function WorkspaceScreenContent({ sessionId: providerSessionId, }) ?? null; if (!command) { - toast.error("Resume command not available"); + toast.error(t("workspace.tabs.toasts.resumeCommandUnavailable")); return; } try { await Clipboard.setStringAsync(command); - toast.copied("resume command"); + toast.copied(t("workspace.tabs.toasts.resumeCommandCopiedLabel")); } catch { - toast.error("Copy failed"); + toast.error(t("workspace.tabs.toasts.copyFailed")); } }, - [normalizedServerId, toast], + [normalizedServerId, toast, t], ); const handleReloadAgent = useCallback( async (agentId: string) => { if (!client || !isConnected) { - toast.error("Host is not connected"); + toast.error(t("workspace.terminal.hostDisconnected")); return; } - toast.show("Reloading agent…", { durationMs: null }); + toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null }); try { await client.refreshAgent(agentId); // Send the existing cursor so the server detects the new epoch and @@ -2544,41 +2655,43 @@ function WorkspaceScreenContent({ ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } : {}), }); - toast.show("Reloaded agent", { variant: "success" }); + toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" }); } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to reload agent"); + toast.error( + error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"), + ); } }, - [client, isConnected, normalizedServerId, toast], + [client, isConnected, normalizedServerId, toast, t], ); const handleCopyWorkspacePath = useCallback(async () => { if (!workspaceDirectory) { - toast.error("Workspace path not available"); + toast.error(t("workspace.header.toasts.workspacePathUnavailable")); return; } try { await Clipboard.setStringAsync(workspaceDirectory); - toast.copied("Workspace path"); + toast.copied(t("workspace.header.toasts.workspacePathCopiedLabel")); } catch { - toast.error("Copy failed"); + toast.error(t("workspace.tabs.toasts.copyFailed")); } - }, [toast, workspaceDirectory]); + }, [toast, workspaceDirectory, t]); const handleCopyBranchName = useCallback(async () => { if (!currentBranchName) { - toast.error("Branch name not available"); + toast.error(t("workspace.header.toasts.branchNameUnavailable")); return; } try { await Clipboard.setStringAsync(currentBranchName); - toast.copied("Branch name"); + toast.copied(t("workspace.header.toasts.branchNameCopiedLabel")); } catch { - toast.error("Copy failed"); + toast.error(t("workspace.tabs.toasts.copyFailed")); } - }, [currentBranchName, toast]); + }, [currentBranchName, toast, t]); const handleOpenSetupTab = useCallback(() => { if (!persistenceKey) { @@ -2604,9 +2717,9 @@ function WorkspaceScreenContent({ const groups = classifyBulkClosableTabs(tabsToClose); const confirmed = await confirmDialog({ title, - message: buildBulkCloseConfirmationMessage(groups), - confirmLabel: "Close", - cancelLabel: "Cancel", + message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels), + confirmLabel: t("workspace.tabs.confirmations.close"), + cancelLabel: t("workspace.tabs.confirmations.cancel"), destructive: true, }); if (!confirmed) { @@ -2632,7 +2745,14 @@ function WorkspaceScreenContent({ const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); }, - [client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey], + [ + bulkCloseConfirmationLabels, + client, + closeTab, + closeWorkspaceTabWithCleanup, + persistenceKey, + t, + ], ); const handleCloseTabsToLeftInPane = useCallback( @@ -2643,11 +2763,11 @@ function WorkspaceScreenContent({ } await handleBulkCloseTabs({ tabsToClose: paneTabs.slice(0, index), - title: "Close tabs to the left?", + title: t("workspace.tabs.confirmations.closeTabsLeftTitle"), logLabel: "to the left", }); }, - [handleBulkCloseTabs], + [handleBulkCloseTabs, t], ); const handleCloseTabsToLeft = useCallback( @@ -2665,11 +2785,11 @@ function WorkspaceScreenContent({ } await handleBulkCloseTabs({ tabsToClose: paneTabs.slice(index + 1), - title: "Close tabs to the right?", + title: t("workspace.tabs.confirmations.closeTabsRightTitle"), logLabel: "to the right", }); }, - [handleBulkCloseTabs], + [handleBulkCloseTabs, t], ); const handleCloseTabsToRight = useCallback( @@ -2684,11 +2804,11 @@ function WorkspaceScreenContent({ const tabsToClose = paneTabs.filter((tab) => tab.tabId !== tabId); await handleBulkCloseTabs({ tabsToClose, - title: "Close other tabs?", + title: t("workspace.tabs.confirmations.closeOtherTabsTitle"), logLabel: "from close other tabs", }); }, - [handleBulkCloseTabs], + [handleBulkCloseTabs, t], ); const handleCloseOtherTabs = useCallback( @@ -3084,13 +3204,16 @@ function WorkspaceScreenContent({ [focusedPaneId, handleReorderTabsInPane], ); - const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() { - return ( - - No tabs in this pane. - - ); - }, []); + const renderSplitPaneEmptyState = useCallback( + function renderSplitPaneEmptyState() { + return ( + + {t("workspace.tabs.emptyPane")} + + ); + }, + [t], + ); const containerStyle = containerWithWorkspaceBackgroundStyle; @@ -3147,7 +3270,7 @@ function WorkspaceScreenContent({ testID="workspace-explorer-toggle" onPress={handleToggleExplorer} accessibilityRole="button" - accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"} + accessibilityLabel={explorerToggleLabel} accessibilityState={explorerToggleAccessibilityState} style={explorerToggleStyle} > @@ -3175,7 +3298,9 @@ function WorkspaceScreenContent({ offset={8} > - Toggle explorer + + {t("workspace.tabs.explorer.toggle")} + @@ -3186,13 +3311,13 @@ function WorkspaceScreenContent({ {({ hovered }) => { @@ -3206,13 +3331,13 @@ function WorkspaceScreenContent({ {({ hovered }) => { @@ -3247,8 +3372,10 @@ function WorkspaceScreenContent({ isGitCheckout, handleToggleExplorer, isExplorerOpen, + explorerToggleLabel, explorerToggleAccessibilityState, explorerToggleStyle, + t, ], ); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx index 6c42778cd..c89fe449c 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx @@ -1,6 +1,7 @@ /** * @vitest-environment jsdom */ +import { i18n as testI18n } from "@/i18n/i18next"; import React, { type ReactElement } from "react"; import { act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -9,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +void testI18n; + const { theme, startWorkspaceScriptMock } = vi.hoisted(() => { const hoistedTheme = { spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 }, diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 4121effcf..62545de6a 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -4,6 +4,7 @@ import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; @@ -169,6 +170,7 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): } function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement { + const { t } = useTranslation(); const disabled = !url; const closeMenu = useDropdownMenuClose(); @@ -192,7 +194,7 @@ function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkPro return ( - exit {code} + {t("workspace.scripts.states.exitCode", { code })} ); } @@ -257,6 +260,7 @@ function ScriptRow({ onViewTerminal, onOpenUrlInBrowserTab, }: ScriptRowProps): ReactElement { + const { t } = useTranslation(); const isRunning = script.lifecycle === "running"; const isService = (script.type ?? "service") === "service"; const exitCode = script.exitCode ?? null; @@ -309,21 +313,25 @@ function ScriptRow({ if (isRunning && liveTerminalId) { primaryAction = ( ); } else if (!isRunning) { primaryAction = ( ); @@ -332,7 +340,9 @@ function ScriptRow({ return ( @@ -372,6 +382,7 @@ export function WorkspaceScriptsButton({ hideLabels, presentation = "split", }: WorkspaceScriptsButtonProps): ReactElement | null { + const { t } = useTranslation(); const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; @@ -380,7 +391,7 @@ export function WorkspaceScriptsButton({ const startScriptMutation = useMutation({ mutationFn: async (scriptName: string) => { if (!client) { - throw new Error("Daemon client not available"); + throw new Error(t("common.errors.daemonClientUnavailable")); } const result = await client.startWorkspaceScript(workspaceId, scriptName); if (result.error) { @@ -389,9 +400,14 @@ export function WorkspaceScriptsButton({ return result; }, onError: (error, scriptName) => { - toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, { - variant: "error", - }); + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.startFailed", { scriptName }), + { + variant: "error", + }, + ); }, onSuccess: (result) => { if (result.terminalId) { @@ -432,7 +448,7 @@ export function WorkspaceScriptsButton({ testID="workspace-scripts-button" style={triggerStyle} accessibilityRole="button" - accessibilityLabel="Workspace scripts" + accessibilityLabel={t("workspace.scripts.accessibility.trigger")} > - {!hideLabels && Scripts} + {!hideLabels && ( + {t("workspace.scripts.title")} + )} {presentation === "split" ? ( ) : null} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index a208ca03c..5f7587452 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -1,9 +1,38 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { i18n } from "@/i18n/i18next"; import { encodeFilePathForPathSegment } from "@/utils/host-routes"; import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity"; export type WorkspaceTabMenuSurface = "desktop" | "mobile"; +export interface WorkspaceTabMenuLabels { + copyResumeCommand: string; + copyAgentId: string; + rename: string; + closeAbove: string; + closeBelow: string; + closeLeft: string; + closeRight: string; + closeOthers: string; + reloadAgent: string; + reloadAgentTooltip: string; + close: string; +} + +export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = { + copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"), + rename: i18n.t("workspace.tabs.menu.rename"), + closeAbove: i18n.t("workspace.tabs.menu.closeAbove"), + closeBelow: i18n.t("workspace.tabs.menu.closeBelow"), + closeLeft: i18n.t("workspace.tabs.menu.closeLeft"), + closeRight: i18n.t("workspace.tabs.menu.closeRight"), + closeOthers: i18n.t("workspace.tabs.menu.closeOthers"), + reloadAgent: i18n.t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: i18n.t("workspace.tabs.menu.reloadAgentTooltip"), + close: i18n.t("workspace.tabs.menu.close"), +}; + export type WorkspaceTabMenuEntry = | { kind: "item"; @@ -43,6 +72,7 @@ interface BuildWorkspaceTabMenuEntriesInput { onCloseTabsBefore: (tabId: string) => Promise | void; onCloseTabsAfter: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; + labels?: WorkspaceTabMenuLabels; } interface BuildWorkspaceDesktopTabActionsInput { @@ -57,6 +87,7 @@ interface BuildWorkspaceDesktopTabActionsInput { onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; + labels?: WorkspaceTabMenuLabels; } export interface WorkspaceDesktopTabActions { @@ -65,12 +96,18 @@ export interface WorkspaceDesktopTabActions { closeButtonTestId: string; } -function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string { - return surface === "mobile" ? "Close tabs above" : "Close to the left"; +function buildCloseBeforeLabel( + surface: WorkspaceTabMenuSurface, + labels: WorkspaceTabMenuLabels, +): string { + return surface === "mobile" ? labels.closeAbove : labels.closeLeft; } -function buildCloseAfterLabel(surface: WorkspaceTabMenuSurface): string { - return surface === "mobile" ? "Close tabs below" : "Close to the right"; +function buildCloseAfterLabel( + surface: WorkspaceTabMenuSurface, + labels: WorkspaceTabMenuLabels, +): string { + return surface === "mobile" ? labels.closeBelow : labels.closeRight; } function buildCloseBeforeTestIDSuffix(surface: WorkspaceTabMenuSurface): string { @@ -118,6 +155,7 @@ export function buildWorkspaceTabMenuEntries( onCloseTabsAfter, onCloseOtherTabs, } = input; + const labels = input.labels ?? DEFAULT_WORKSPACE_TAB_MENU_LABELS; const isFirstTab = index === 0; const isLastTab = index === tabCount - 1; const isOnlyTab = tabCount <= 1; @@ -128,7 +166,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "copy-resume-command", - label: "Copy resume command", + label: labels.copyResumeCommand, icon: "copy", testID: `${menuTestIDBase}-copy-resume-command`, onSelect: () => { @@ -138,7 +176,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "copy-agent-id", - label: "Copy agent id", + label: labels.copyAgentId, icon: "copy", hint: agentId.slice(0, 7), testID: `${menuTestIDBase}-copy-agent-id`, @@ -152,7 +190,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "rename", - label: "Rename", + label: labels.rename, icon: "pencil", testID: `${menuTestIDBase}-rename`, onSelect: () => { @@ -168,7 +206,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "close-before", - label: buildCloseBeforeLabel(surface), + label: buildCloseBeforeLabel(surface, labels), icon: "arrow-left-to-line", disabled: isFirstTab, testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`, @@ -179,7 +217,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "close-after", - label: buildCloseAfterLabel(surface), + label: buildCloseAfterLabel(surface, labels), icon: "arrow-right-to-line", disabled: isLastTab, testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`, @@ -190,7 +228,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "close-others", - label: "Close other tabs", + label: labels.closeOthers, icon: "copy-x", disabled: isOnlyTab, testID: `${menuTestIDBase}-close-others`, @@ -203,9 +241,9 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "reload-agent", - label: "Reload agent", + label: labels.reloadAgent, icon: "rotate-cw", - tooltip: "Reload agent to update skills, MCPs or login status.", + tooltip: labels.reloadAgentTooltip, testID: `${menuTestIDBase}-reload-agent`, onSelect: () => { void onReloadAgent(agentId); @@ -215,7 +253,7 @@ export function buildWorkspaceTabMenuEntries( entries.push({ kind: "item", key: "close", - label: "Close", + label: labels.close, icon: "x", testID: `${menuTestIDBase}-close`, onSelect: () => { @@ -246,6 +284,7 @@ export function buildWorkspaceDesktopTabActions( onCloseTabsBefore: input.onCloseTabsToLeft, onCloseTabsAfter: input.onCloseTabsToRight, onCloseOtherTabs: input.onCloseOtherTabs, + labels: input.labels, }), closeButtonTestId: getCloseButtonTestId(input.tab), }; diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index ac03e040c..a421c2b2c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; import { SyncedLoader } from "@/components/synced-loader"; import { ensurePanelsRegistered } from "@/panels/register-panels"; @@ -181,6 +182,7 @@ export function WorkspaceTabOptionRow({ onPress, trailingAccessory, }: WorkspaceTabOptionRowProps): ReactElement { + const { t } = useTranslation(); const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.optionMainPressable, @@ -200,7 +202,9 @@ export function WorkspaceTabOptionRow({ - {presentation.titleState === "loading" ? "Loading..." : presentation.label} + {presentation.titleState === "loading" + ? t("workspace.tabs.loading") + : presentation.label} diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index f043f4c64..6d13b3ed3 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -6,6 +6,7 @@ import type { HostProfile } from "@/types/host-connection"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { openExternalUrl } from "@/utils/open-external-url"; import { isWeb } from "@/constants/platform"; +import { i18n } from "@/i18n/i18next"; interface DownloadProgress { percent: number; @@ -85,12 +86,12 @@ export const useDownloadStore = create()((set, get) => ({ try { const tokenResponse = await requestFileDownloadToken(path); if (tokenResponse.error || !tokenResponse.token) { - throw new Error(tokenResponse.error ?? "Failed to request download token."); + throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed")); } const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); if (!downloadTarget.baseUrl) { - throw new Error("Download host is unavailable."); + throw new Error(i18n.t("downloads.hostUnavailable")); } const resolvedFileName = tokenResponse.fileName ?? fileName; @@ -140,7 +141,7 @@ export const useDownloadStore = create()((set, get) => ({ const result = await downloadResumable.downloadAsync(); if (!result) { - throw new Error("Download was cancelled."); + throw new Error(i18n.t("downloads.cancelled")); } get().completeDownload(id); @@ -148,11 +149,13 @@ export const useDownloadStore = create()((set, get) => ({ if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(result.uri, { mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: resolvedFileName ? `Share ${resolvedFileName}` : "Share file", + dialogTitle: resolvedFileName + ? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName }) + : i18n.t("downloads.shareFile"), }); } } catch (error) { - const message = error instanceof Error ? error.message : "Failed to download file."; + const message = error instanceof Error ? error.message : i18n.t("downloads.failed"); if (isWeb) { console.warn("[DownloadStore] Download failed:", message); get().failDownload(id, message); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 47178949d..130689676 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState, type ReactElement } from "react"; import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; import { Archive, ChevronDown, ChevronRight } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { getProviderIcon } from "@/components/provider-icons"; @@ -123,10 +124,12 @@ function SubagentsTrackRow({ onOpenSubagent, onArchiveSubagent, }: SubagentsTrackRowProps): ReactElement { + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const [hovered, setHovered] = useState(false); const presentation = useMemo(() => buildRowPresentation(row), [row]); - const displayLabel = presentation.titleState === "loading" ? "Loading..." : presentation.label; + const displayLabel = + presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { onOpenSubagent(row.id); }, [onOpenSubagent, row.id]); @@ -179,6 +182,7 @@ function SubagentArchiveButton({ visible: boolean; onPress: () => void; }): ReactElement { + const { t } = useTranslation(); return ( - Archive subagent + {t("subagents.archiveTooltip")} diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 01d375c52..af50ed91a 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,5 +1,6 @@ import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; +import { i18n } from "@/i18n/i18next"; export interface TerminalStreamControllerClient { subscribeTerminal: ( @@ -127,7 +128,8 @@ export class TerminalStreamController { this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: false, - error: error instanceof Error ? error.message : "Unable to subscribe to terminal", + error: + error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), }); }); } diff --git a/packages/app/src/utils/open-service-url.ts b/packages/app/src/utils/open-service-url.ts index 66dfef58d..7a0768f41 100644 --- a/packages/app/src/utils/open-service-url.ts +++ b/packages/app/src/utils/open-service-url.ts @@ -4,6 +4,7 @@ import { persistAppSettings, type ServiceUrlBehavior, } from "@/hooks/use-settings"; +import { i18n } from "@/i18n/i18next"; import { openExternalUrl } from "@/utils/open-external-url"; export interface OpenServiceUrlOptions { @@ -36,11 +37,11 @@ async function resolveBehavior(url: string): Promise = result.confirmed ? "in-app" : "external";