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) => (
{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
@@ -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";