diff --git a/.plans/web-i18n-rollout.md b/.plans/web-i18n-rollout.md new file mode 100644 index 000000000..1d73bab40 --- /dev/null +++ b/.plans/web-i18n-rollout.md @@ -0,0 +1,196 @@ +# Web i18n Rollout Tracker + +_Last updated: 2026-03-31_ + +This document tracks the phased rollout of multilingual support for `apps/web`. + +Supported locales: + +- `en` +- `es` +- `fr` +- `zh-CN` + +Status values: + +- `TODO`: Not started +- `IN_PROGRESS`: Started but not yet shippable +- `DONE`: Implemented and verified +- `BLOCKED`: Waiting on a dependency or decision + +## Scope + +In scope for this rollout: + +- frontend-only localization in `apps/web` +- product-owned UI strings only +- locale persistence via app settings +- locale-aware timestamps +- root screens, settings, onboarding, and mobile pairing in the first shippable stop + +Out of scope for this rollout: + +- `apps/server` +- `apps/marketing` +- user/model/code content translation +- arbitrary provider/server freeform error translation + +## Current Snapshot + +Overall status: `IN_PROGRESS` + +Completed so far: + +- Added `react-intl` to `apps/web` +- Added locale schema support to `apps/web/src/appSettings.ts` +- Added the shared i18n scaffolding under `apps/web/src/i18n/` +- Added initial message catalogs for `en`, `es`, `fr`, and `zh-CN` +- Wired the root route through a shared `I18nProvider` +- Made timestamps honor the resolved app locale +- Updated the timestamp callsites in chat and diff surfaces +- Added Phase 1 guardrail tests for locale resolution, timestamp formatting, and catalog parity +- Resolved the repo-wide server typecheck blocker by forcing a single `effect` version across `@effect/*` +- `apps/web` tests pass +- `bun fmt` passed +- `bun lint` passed +- `bun typecheck` passed + +Not yet completed: + +- Settings page migration +- Onboarding migration +- Mobile pairing migration + +## Phase 1 — Infrastructure + +Objective: +Establish the shared localization foundation without coupling it to the server or user content. + +Checklist: + +- [x] Add `react-intl` to `apps/web` + - Status: `DONE` +- [x] Add persisted locale preference to `apps/web/src/appSettings.ts` + - Status: `DONE` +- [x] Add shared i18n module in `apps/web/src/i18n/` + - Status: `DONE` +- [x] Add message catalogs for `en`, `es`, `fr`, and `zh-CN` + - Status: `DONE` +- [ ] Wire `I18nProvider` into [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx) + - Status: `DONE` +- [ ] Expose stable translation helpers for component usage + - Status: `DONE` +- [ ] Add locale-aware timestamp formatting in [apps/web/src/timestampFormat.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/timestampFormat.ts) + - Status: `DONE` +- [ ] Update timestamp callsites in [apps/web/src/components/chat/MessagesTimeline.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/chat/MessagesTimeline.tsx) + - Status: `DONE` +- [ ] Update timestamp callsites in [apps/web/src/components/DiffPanel.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/DiffPanel.tsx) + - Status: `DONE` + +Exit criteria: + +- Locale can be resolved at runtime from `system | en | es | fr | zh-CN` +- The app can render under a single root i18n provider +- Timestamp formatting can follow the selected app locale + +## Phase 2 — First Shippable Surfaces + +Objective: +Ship a coherent multilingual slice that is complete on the highest-value product-owned surfaces. + +Checklist: + +- [ ] Migrate root route loading/error copy in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx) + - Status: `TODO` +- [ ] Migrate root keybinding toasts in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx) + - Status: `TODO` +- [ ] Add language selector to [apps/web/src/routes/_chat.settings.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/_chat.settings.tsx) + - Status: `TODO` +- [ ] Migrate product-owned settings copy in [apps/web/src/routes/_chat.settings.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/_chat.settings.tsx) + - Status: `TODO` +- [ ] Migrate supporting settings components in [apps/web/src/components/EnvironmentVariablesEditor.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/EnvironmentVariablesEditor.tsx) + - Status: `TODO` +- [ ] Migrate supporting settings components in [apps/web/src/components/CustomThemeDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/CustomThemeDialog.tsx) + - Status: `TODO` +- [ ] Migrate onboarding content in [apps/web/src/components/onboarding/onboardingSteps.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/onboarding/onboardingSteps.ts) + - Status: `TODO` +- [ ] Migrate onboarding controls in [apps/web/src/components/onboarding/OnboardingDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/onboarding/OnboardingDialog.tsx) + - Status: `TODO` +- [ ] Migrate mobile pairing UI in [apps/web/src/components/mobile/MobilePairingScreen.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/mobile/MobilePairingScreen.tsx) + - Status: `TODO` + +Exit criteria: + +- Users can select a language in Settings without reloading +- Root screens, Settings, Onboarding, and Mobile Pairing render localized product UI +- English remains the safe fallback when a locale cannot be resolved or loaded + +## Phase 3 — High-Traffic Product Surfaces + +Objective: +Extend localization to the most visible remaining chrome and toast-heavy flows. + +Checklist: + +- [ ] Migrate sidebar toasts and chrome in [apps/web/src/components/Sidebar.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/Sidebar.tsx) + - Status: `TODO` +- [ ] Migrate chat home empty state in [apps/web/src/components/ChatHomeEmptyState.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/ChatHomeEmptyState.tsx) + - Status: `TODO` +- [ ] Migrate workspace file tree messages in [apps/web/src/components/WorkspaceFileTree.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/WorkspaceFileTree.tsx) + - Status: `TODO` +- [ ] Migrate Git actions UI copy in [apps/web/src/components/GitActionsControl.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/GitActionsControl.tsx) + - Status: `TODO` +- [ ] Migrate branch selector copy in [apps/web/src/components/BranchToolbarBranchSelector.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/BranchToolbarBranchSelector.tsx) + - Status: `TODO` + +Exit criteria: + +- The highest-traffic app chrome and common toasts are localized +- Remaining untranslated product UI is narrow and intentional + +## Phase 4 — Hardening and Verification + +Objective: +Make the rollout safe to maintain and safe to ship repeatedly. + +Checklist: + +- [ ] Add locale resolution tests + - Status: `DONE` +- [ ] Add app settings default tests for locale + - Status: `DONE` +- [ ] Add message catalog parity tests + - Status: `DONE` +- [ ] Add timestamp formatting tests + - Status: `DONE` +- [ ] Run `bun fmt` + - Status: `DONE` +- [ ] Run `bun lint` + - Status: `DONE` +- [ ] Run `bun typecheck` + - Status: `DONE` + +Exit criteria: + +- Catalog drift is caught by tests +- Locale behavior is covered by automated checks +- Required repository quality gates pass + +## Shippable Stop + +The first shippable stop is: + +- Phase 1 complete +- Phase 2 complete +- Phase 4 verification complete + +Phase 3 can follow later without blocking the first release if the app’s core localized surfaces are already coherent. + +## Next Up + +Immediate next implementation steps: + +1. Migrate root route strings and root toasts. +2. Migrate Settings and its supporting components. +3. Migrate Onboarding and Mobile Pairing. +4. Continue with Phase 2 completion toward the first shippable localized stop. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6132277c6..468235269 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -356,9 +356,7 @@ describe("ProviderCommandReactor", () => { }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.worktreePath).toBeNull(); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ce6f53fd8..6ec0b2c17 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -6,6 +6,7 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL, EventId, type OrchestrationEvent, + type ProjectId, type ProviderModelOptions, ProviderKind, type ProviderStartOptions, @@ -145,11 +146,11 @@ function buildGeneratedWorktreeBranchName(raw: string): string { function resolveSessionCwd(input: { readonly thread: { readonly id: ThreadId; - readonly projectId: string; + readonly projectId: ProjectId; readonly worktreePath: string | null; }; readonly projects: ReadonlyArray<{ - readonly id: string; + readonly id: ProjectId; readonly workspaceRoot: string; }>; }): { diff --git a/apps/web/package.json b/apps/web/package.json index 435217dd2..768635a73 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "oxfmt": "^0.42.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-intl": "^10.1.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index b596e098c..e1fd28d33 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -5,6 +5,7 @@ import { AppSettingsSchema, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + DEFAULT_APP_LOCALE, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, getCustomModelOptionsByProvider, @@ -258,6 +259,7 @@ describe("AppSettingsSchema", () => { defaultThreadEnvMode: "worktree", confirmThreadDelete: false, enableAssistantStreaming: false, + locale: DEFAULT_APP_LOCALE, sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, timestampFormat: DEFAULT_TIMESTAMP_FORMAT, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14f53df75..bd4481aa1 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -11,6 +11,7 @@ import { normalizeModelSlug, resolveSelectableModel, } from "@okcode/shared/model"; +import { APP_LOCALE_PREFERENCES } from "./i18n/types"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; @@ -21,6 +22,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const AppLocale = Schema.Literals(APP_LOCALE_PREFERENCES); +export type AppLocale = typeof AppLocale.Type; +export const DEFAULT_APP_LOCALE: AppLocale = "system"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -64,6 +68,7 @@ export const AppSettingsSchema = Schema.Struct({ confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)), openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e7192088f..9b9821f03 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -23,6 +23,7 @@ import { } from "../lib/diffFileReviewState"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useI18n } from "../i18n/useI18n"; import { useStore } from "../store"; import { useAppSettings } from "../appSettings"; import { formatShortTimestamp } from "../timestampFormat"; @@ -349,6 +350,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); + const { resolvedLocale } = useI18n(); const { settings } = useAppSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); @@ -641,14 +643,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { {selectedTurnId === null ? "All changes" : selectedTurn?.turnId === latestSelectedTurnId - ? `Latest • ${formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat)}` + ? `Latest • ${formatShortTimestamp( + selectedTurn.completedAt, + settings.timestampFormat, + resolvedLocale, + )}` : `Change ${ selectedTurn?.checkpointTurnCount ?? (selectedTurn ? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId] : null) ?? "?" - } • ${selectedTurn ? formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat) : ""}`} + } • ${ + selectedTurn + ? formatShortTimestamp( + selectedTurn.completedAt, + settings.timestampFormat, + resolvedLocale, + ) + : "" + }`} All changes @@ -665,7 +679,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }`} - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + {formatShortTimestamp( + summary.completedAt, + settings.timestampFormat, + resolvedLocale, + )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b025e0fc5..3c37a563e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -127,10 +127,7 @@ export const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && hasCodeViewerTabs && ( - + )} {!isMobileCompanion && activeProjectName && ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 86ed2c8ea..c81a4ebff 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,8 +1,10 @@ import { MessageId } from "@okcode/contracts"; +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance"; +import { I18nProvider } from "~/i18n/I18nProvider"; function matchMedia() { return { @@ -36,6 +38,9 @@ beforeAll(() => { documentElement: { classList, offsetHeight: 0, + style: { + setProperty: () => {}, + }, }, }); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { @@ -46,10 +51,14 @@ beforeAll(() => { const EMPTY_SHORTCUT_GUIDES = buildChatShortcutGuides([], "Win32"); +function renderWithI18n(element: ReactElement) { + return renderToStaticMarkup({element}); +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( + const markup = renderWithI18n( { it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( + const markup = renderWithI18n( { it("renders shortcut guidance when the timeline is empty", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( + const markup = renderWithI18n( (null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -467,7 +469,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}

- {formatTimestamp(row.message.createdAt, timestampFormat)} + {formatTimestamp(row.message.createdAt, timestampFormat, resolvedLocale)}

@@ -579,6 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ? formatElapsed(row.durationStart, nowIso) : formatElapsed(row.durationStart, row.message.completedAt), timestampFormat, + resolvedLocale, )}

@@ -795,9 +798,10 @@ function formatMessageMeta( createdAt: string, duration: string | null, timestampFormat: TimestampFormat, + locale: ReturnType["resolvedLocale"], ): string { - if (!duration) return formatTimestamp(createdAt, timestampFormat); - return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; + if (!duration) return formatTimestamp(createdAt, timestampFormat, locale); + return `${formatTimestamp(createdAt, timestampFormat, locale)} • ${duration}`; } const UserMessageTerminalContextInlineLabel = memo( diff --git a/apps/web/src/i18n/I18nProvider.tsx b/apps/web/src/i18n/I18nProvider.tsx new file mode 100644 index 000000000..993fbf5ef --- /dev/null +++ b/apps/web/src/i18n/I18nProvider.tsx @@ -0,0 +1,112 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { useAppSettings } from "../appSettings"; +import { getNavigatorLocaleSnapshot, resolveAppLocale } from "./locale"; +import { EN_MESSAGES, loadMessages } from "./loadMessages"; +import type { AppLocalePreference, AppMessages, ResolvedAppLocale } from "./types"; + +type I18nContextValue = { + locale: AppLocalePreference; + resolvedLocale: ResolvedAppLocale; + messages: AppMessages; +}; + +const I18nContext = createContext(null); + +export function I18nProvider({ children }: { children: ReactNode }) { + const { settings } = useAppSettings(); + const [navigatorLocale, setNavigatorLocale] = useState(() => getNavigatorLocaleSnapshot()); + const [loadedLocale, setLoadedLocale] = useState("en"); + const [loadedMessages, setLoadedMessages] = useState(EN_MESSAGES); + + const resolvedLocale = useMemo( + () => resolveAppLocale(settings.locale, navigatorLocale.languages, navigatorLocale.language), + [navigatorLocale.language, navigatorLocale.languages, settings.locale], + ); + + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + + const syncNavigatorLocale = () => { + setNavigatorLocale(getNavigatorLocaleSnapshot()); + }; + + window.addEventListener("languagechange", syncNavigatorLocale); + return () => { + window.removeEventListener("languagechange", syncNavigatorLocale); + }; + }, []); + + useEffect(() => { + let cancelled = false; + + if (resolvedLocale === "en") { + setLoadedLocale("en"); + setLoadedMessages(EN_MESSAGES); + return undefined; + } + + void loadMessages(resolvedLocale).then((messages) => { + if (cancelled) { + return; + } + + setLoadedLocale(resolvedLocale); + setLoadedMessages(messages); + }); + + return () => { + cancelled = true; + }; + }, [resolvedLocale]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + document.documentElement.lang = resolvedLocale; + document.documentElement.dir = "ltr"; + }, [resolvedLocale]); + + const activeMessages = loadedLocale === resolvedLocale ? loadedMessages : EN_MESSAGES; + const contextValue = useMemo( + () => ({ + locale: settings.locale, + resolvedLocale, + messages: activeMessages, + }), + [activeMessages, resolvedLocale, settings.locale], + ); + + return ( + + { + if ("code" in error && error.code === "MISSING_TRANSLATION") { + return; + } + + console.error(error); + }} + > + {children} + + + ); +} + +export function useI18nContext(): I18nContextValue { + const context = useContext(I18nContext); + if (!context) { + throw new Error("useI18nContext must be used within an I18nProvider."); + } + + return context; +} diff --git a/apps/web/src/i18n/loadMessages.ts b/apps/web/src/i18n/loadMessages.ts new file mode 100644 index 000000000..524b7c449 --- /dev/null +++ b/apps/web/src/i18n/loadMessages.ts @@ -0,0 +1,48 @@ +import type { AppMessages, ResolvedAppLocale } from "./types"; +import enMessagesJson from "./messages/en.json"; + +export const EN_MESSAGES: AppMessages = enMessagesJson; + +export type MessageLoaderMap = Record Promise>; + +const MESSAGE_LOADERS: MessageLoaderMap = { + en: () => Promise.resolve(EN_MESSAGES), + es: () => import("./messages/es.json").then((module) => module.default), + fr: () => import("./messages/fr.json").then((module) => module.default), + "zh-CN": () => import("./messages/zh-CN.json").then((module) => module.default), +}; + +const messageCache = new Map>(); +const failedLocaleLogs = new Set(); + +function logLocaleLoadFailure(locale: ResolvedAppLocale, error: unknown) { + if (failedLocaleLogs.has(locale)) { + return; + } + + failedLocaleLogs.add(locale); + console.error(`[i18n] Failed to load locale "${locale}". Falling back to English.`, error); +} + +export async function loadMessagesFromLoaders( + locale: ResolvedAppLocale, + loaders: MessageLoaderMap, +): Promise { + try { + return await loaders[locale](); + } catch (error) { + logLocaleLoadFailure(locale, error); + return EN_MESSAGES; + } +} + +export function loadMessages(locale: ResolvedAppLocale): Promise { + const cached = messageCache.get(locale); + if (cached) { + return cached; + } + + const pending = loadMessagesFromLoaders(locale, MESSAGE_LOADERS); + messageCache.set(locale, pending); + return pending; +} diff --git a/apps/web/src/i18n/locale.test.ts b/apps/web/src/i18n/locale.test.ts new file mode 100644 index 000000000..388d831bc --- /dev/null +++ b/apps/web/src/i18n/locale.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolveAppLocale } from "./locale"; + +describe("resolveAppLocale", () => { + it("returns the explicitly selected locale when supported", () => { + expect(resolveAppLocale("fr", ["es-MX"], "en-US")).toBe("fr"); + }); + + it("resolves Spanish from system locale families", () => { + expect(resolveAppLocale("system", ["es-MX"], "en-US")).toBe("es"); + }); + + it("resolves Simplified Chinese from zh-Hans", () => { + expect(resolveAppLocale("system", ["zh-Hans-CN"], "en-US")).toBe("zh-CN"); + }); + + it("falls back to English for unsupported locales", () => { + expect(resolveAppLocale("system", ["de-DE"], "pt-BR")).toBe("en"); + }); +}); diff --git a/apps/web/src/i18n/locale.ts b/apps/web/src/i18n/locale.ts new file mode 100644 index 000000000..2d794c2c1 --- /dev/null +++ b/apps/web/src/i18n/locale.ts @@ -0,0 +1,69 @@ +import type { AppLocalePreference, ResolvedAppLocale } from "./types"; + +function matchSupportedLocale(candidate: string | null | undefined): ResolvedAppLocale | null { + if (typeof candidate !== "string") { + return null; + } + + const normalized = candidate.trim().toLowerCase(); + if (normalized.length === 0) { + return null; + } + + if (normalized === "en" || normalized.startsWith("en-")) { + return "en"; + } + + if (normalized === "es" || normalized.startsWith("es-")) { + return "es"; + } + + if (normalized === "fr" || normalized.startsWith("fr-")) { + return "fr"; + } + + if ( + normalized === "zh" || + normalized === "zh-cn" || + normalized === "zh-sg" || + normalized === "zh-hans" || + normalized.startsWith("zh-hans-") + ) { + return "zh-CN"; + } + + return null; +} + +export function resolveAppLocale( + localePreference: AppLocalePreference, + navigatorLanguages: readonly string[] = [], + navigatorLanguage?: string | null, +): ResolvedAppLocale { + if (localePreference !== "system") { + return matchSupportedLocale(localePreference) ?? "en"; + } + + for (const candidate of [...navigatorLanguages, navigatorLanguage]) { + const resolved = matchSupportedLocale(candidate); + if (resolved) { + return resolved; + } + } + + return "en"; +} + +export function getNavigatorLocaleSnapshot(): { + language: string | null; + languages: readonly string[]; +} { + if (typeof navigator === "undefined") { + return { language: null, languages: [] }; + } + + return { + language: navigator.language ?? null, + languages: Array.isArray(navigator.languages) ? [...navigator.languages] : [], + }; +} diff --git a/apps/web/src/i18n/messages.test.ts b/apps/web/src/i18n/messages.test.ts new file mode 100644 index 000000000..2cd00c5ed --- /dev/null +++ b/apps/web/src/i18n/messages.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import enMessages from "./messages/en.json"; +import esMessages from "./messages/es.json"; +import frMessages from "./messages/fr.json"; +import zhCNMessages from "./messages/zh-CN.json"; + +const catalogs = { + en: enMessages, + es: esMessages, + fr: frMessages, + "zh-CN": zhCNMessages, +} as const; + +describe("message catalogs", () => { + it("keep every locale in sync with the English source catalog", () => { + const englishKeys = Object.keys(catalogs.en).toSorted(); + + for (const [locale, messages] of Object.entries(catalogs)) { + expect(Object.keys(messages).toSorted(), `catalog keys for ${locale}`).toEqual(englishKeys); + } + }); +}); diff --git a/apps/web/src/i18n/messages/en.json b/apps/web/src/i18n/messages/en.json new file mode 100644 index 000000000..24552ad8c --- /dev/null +++ b/apps/web/src/i18n/messages/en.json @@ -0,0 +1,260 @@ +{ + "common.actions.add": "Add", + "common.actions.back": "Back", + "common.actions.cancel": "Cancel", + "common.actions.discard": "Discard", + "common.actions.getStarted": "Get Started", + "common.actions.next": "Next", + "common.actions.openFile": "Open file", + "common.actions.opening": "Opening...", + "common.actions.reloadApp": "Reload app", + "common.actions.restoreDefaults": "Restore defaults", + "common.actions.showLess": "Show less", + "common.actions.skip": "Skip", + "common.actions.tryAgain": "Try again", + "common.custom": "Custom", + "common.dark": "Dark", + "common.default": "Default", + "common.light": "Light", + "common.local": "Local", + "common.newWorktree": "New worktree", + "common.system": "System", + "common.systemDefault": "System default", + "common.unknownError": "Unknown error", + "customThemeDialog.action.apply": "Apply Theme", + "customThemeDialog.action.loading": "Loading...", + "customThemeDialog.action.parse": "Parse Theme", + "customThemeDialog.color.accent": "Accent", + "customThemeDialog.color.background": "Background", + "customThemeDialog.color.border": "Border", + "customThemeDialog.color.card": "Card", + "customThemeDialog.color.destructive": "Destructive", + "customThemeDialog.color.muted": "Muted", + "customThemeDialog.color.primary": "Primary", + "customThemeDialog.color.secondary": "Secondary", + "customThemeDialog.description": "Paste CSS or a tweakcn.com theme URL below.", + "customThemeDialog.error.parseFailed": "Failed to parse theme.", + "customThemeDialog.placeholder": "Paste theme CSS, JSON, or a tweakcn.com URL...\n\nExample:\nhttps://tweakcn.com/themes/catppuccin\n\nor\n\n:root {\n --background: oklch(1 0 0);\n --primary: oklch(0.58 0.2 277);\n ...\n}", + "customThemeDialog.preview.title": "Preview", + "customThemeDialog.preview.titleWithName": "Preview - {name}", + "customThemeDialog.title": "Import Custom Theme", + "customThemeDialog.tokens.font": "Font: {value}", + "customThemeDialog.tokens.mono": "Mono: {value}", + "customThemeDialog.tokens.radius": "Radius: {value}", + "customThemeDialog.urlBadge": "URL", + "customThemeDialog.variablesCount": "{lightCount} light + {darkCount} dark variables", + "envEditor.action.saving": "Saving...", + "envEditor.blankRowHint": "Blank rows are ignored until they contain a key.", + "envEditor.encryptionNotice": "Values are encrypted at rest before they are written to the local state database.", + "envEditor.keyLabel": "Key {index}", + "envEditor.keyPlaceholder": "API_KEY", + "envEditor.removeAria": "Remove variable", + "envEditor.saveError": "Failed to save environment variables.", + "envEditor.savedCount": "{count}/{max} saved variables", + "envEditor.scopeHint": "This value will be available to launches in the matching scope.", + "envEditor.validation.keyDuplicate": "This variable name is duplicated.", + "envEditor.validation.keyPattern": "Use letters, numbers, and underscores, starting with a letter or underscore.", + "envEditor.validation.keyTooLong": "Keys must be {max} characters or less.", + "envEditor.validation.nameRequired": "A variable name is required.", + "envEditor.validation.valueTooLong": "Values must be {max} characters or less.", + "envEditor.valueLabel": "Value", + "envEditor.valuePlaceholder": "secret value", + "mobilePairing.bridgeUnavailable": "Mobile pairing bridge is unavailable.", + "mobilePairing.clearFailed": "Could not clear pairing.", + "mobilePairing.clearSaved": "Clear saved pairing", + "mobilePairing.description": "Paste a pairing link like okcode://pair?server=…&token=… or a server URL that includes ?token=….", + "mobilePairing.pair": "Pair device", + "mobilePairing.pairFailed": "Could not pair this device.", + "mobilePairing.pairing": "Pairing...", + "mobilePairing.placeholder": "okcode://pair?server=…&token=…", + "mobilePairing.title": "Pair this device", + "onboarding.actions.getStarted": "Get Started", + "onboarding.actions.next": "Next", + "onboarding.actions.skip": "Skip", + "onboarding.progress.label": "Onboarding progress", + "onboarding.progress.stepAria": "Go to step {index} of {total}: {title}", + "onboarding.step.approvals.description": "You decide what gets executed. The agent asks for your approval before making changes, so nothing happens without your say-so.", + "onboarding.step.approvals.detail1": "Approve, request changes, or cancel any proposed action", + "onboarding.step.approvals.detail2": "Switch between full-access and approval-required modes per thread", + "onboarding.step.approvals.detail3": "Review pending file changes before they're applied", + "onboarding.step.approvals.title": "Stay in Control", + "onboarding.step.chat.description": "Chat with AI coding agents in real time. Ask questions, request changes, or let the agent drive entire features.", + "onboarding.step.chat.detail1": "Choose between multiple providers - Codex and Claude", + "onboarding.step.chat.detail2": "Stream responses in real time as the agent works", + "onboarding.step.chat.detail3": "Attach images and terminal context directly in your prompts", + "onboarding.step.chat.title": "AI-Powered Conversations", + "onboarding.step.diff.description": "Inspect every code change the agent makes with a built-in diff viewer before accepting anything.", + "onboarding.step.diff.detail1": "Inline and side-by-side diff views with syntax highlighting", + "onboarding.step.diff.detail2": "Accept or reject changes per-file with a single click", + "onboarding.step.diff.detail3": "Word-level highlighting shows exactly what changed", + "onboarding.step.diff.title": "Review Changes Side-by-Side", + "onboarding.step.getStarted.description": "You're ready to start building. Here are a few shortcuts to help you move fast.", + "onboarding.step.getStarted.detail1": "Press Cmd+N (or Ctrl+N) to create a new thread instantly", + "onboarding.step.getStarted.detail2": "Use the sidebar to switch between projects and threads", + "onboarding.step.getStarted.detail3": "Open Settings to customize models, themes, and keybindings", + "onboarding.step.getStarted.title": "You're All Set!", + "onboarding.step.git.description": "Every thread can run in its own git worktree, keeping your main branch safe while the agent experiments freely.", + "onboarding.step.git.detail1": "New threads automatically create isolated worktrees", + "onboarding.step.git.detail2": "Switch branches, create PRs, and manage worktrees from the toolbar", + "onboarding.step.git.detail3": "Link threads to existing pull requests for focused code review", + "onboarding.step.git.title": "Built-in Git Workflows", + "onboarding.step.plan.description": "Switch to Plan mode and let the agent outline a structured implementation strategy before writing a single line of code.", + "onboarding.step.plan.detail1": "Step-by-step plans with status tracking as work progresses", + "onboarding.step.plan.detail2": "Review, copy, or export plans as Markdown", + "onboarding.step.plan.detail3": "Click \"Implement Plan\" to kick off execution in a new thread", + "onboarding.step.plan.title": "AI-Generated Plans", + "onboarding.step.terminal.description": "A full terminal lives inside every thread - run commands, see output, and feed context back to the agent.", + "onboarding.step.terminal.detail1": "Up to four terminal tabs per thread for parallel workflows", + "onboarding.step.terminal.detail2": "Select terminal output and add it directly to your prompt", + "onboarding.step.terminal.detail3": "Track running subprocesses with live activity indicators", + "onboarding.step.terminal.title": "Integrated Terminal", + "onboarding.step.welcome.description": "Your AI-powered coding companion. Let's take a quick tour of the features that will supercharge your workflow.", + "onboarding.step.welcome.detail1": "Work alongside AI agents that read, write, and reason about your code", + "onboarding.step.welcome.detail2": "Every conversation runs in an isolated git worktree by default", + "onboarding.step.welcome.detail3": "This tour takes about a minute - you can skip at any time", + "onboarding.step.welcome.title": "Welcome to OK Code", + "root.error.detailsUnavailable": "No additional error details are available.", + "root.error.hideDetails": "Hide error details", + "root.error.showDetails": "Show error details", + "root.error.title": "Something went wrong.", + "root.error.unexpected": "An unexpected router error occurred.", + "root.loading.connectingServer": "Connecting to {appName} server...", + "root.loading.restoringMobilePairing": "Restoring mobile pairing...", + "root.toast.invalidKeybindings.action": "Open keybindings.json", + "root.toast.invalidKeybindings.title": "Invalid keybindings configuration", + "root.toast.keybindingsUpdated.description": "Keybindings configuration reloaded successfully.", + "root.toast.keybindingsUpdated.title": "Keybindings updated", + "root.toast.openKeybindingsFailed.title": "Unable to open keybindings file", + "root.toast.unknownFileOpenError": "Unknown error opening file.", + "settings.advanced.keybindings.description": "Open the persisted `keybindings.json` file to edit advanced bindings directly.", + "settings.advanced.keybindings.noEditors": "No available editors found.", + "settings.advanced.keybindings.openFile": "Open file", + "settings.advanced.keybindings.opensInPreferredEditor": "Opens in your preferred editor.", + "settings.advanced.keybindings.opening": "Opening...", + "settings.advanced.keybindings.resolvingPath": "Resolving keybindings path...", + "settings.advanced.keybindings.title": "Keybindings", + "settings.advanced.providerInstalls.claude.binaryDescription": "Leave blank to use claude from your PATH. Authentication uses claude auth login.", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Anthropic binary path", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude binary path", + "settings.advanced.providerInstalls.codex.binaryDescription": "Leave blank to use codex from your PATH. Authentication normally uses codex login unless your Codex config points at a custom model provider.", + "settings.advanced.providerInstalls.codex.binaryPathLabel": "Codex binary path", + "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Codex binary path", + "settings.advanced.providerInstalls.codex.homeDescription": "Optional custom Codex home and config directory.", + "settings.advanced.providerInstalls.codex.homePathLabel": "CODEX_HOME path", + "settings.advanced.providerInstalls.codex.homePlaceholder": "CODEX_HOME", + "settings.advanced.providerInstalls.customBadge": "Custom", + "settings.advanced.providerInstalls.description": "Override the CLI binaries and auth homes used for new sessions.", + "settings.advanced.providerInstalls.title": "Provider installs", + "settings.advanced.version.description": "Current application version.", + "settings.advanced.version.title": "Version", + "settings.changed.assistantOutput": "Assistant output", + "settings.changed.borderRadius": "Border radius", + "settings.changed.colorTheme": "Color theme", + "settings.changed.customModels": "Custom models", + "settings.changed.deleteConfirmation": "Delete confirmation", + "settings.changed.diffWordWrap": "Diff line wrapping", + "settings.changed.font": "Font", + "settings.changed.fontFamily": "Font family", + "settings.changed.gitWritingModel": "Git writing model", + "settings.changed.language": "Language", + "settings.changed.newThreadMode": "New thread mode", + "settings.changed.openLinksExternally": "Open links externally", + "settings.changed.providerInstalls": "Provider installs", + "settings.changed.theme": "Theme", + "settings.changed.timeFormat": "Time format", + "settings.environment.global.description": "Available to every provider session, terminal, Git command, and health check launched on this machine.", + "settings.environment.global.editorDescription": "Global values are encrypted locally and merged into every runtime environment.", + "settings.environment.global.empty": "No global variables saved yet.", + "settings.environment.global.failed": "Failed to load saved variables: {error}", + "settings.environment.global.loading": "Loading saved variables...", + "settings.environment.global.save": "Save global", + "settings.environment.global.saved": "{count, plural, one {# saved variable} other {# saved variables}}", + "settings.environment.global.title": "Global variables", + "settings.environment.project.description": "Saved per project and merged on top of the global set when that project launches a provider, terminal, or helper command.", + "settings.environment.project.editorDescription": "Open or create a project to edit project variables.", + "settings.environment.project.editorDescriptionWithProject": "Project values override global values for {projectName}.", + "settings.environment.project.empty": "No project variables saved yet.", + "settings.environment.project.loading": "Loading project variables...", + "settings.environment.project.noProject": "Open a project to edit project variables.", + "settings.environment.project.noProjects": "No projects available.", + "settings.environment.project.save": "Save project", + "settings.environment.project.saveErrorNoProject": "Select a project before saving project variables.", + "settings.environment.project.selectPlaceholder": "Select project", + "settings.environment.project.title": "Project variables", + "settings.general.assistantOutput.aria": "Stream assistant messages", + "settings.general.assistantOutput.description": "Show token-by-token output while a response is in progress.", + "settings.general.assistantOutput.title": "Assistant output", + "settings.general.borderRadius.aria": "Border radius", + "settings.general.borderRadius.description": "Adjust the corner roundness of UI elements.", + "settings.general.borderRadius.title": "Border radius", + "settings.general.colorTheme.aria": "Color theme", + "settings.general.colorTheme.description": "Pick a color palette for light and dark modes.", + "settings.general.colorTheme.importAria": "Import custom theme", + "settings.general.colorTheme.importTooltip": "Import from tweakcn.com", + "settings.general.colorTheme.option.custom": "Custom", + "settings.general.colorTheme.option.default": "Default", + "settings.general.colorTheme.title": "Color theme", + "settings.general.deleteConfirmation.aria": "Confirm thread deletion", + "settings.general.deleteConfirmation.description": "Ask before deleting a thread and its chat history.", + "settings.general.deleteConfirmation.title": "Delete confirmation", + "settings.general.diffWordWrap.aria": "Wrap diff lines by default", + "settings.general.diffWordWrap.description": "Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session.", + "settings.general.diffWordWrap.title": "Diff line wrapping", + "settings.general.font.description": "Choose the typeface for the interface.", + "settings.general.font.title": "Font", + "settings.general.fontFamilyOverride.aria": "Font family override", + "settings.general.fontFamilyOverride.description": "Override the UI font. Use any Google Font name.", + "settings.general.fontFamilyOverride.placeholder": "e.g. Inter, sans-serif", + "settings.general.fontFamilyOverride.title": "Font family", + "settings.general.language.aria": "Language preference", + "settings.general.language.description": "Choose the language used for the app interface.", + "settings.general.language.option.en": "English", + "settings.general.language.option.es": "Español", + "settings.general.language.option.fr": "Français", + "settings.general.language.option.system": "System default", + "settings.general.language.option.zh-CN": "简体中文", + "settings.general.language.title": "Language", + "settings.general.newThreads.aria": "Default thread mode", + "settings.general.newThreads.description": "Pick the default workspace mode for newly created draft threads.", + "settings.general.newThreads.title": "New threads", + "settings.general.openLinksExternally.aria": "Open links externally", + "settings.general.openLinksExternally.description": "Open terminal URLs in your default browser instead of the embedded preview panel.", + "settings.general.openLinksExternally.title": "Open links externally", + "settings.general.sidebarOpacity.aria": "Sidebar opacity", + "settings.general.sidebarOpacity.description": "Adjust the transparency of the side panel and project list.", + "settings.general.sidebarOpacity.title": "Sidebar opacity", + "settings.general.theme.aria": "Theme preference", + "settings.general.theme.description": "Choose how OK Code looks across the app.", + "settings.general.theme.title": "Theme", + "settings.general.timeFormat.aria": "Timestamp format", + "settings.general.timeFormat.description": "System default follows your browser or OS clock preference.", + "settings.general.timeFormat.option.12Hour": "12-hour", + "settings.general.timeFormat.option.24Hour": "24-hour", + "settings.general.timeFormat.option.locale": "System default", + "settings.general.timeFormat.title": "Time format", + "settings.general.windowOpacity.aria": "Window opacity", + "settings.general.windowOpacity.description": "Adjust the transparency of the entire application window.", + "settings.general.windowOpacity.title": "Window opacity", + "settings.models.customModels.addButton": "Add", + "settings.models.customModels.description": "Add custom model slugs for Codex or Anthropic. The chat picker groups models by provider.", + "settings.models.customModels.providerAria": "Custom model provider", + "settings.models.customModels.removeAria": "Remove {slug}", + "settings.models.customModels.showMore": "Show more ({count})", + "settings.models.customModels.title": "Custom models", + "settings.models.customModels.validation.alreadySaved": "That custom model is already saved.", + "settings.models.customModels.validation.builtIn": "That model is already built in.", + "settings.models.customModels.validation.enterSlug": "Enter a model slug.", + "settings.models.customModels.validation.tooLong": "Model slugs must be {max} characters or less.", + "settings.models.gitWritingModel.aria": "Git text generation model", + "settings.models.gitWritingModel.description": "Used for generated commit messages, PR titles, and branch names.", + "settings.models.gitWritingModel.title": "Git writing model", + "settings.reset.aria": "Reset {label} to default", + "settings.reset.tooltip": "Reset to default", + "settings.restoreDialog.description": "This will reset: {changes}.", + "settings.restoreDialog.title": "Restore default settings?", + "settings.section.advanced": "Advanced", + "settings.section.environment": "Environment", + "settings.section.general": "General", + "settings.section.models": "Models", + "settings.title": "Settings" +} diff --git a/apps/web/src/i18n/messages/es.json b/apps/web/src/i18n/messages/es.json new file mode 100644 index 000000000..e09e58023 --- /dev/null +++ b/apps/web/src/i18n/messages/es.json @@ -0,0 +1,260 @@ +{ + "common.actions.add": "Agregar", + "common.actions.back": "Atrás", + "common.actions.cancel": "Cancelar", + "common.actions.discard": "Descartar", + "common.actions.getStarted": "Comenzar", + "common.actions.next": "Siguiente", + "common.actions.openFile": "Abrir archivo", + "common.actions.opening": "Abriendo...", + "common.actions.reloadApp": "Recargar app", + "common.actions.restoreDefaults": "Restaurar valores predeterminados", + "common.actions.showLess": "Mostrar menos", + "common.actions.skip": "Omitir", + "common.actions.tryAgain": "Reintentar", + "common.custom": "Personalizado", + "common.dark": "Oscuro", + "common.default": "Predeterminado", + "common.light": "Claro", + "common.local": "Local", + "common.newWorktree": "Nuevo worktree", + "common.system": "Sistema", + "common.systemDefault": "Predeterminado del sistema", + "common.unknownError": "Error desconocido", + "customThemeDialog.action.apply": "Aplicar tema", + "customThemeDialog.action.loading": "Cargando...", + "customThemeDialog.action.parse": "Analizar tema", + "customThemeDialog.color.accent": "Acento", + "customThemeDialog.color.background": "Fondo", + "customThemeDialog.color.border": "Borde", + "customThemeDialog.color.card": "Tarjeta", + "customThemeDialog.color.destructive": "Destructivo", + "customThemeDialog.color.muted": "Atenuado", + "customThemeDialog.color.primary": "Primario", + "customThemeDialog.color.secondary": "Secundario", + "customThemeDialog.description": "Pega CSS o una URL de tema de tweakcn.com abajo.", + "customThemeDialog.error.parseFailed": "No se pudo analizar el tema.", + "customThemeDialog.placeholder": "Pega CSS del tema, JSON o una URL de tweakcn.com...\n\nEjemplo:\nhttps://tweakcn.com/themes/catppuccin\n\nu\n\n:root {\n --background: oklch(1 0 0);\n --primary: oklch(0.58 0.2 277);\n ...\n}", + "customThemeDialog.preview.title": "Vista previa", + "customThemeDialog.preview.titleWithName": "Vista previa - {name}", + "customThemeDialog.title": "Importar tema personalizado", + "customThemeDialog.tokens.font": "Fuente: {value}", + "customThemeDialog.tokens.mono": "Mono: {value}", + "customThemeDialog.tokens.radius": "Radio: {value}", + "customThemeDialog.urlBadge": "URL", + "customThemeDialog.variablesCount": "{lightCount} variables claras + {darkCount} variables oscuras", + "envEditor.action.saving": "Guardando...", + "envEditor.blankRowHint": "Las filas vacías se ignoran hasta que contengan una clave.", + "envEditor.encryptionNotice": "Los valores se cifran en reposo antes de escribirse en la base de datos local de estado.", + "envEditor.keyLabel": "Clave {index}", + "envEditor.keyPlaceholder": "API_KEY", + "envEditor.removeAria": "Eliminar variable", + "envEditor.saveError": "No se pudieron guardar las variables de entorno.", + "envEditor.savedCount": "{count}/{max} variables guardadas", + "envEditor.scopeHint": "Este valor estará disponible para los lanzamientos dentro del alcance correspondiente.", + "envEditor.validation.keyDuplicate": "Este nombre de variable está duplicado.", + "envEditor.validation.keyPattern": "Usa letras, números y guiones bajos, comenzando con una letra o un guion bajo.", + "envEditor.validation.keyTooLong": "Las claves deben tener como máximo {max} caracteres.", + "envEditor.validation.nameRequired": "Se requiere un nombre de variable.", + "envEditor.validation.valueTooLong": "Los valores deben tener como máximo {max} caracteres.", + "envEditor.valueLabel": "Valor", + "envEditor.valuePlaceholder": "valor secreto", + "mobilePairing.bridgeUnavailable": "El puente de emparejamiento móvil no está disponible.", + "mobilePairing.clearFailed": "No se pudo borrar el emparejamiento.", + "mobilePairing.clearSaved": "Borrar emparejamiento guardado", + "mobilePairing.description": "Pega un enlace de emparejamiento como okcode://pair?server=…&token=… o una URL del servidor que incluya ?token=….", + "mobilePairing.pair": "Emparejar dispositivo", + "mobilePairing.pairFailed": "No se pudo emparejar este dispositivo.", + "mobilePairing.pairing": "Emparejando...", + "mobilePairing.placeholder": "okcode://pair?server=…&token=…", + "mobilePairing.title": "Emparejar este dispositivo", + "onboarding.actions.getStarted": "Comenzar", + "onboarding.actions.next": "Siguiente", + "onboarding.actions.skip": "Omitir", + "onboarding.progress.label": "Progreso de introducción", + "onboarding.progress.stepAria": "Ir al paso {index} de {total}: {title}", + "onboarding.step.approvals.description": "Tú decides qué se ejecuta. El agente pide tu aprobación antes de hacer cambios, así que nada ocurre sin tu visto bueno.", + "onboarding.step.approvals.detail1": "Aprueba, solicita cambios o cancela cualquier acción propuesta", + "onboarding.step.approvals.detail2": "Cambia entre modos de acceso total y aprobación requerida por hilo", + "onboarding.step.approvals.detail3": "Revisa los cambios pendientes en archivos antes de aplicarlos", + "onboarding.step.approvals.title": "Mantén el control", + "onboarding.step.chat.description": "Chatea con agentes de codificación con IA en tiempo real. Haz preguntas, solicita cambios o deja que el agente lleve funciones completas.", + "onboarding.step.chat.detail1": "Elige entre varios proveedores: Codex y Claude", + "onboarding.step.chat.detail2": "Recibe respuestas en tiempo real mientras el agente trabaja", + "onboarding.step.chat.detail3": "Adjunta imágenes y contexto del terminal directamente en tus prompts", + "onboarding.step.chat.title": "Conversaciones con IA", + "onboarding.step.diff.description": "Inspecciona cada cambio de código que haga el agente con un visor de diff integrado antes de aceptar nada.", + "onboarding.step.diff.detail1": "Vistas diff en línea y lado a lado con resaltado de sintaxis", + "onboarding.step.diff.detail2": "Acepta o rechaza cambios por archivo con un solo clic", + "onboarding.step.diff.detail3": "El resaltado a nivel de palabra muestra exactamente qué cambió", + "onboarding.step.diff.title": "Revisa cambios lado a lado", + "onboarding.step.getStarted.description": "Ya puedes empezar a construir. Aquí tienes algunos atajos para moverte rápido.", + "onboarding.step.getStarted.detail1": "Pulsa Cmd+N (o Ctrl+N) para crear un hilo nuevo al instante", + "onboarding.step.getStarted.detail2": "Usa la barra lateral para alternar entre proyectos e hilos", + "onboarding.step.getStarted.detail3": "Abre Configuración para personalizar modelos, temas y atajos", + "onboarding.step.getStarted.title": "¡Todo listo!", + "onboarding.step.git.description": "Cada hilo puede ejecutarse en su propio worktree de git, manteniendo tu rama principal segura mientras el agente experimenta libremente.", + "onboarding.step.git.detail1": "Los hilos nuevos crean worktrees aislados automáticamente", + "onboarding.step.git.detail2": "Cambia ramas, crea PR y administra worktrees desde la barra de herramientas", + "onboarding.step.git.detail3": "Vincula hilos a pull requests existentes para revisiones de código enfocadas", + "onboarding.step.git.title": "Flujos de Git integrados", + "onboarding.step.plan.description": "Cambia al modo Plan y deja que el agente esboce una estrategia de implementación estructurada antes de escribir una sola línea de código.", + "onboarding.step.plan.detail1": "Planes paso a paso con seguimiento de estado mientras avanza el trabajo", + "onboarding.step.plan.detail2": "Revisa, copia o exporta planes como Markdown", + "onboarding.step.plan.detail3": "Haz clic en \"Implement Plan\" para iniciar la ejecución en un hilo nuevo", + "onboarding.step.plan.title": "Planes generados por IA", + "onboarding.step.terminal.description": "Un terminal completo vive dentro de cada hilo: ejecuta comandos, ve la salida y devuelve contexto al agente.", + "onboarding.step.terminal.detail1": "Hasta cuatro pestañas de terminal por hilo para flujos paralelos", + "onboarding.step.terminal.detail2": "Selecciona la salida del terminal y añádela directamente a tu prompt", + "onboarding.step.terminal.detail3": "Sigue subprocesos en ejecución con indicadores de actividad en vivo", + "onboarding.step.terminal.title": "Terminal integrado", + "onboarding.step.welcome.description": "Tu compañero de programación impulsado por IA. Hagamos un recorrido rápido por las funciones que potenciarán tu flujo de trabajo.", + "onboarding.step.welcome.detail1": "Trabaja junto a agentes de IA que leen, escriben y razonan sobre tu código", + "onboarding.step.welcome.detail2": "Cada conversación se ejecuta por defecto en un worktree de git aislado", + "onboarding.step.welcome.detail3": "Este recorrido tarda alrededor de un minuto; puedes omitirlo en cualquier momento", + "onboarding.step.welcome.title": "Bienvenido a OK Code", + "root.error.detailsUnavailable": "No hay detalles adicionales del error disponibles.", + "root.error.hideDetails": "Ocultar detalles del error", + "root.error.showDetails": "Mostrar detalles del error", + "root.error.title": "Algo salió mal.", + "root.error.unexpected": "Se produjo un error inesperado del enrutador.", + "root.loading.connectingServer": "Conectando con el servidor de {appName}...", + "root.loading.restoringMobilePairing": "Restaurando el emparejamiento móvil...", + "root.toast.invalidKeybindings.action": "Abrir keybindings.json", + "root.toast.invalidKeybindings.title": "Configuración de atajos inválida", + "root.toast.keybindingsUpdated.description": "La configuración de atajos se recargó correctamente.", + "root.toast.keybindingsUpdated.title": "Atajos actualizados", + "root.toast.openKeybindingsFailed.title": "No se pudo abrir el archivo de atajos", + "root.toast.unknownFileOpenError": "Error desconocido al abrir el archivo.", + "settings.advanced.keybindings.description": "Abre el archivo persistido `keybindings.json` para editar directamente los atajos avanzados.", + "settings.advanced.keybindings.noEditors": "No se encontraron editores disponibles.", + "settings.advanced.keybindings.openFile": "Abrir archivo", + "settings.advanced.keybindings.opensInPreferredEditor": "Se abre en tu editor preferido.", + "settings.advanced.keybindings.opening": "Abriendo...", + "settings.advanced.keybindings.resolvingPath": "Resolviendo la ruta de keybindings...", + "settings.advanced.keybindings.title": "Atajos", + "settings.advanced.providerInstalls.claude.binaryDescription": "Déjalo vacío para usar claude desde tu PATH. La autenticación usa claude auth login.", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Ruta del binario de Anthropic", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Ruta del binario de Claude", + "settings.advanced.providerInstalls.codex.binaryDescription": "Déjalo vacío para usar codex desde tu PATH. La autenticación normalmente usa codex login, salvo que tu configuración de Codex apunte a un proveedor de modelos personalizado.", + "settings.advanced.providerInstalls.codex.binaryPathLabel": "Ruta del binario de Codex", + "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Ruta del binario de Codex", + "settings.advanced.providerInstalls.codex.homeDescription": "Directorio opcional de inicio y configuración personalizado de Codex.", + "settings.advanced.providerInstalls.codex.homePathLabel": "Ruta de CODEX_HOME", + "settings.advanced.providerInstalls.codex.homePlaceholder": "CODEX_HOME", + "settings.advanced.providerInstalls.customBadge": "Personalizado", + "settings.advanced.providerInstalls.description": "Sobrescribe los binarios CLI y los homes de autenticación usados para sesiones nuevas.", + "settings.advanced.providerInstalls.title": "Instalaciones de proveedores", + "settings.advanced.version.description": "Versión actual de la aplicación.", + "settings.advanced.version.title": "Versión", + "settings.changed.assistantOutput": "Salida del asistente", + "settings.changed.borderRadius": "Radio del borde", + "settings.changed.colorTheme": "Tema de color", + "settings.changed.customModels": "Modelos personalizados", + "settings.changed.deleteConfirmation": "Confirmación de eliminación", + "settings.changed.diffWordWrap": "Ajuste de líneas en diff", + "settings.changed.font": "Fuente", + "settings.changed.fontFamily": "Familia tipográfica", + "settings.changed.gitWritingModel": "Modelo de escritura de Git", + "settings.changed.language": "Idioma", + "settings.changed.newThreadMode": "Modo de hilo nuevo", + "settings.changed.openLinksExternally": "Abrir enlaces externamente", + "settings.changed.providerInstalls": "Instalaciones de proveedores", + "settings.changed.theme": "Tema", + "settings.changed.timeFormat": "Formato de hora", + "settings.environment.global.description": "Disponible para cada sesión de proveedor, terminal, comando Git y verificación de salud lanzados en esta máquina.", + "settings.environment.global.editorDescription": "Los valores globales se cifran localmente y se combinan en cada entorno de ejecución.", + "settings.environment.global.empty": "Aún no hay variables globales guardadas.", + "settings.environment.global.failed": "No se pudieron cargar las variables guardadas: {error}", + "settings.environment.global.loading": "Cargando variables guardadas...", + "settings.environment.global.save": "Guardar global", + "settings.environment.global.saved": "{count, plural, one {# variable guardada} other {# variables guardadas}}", + "settings.environment.global.title": "Variables globales", + "settings.environment.project.description": "Se guardan por proyecto y se combinan sobre el conjunto global cuando ese proyecto inicia un proveedor, terminal o comando auxiliar.", + "settings.environment.project.editorDescription": "Abre o crea un proyecto para editar las variables del proyecto.", + "settings.environment.project.editorDescriptionWithProject": "Los valores del proyecto sobrescriben los globales para {projectName}.", + "settings.environment.project.empty": "Aún no hay variables del proyecto guardadas.", + "settings.environment.project.loading": "Cargando variables del proyecto...", + "settings.environment.project.noProject": "Abre un proyecto para editar las variables del proyecto.", + "settings.environment.project.noProjects": "No hay proyectos disponibles.", + "settings.environment.project.save": "Guardar proyecto", + "settings.environment.project.saveErrorNoProject": "Selecciona un proyecto antes de guardar las variables del proyecto.", + "settings.environment.project.selectPlaceholder": "Seleccionar proyecto", + "settings.environment.project.title": "Variables del proyecto", + "settings.general.assistantOutput.aria": "Transmitir mensajes del asistente", + "settings.general.assistantOutput.description": "Muestra salida token por token mientras una respuesta está en progreso.", + "settings.general.assistantOutput.title": "Salida del asistente", + "settings.general.borderRadius.aria": "Radio del borde", + "settings.general.borderRadius.description": "Ajusta la redondez de las esquinas de los elementos de UI.", + "settings.general.borderRadius.title": "Radio del borde", + "settings.general.colorTheme.aria": "Tema de color", + "settings.general.colorTheme.description": "Elige una paleta de colores para los modos claro y oscuro.", + "settings.general.colorTheme.importAria": "Importar tema personalizado", + "settings.general.colorTheme.importTooltip": "Importar desde tweakcn.com", + "settings.general.colorTheme.option.custom": "Personalizado", + "settings.general.colorTheme.option.default": "Predeterminado", + "settings.general.colorTheme.title": "Tema de color", + "settings.general.deleteConfirmation.aria": "Confirmar eliminación del hilo", + "settings.general.deleteConfirmation.description": "Pregunta antes de eliminar un hilo y su historial de chat.", + "settings.general.deleteConfirmation.title": "Confirmación de eliminación", + "settings.general.diffWordWrap.aria": "Ajustar líneas del diff por defecto", + "settings.general.diffWordWrap.description": "Define el estado de ajuste predeterminado cuando se abre el panel de diff. El control dentro del panel solo afecta la sesión actual del diff.", + "settings.general.diffWordWrap.title": "Ajuste de líneas en diff", + "settings.general.font.description": "Elige la tipografía de la interfaz.", + "settings.general.font.title": "Fuente", + "settings.general.fontFamilyOverride.aria": "Sobrescritura de familia tipográfica", + "settings.general.fontFamilyOverride.description": "Sobrescribe la fuente de la UI. Usa cualquier nombre de Google Font.", + "settings.general.fontFamilyOverride.placeholder": "p. ej. Inter, sans-serif", + "settings.general.fontFamilyOverride.title": "Familia tipográfica", + "settings.general.language.aria": "Preferencia de idioma", + "settings.general.language.description": "Elige el idioma usado para la interfaz de la app.", + "settings.general.language.option.en": "English", + "settings.general.language.option.es": "Español", + "settings.general.language.option.fr": "Français", + "settings.general.language.option.system": "Predeterminado del sistema", + "settings.general.language.option.zh-CN": "简体中文", + "settings.general.language.title": "Idioma", + "settings.general.newThreads.aria": "Modo predeterminado del hilo", + "settings.general.newThreads.description": "Elige el modo de espacio de trabajo predeterminado para los nuevos borradores de hilos.", + "settings.general.newThreads.title": "Hilos nuevos", + "settings.general.openLinksExternally.aria": "Abrir enlaces externamente", + "settings.general.openLinksExternally.description": "Abre las URL del terminal en tu navegador predeterminado en lugar del panel de vista previa integrado.", + "settings.general.openLinksExternally.title": "Abrir enlaces externamente", + "settings.general.sidebarOpacity.aria": "Opacidad de la barra lateral", + "settings.general.sidebarOpacity.description": "Ajusta la transparencia del panel lateral y la lista de proyectos.", + "settings.general.sidebarOpacity.title": "Opacidad de la barra lateral", + "settings.general.theme.aria": "Preferencia de tema", + "settings.general.theme.description": "Elige cómo se ve OK Code en toda la app.", + "settings.general.theme.title": "Tema", + "settings.general.timeFormat.aria": "Formato de marca de tiempo", + "settings.general.timeFormat.description": "El valor predeterminado del sistema sigue la preferencia de reloj de tu navegador o sistema operativo.", + "settings.general.timeFormat.option.12Hour": "12 horas", + "settings.general.timeFormat.option.24Hour": "24 horas", + "settings.general.timeFormat.option.locale": "Predeterminado del sistema", + "settings.general.timeFormat.title": "Formato de hora", + "settings.general.windowOpacity.aria": "Opacidad de la ventana", + "settings.general.windowOpacity.description": "Ajusta la transparencia de toda la ventana de la aplicación.", + "settings.general.windowOpacity.title": "Opacidad de la ventana", + "settings.models.customModels.addButton": "Agregar", + "settings.models.customModels.description": "Añade slugs de modelos personalizados para Codex o Anthropic. El selector de chat agrupa los modelos por proveedor.", + "settings.models.customModels.providerAria": "Proveedor de modelo personalizado", + "settings.models.customModels.removeAria": "Eliminar {slug}", + "settings.models.customModels.showMore": "Mostrar más ({count})", + "settings.models.customModels.title": "Modelos personalizados", + "settings.models.customModels.validation.alreadySaved": "Ese modelo personalizado ya está guardado.", + "settings.models.customModels.validation.builtIn": "Ese modelo ya está integrado.", + "settings.models.customModels.validation.enterSlug": "Introduce un slug de modelo.", + "settings.models.customModels.validation.tooLong": "Los slugs del modelo deben tener como máximo {max} caracteres.", + "settings.models.gitWritingModel.aria": "Modelo de generación de texto para Git", + "settings.models.gitWritingModel.description": "Se usa para mensajes de commit, títulos de PR y nombres de ramas generados.", + "settings.models.gitWritingModel.title": "Modelo de escritura de Git", + "settings.reset.aria": "Restablecer {label} al valor predeterminado", + "settings.reset.tooltip": "Restablecer al valor predeterminado", + "settings.restoreDialog.description": "Esto restablecerá: {changes}.", + "settings.restoreDialog.title": "¿Restaurar la configuración predeterminada?", + "settings.section.advanced": "Avanzado", + "settings.section.environment": "Entorno", + "settings.section.general": "General", + "settings.section.models": "Modelos", + "settings.title": "Configuración" +} diff --git a/apps/web/src/i18n/messages/fr.json b/apps/web/src/i18n/messages/fr.json new file mode 100644 index 000000000..b93be2358 --- /dev/null +++ b/apps/web/src/i18n/messages/fr.json @@ -0,0 +1,260 @@ +{ + "common.actions.add": "Ajouter", + "common.actions.back": "Retour", + "common.actions.cancel": "Annuler", + "common.actions.discard": "Ignorer", + "common.actions.getStarted": "Commencer", + "common.actions.next": "Suivant", + "common.actions.openFile": "Ouvrir le fichier", + "common.actions.opening": "Ouverture...", + "common.actions.reloadApp": "Recharger l'application", + "common.actions.restoreDefaults": "Restaurer les valeurs par défaut", + "common.actions.showLess": "Afficher moins", + "common.actions.skip": "Ignorer", + "common.actions.tryAgain": "Réessayer", + "common.custom": "Personnalisé", + "common.dark": "Sombre", + "common.default": "Par défaut", + "common.light": "Clair", + "common.local": "Local", + "common.newWorktree": "Nouveau worktree", + "common.system": "Système", + "common.systemDefault": "Par défaut du système", + "common.unknownError": "Erreur inconnue", + "customThemeDialog.action.apply": "Appliquer le thème", + "customThemeDialog.action.loading": "Chargement...", + "customThemeDialog.action.parse": "Analyser le thème", + "customThemeDialog.color.accent": "Accent", + "customThemeDialog.color.background": "Arrière-plan", + "customThemeDialog.color.border": "Bordure", + "customThemeDialog.color.card": "Carte", + "customThemeDialog.color.destructive": "Destructif", + "customThemeDialog.color.muted": "Atténué", + "customThemeDialog.color.primary": "Primaire", + "customThemeDialog.color.secondary": "Secondaire", + "customThemeDialog.description": "Collez du CSS ou une URL de thème tweakcn.com ci-dessous.", + "customThemeDialog.error.parseFailed": "Impossible d'analyser le thème.", + "customThemeDialog.placeholder": "Collez le CSS du thème, du JSON ou une URL tweakcn.com...\n\nExemple :\nhttps://tweakcn.com/themes/catppuccin\n\nou\n\n:root {\n --background: oklch(1 0 0);\n --primary: oklch(0.58 0.2 277);\n ...\n}", + "customThemeDialog.preview.title": "Aperçu", + "customThemeDialog.preview.titleWithName": "Aperçu - {name}", + "customThemeDialog.title": "Importer un thème personnalisé", + "customThemeDialog.tokens.font": "Police : {value}", + "customThemeDialog.tokens.mono": "Mono : {value}", + "customThemeDialog.tokens.radius": "Rayon : {value}", + "customThemeDialog.urlBadge": "URL", + "customThemeDialog.variablesCount": "{lightCount} variables claires + {darkCount} variables sombres", + "envEditor.action.saving": "Enregistrement...", + "envEditor.blankRowHint": "Les lignes vides sont ignorées tant qu'elles ne contiennent pas de clé.", + "envEditor.encryptionNotice": "Les valeurs sont chiffrées au repos avant d'être écrites dans la base d'état locale.", + "envEditor.keyLabel": "Clé {index}", + "envEditor.keyPlaceholder": "API_KEY", + "envEditor.removeAria": "Supprimer la variable", + "envEditor.saveError": "Impossible d'enregistrer les variables d'environnement.", + "envEditor.savedCount": "{count}/{max} variables enregistrées", + "envEditor.scopeHint": "Cette valeur sera disponible pour les lancements dans la portée correspondante.", + "envEditor.validation.keyDuplicate": "Ce nom de variable est dupliqué.", + "envEditor.validation.keyPattern": "Utilisez des lettres, des chiffres et des traits de soulignement, en commençant par une lettre ou un trait de soulignement.", + "envEditor.validation.keyTooLong": "Les clés doivent contenir au maximum {max} caractères.", + "envEditor.validation.nameRequired": "Un nom de variable est requis.", + "envEditor.validation.valueTooLong": "Les valeurs doivent contenir au maximum {max} caractères.", + "envEditor.valueLabel": "Valeur", + "envEditor.valuePlaceholder": "valeur secrète", + "mobilePairing.bridgeUnavailable": "Le pont d'appairage mobile n'est pas disponible.", + "mobilePairing.clearFailed": "Impossible d'effacer l'appairage.", + "mobilePairing.clearSaved": "Effacer l'appairage enregistré", + "mobilePairing.description": "Collez un lien d'appairage comme okcode://pair?server=…&token=… ou une URL de serveur qui inclut ?token=….", + "mobilePairing.pair": "Appairer l'appareil", + "mobilePairing.pairFailed": "Impossible d'appairer cet appareil.", + "mobilePairing.pairing": "Appairage...", + "mobilePairing.placeholder": "okcode://pair?server=…&token=…", + "mobilePairing.title": "Appairer cet appareil", + "onboarding.actions.getStarted": "Commencer", + "onboarding.actions.next": "Suivant", + "onboarding.actions.skip": "Ignorer", + "onboarding.progress.label": "Progression de l'intégration", + "onboarding.progress.stepAria": "Aller à l'étape {index} sur {total} : {title}", + "onboarding.step.approvals.description": "Vous décidez de ce qui est exécuté. L'agent demande votre approbation avant d'effectuer des changements, donc rien ne se passe sans votre accord.", + "onboarding.step.approvals.detail1": "Approuvez, demandez des changements ou annulez toute action proposée", + "onboarding.step.approvals.detail2": "Passez entre les modes accès complet et approbation requise par fil", + "onboarding.step.approvals.detail3": "Examinez les modifications de fichiers en attente avant leur application", + "onboarding.step.approvals.title": "Gardez le contrôle", + "onboarding.step.chat.description": "Discutez avec des agents de codage IA en temps réel. Posez des questions, demandez des changements ou laissez l'agent piloter des fonctionnalités entières.", + "onboarding.step.chat.detail1": "Choisissez entre plusieurs fournisseurs : Codex et Claude", + "onboarding.step.chat.detail2": "Diffusez les réponses en temps réel pendant que l'agent travaille", + "onboarding.step.chat.detail3": "Joignez des images et le contexte du terminal directement dans vos prompts", + "onboarding.step.chat.title": "Conversations pilotées par l'IA", + "onboarding.step.diff.description": "Inspectez chaque modification de code effectuée par l'agent avec un visualiseur de diff intégré avant d'accepter quoi que ce soit.", + "onboarding.step.diff.detail1": "Vues diff en ligne et côte à côte avec coloration syntaxique", + "onboarding.step.diff.detail2": "Acceptez ou rejetez les changements fichier par fichier en un clic", + "onboarding.step.diff.detail3": "Le surlignage au niveau des mots montre exactement ce qui a changé", + "onboarding.step.diff.title": "Examinez les changements côte à côte", + "onboarding.step.getStarted.description": "Vous êtes prêt à démarrer. Voici quelques raccourcis pour aller vite.", + "onboarding.step.getStarted.detail1": "Appuyez sur Cmd+N (ou Ctrl+N) pour créer instantanément un nouveau fil", + "onboarding.step.getStarted.detail2": "Utilisez la barre latérale pour passer d'un projet ou d'un fil à l'autre", + "onboarding.step.getStarted.detail3": "Ouvrez Paramètres pour personnaliser les modèles, thèmes et raccourcis", + "onboarding.step.getStarted.title": "Vous êtes prêt !", + "onboarding.step.git.description": "Chaque fil peut fonctionner dans son propre worktree git, ce qui protège votre branche principale pendant que l'agent expérimente librement.", + "onboarding.step.git.detail1": "Les nouveaux fils créent automatiquement des worktrees isolés", + "onboarding.step.git.detail2": "Changez de branche, créez des PR et gérez les worktrees depuis la barre d'outils", + "onboarding.step.git.detail3": "Liez des fils à des pull requests existantes pour une revue de code ciblée", + "onboarding.step.git.title": "Workflows Git intégrés", + "onboarding.step.plan.description": "Passez en mode Plan et laissez l'agent définir une stratégie d'implémentation structurée avant d'écrire une seule ligne de code.", + "onboarding.step.plan.detail1": "Plans étape par étape avec suivi d'état à mesure que le travail progresse", + "onboarding.step.plan.detail2": "Examinez, copiez ou exportez les plans en Markdown", + "onboarding.step.plan.detail3": "Cliquez sur \"Implement Plan\" pour lancer l'exécution dans un nouveau fil", + "onboarding.step.plan.title": "Plans générés par l'IA", + "onboarding.step.terminal.description": "Un terminal complet vit dans chaque fil : exécutez des commandes, voyez la sortie et renvoyez du contexte à l'agent.", + "onboarding.step.terminal.detail1": "Jusqu'à quatre onglets de terminal par fil pour des workflows parallèles", + "onboarding.step.terminal.detail2": "Sélectionnez la sortie du terminal et ajoutez-la directement à votre prompt", + "onboarding.step.terminal.detail3": "Suivez les sous-processus actifs avec des indicateurs en direct", + "onboarding.step.terminal.title": "Terminal intégré", + "onboarding.step.welcome.description": "Votre compagnon de codage propulsé par l'IA. Faisons un rapide tour des fonctionnalités qui vont accélérer votre workflow.", + "onboarding.step.welcome.detail1": "Travaillez aux côtés d'agents IA qui lisent, écrivent et raisonnent sur votre code", + "onboarding.step.welcome.detail2": "Chaque conversation s'exécute par défaut dans un worktree git isolé", + "onboarding.step.welcome.detail3": "Cette visite dure environ une minute ; vous pouvez la passer à tout moment", + "onboarding.step.welcome.title": "Bienvenue dans OK Code", + "root.error.detailsUnavailable": "Aucun détail d'erreur supplémentaire n'est disponible.", + "root.error.hideDetails": "Masquer les détails de l'erreur", + "root.error.showDetails": "Afficher les détails de l'erreur", + "root.error.title": "Une erreur est survenue.", + "root.error.unexpected": "Une erreur de routeur inattendue s'est produite.", + "root.loading.connectingServer": "Connexion au serveur {appName}...", + "root.loading.restoringMobilePairing": "Restauration de l'appairage mobile...", + "root.toast.invalidKeybindings.action": "Ouvrir keybindings.json", + "root.toast.invalidKeybindings.title": "Configuration de raccourcis invalide", + "root.toast.keybindingsUpdated.description": "La configuration des raccourcis a bien été rechargée.", + "root.toast.keybindingsUpdated.title": "Raccourcis mis à jour", + "root.toast.openKeybindingsFailed.title": "Impossible d'ouvrir le fichier des raccourcis", + "root.toast.unknownFileOpenError": "Erreur inconnue lors de l'ouverture du fichier.", + "settings.advanced.keybindings.description": "Ouvrez le fichier `keybindings.json` persisté pour modifier directement les raccourcis avancés.", + "settings.advanced.keybindings.noEditors": "Aucun éditeur disponible.", + "settings.advanced.keybindings.openFile": "Ouvrir le fichier", + "settings.advanced.keybindings.opensInPreferredEditor": "S'ouvre dans votre éditeur préféré.", + "settings.advanced.keybindings.opening": "Ouverture...", + "settings.advanced.keybindings.resolvingPath": "Résolution du chemin de keybindings...", + "settings.advanced.keybindings.title": "Raccourcis", + "settings.advanced.providerInstalls.claude.binaryDescription": "Laissez vide pour utiliser claude depuis votre PATH. L'authentification utilise claude auth login.", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Chemin du binaire Anthropic", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Chemin du binaire Claude", + "settings.advanced.providerInstalls.codex.binaryDescription": "Laissez vide pour utiliser codex depuis votre PATH. L'authentification utilise normalement codex login, sauf si votre configuration Codex pointe vers un fournisseur de modèle personnalisé.", + "settings.advanced.providerInstalls.codex.binaryPathLabel": "Chemin du binaire Codex", + "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Chemin du binaire Codex", + "settings.advanced.providerInstalls.codex.homeDescription": "Répertoire de configuration et d'accueil Codex personnalisé facultatif.", + "settings.advanced.providerInstalls.codex.homePathLabel": "Chemin CODEX_HOME", + "settings.advanced.providerInstalls.codex.homePlaceholder": "CODEX_HOME", + "settings.advanced.providerInstalls.customBadge": "Personnalisé", + "settings.advanced.providerInstalls.description": "Remplacez les binaires CLI et les répertoires d'authentification utilisés pour les nouvelles sessions.", + "settings.advanced.providerInstalls.title": "Installations des fournisseurs", + "settings.advanced.version.description": "Version actuelle de l'application.", + "settings.advanced.version.title": "Version", + "settings.changed.assistantOutput": "Sortie de l'assistant", + "settings.changed.borderRadius": "Rayon des bordures", + "settings.changed.colorTheme": "Thème de couleur", + "settings.changed.customModels": "Modèles personnalisés", + "settings.changed.deleteConfirmation": "Confirmation de suppression", + "settings.changed.diffWordWrap": "Retour à la ligne du diff", + "settings.changed.font": "Police", + "settings.changed.fontFamily": "Famille de police", + "settings.changed.gitWritingModel": "Modèle d'écriture Git", + "settings.changed.language": "Langue", + "settings.changed.newThreadMode": "Mode de nouveau fil", + "settings.changed.openLinksExternally": "Ouvrir les liens à l'extérieur", + "settings.changed.providerInstalls": "Installations des fournisseurs", + "settings.changed.theme": "Thème", + "settings.changed.timeFormat": "Format de l'heure", + "settings.environment.global.description": "Disponible pour chaque session de fournisseur, terminal, commande Git et vérification d'état lancés sur cette machine.", + "settings.environment.global.editorDescription": "Les valeurs globales sont chiffrées localement et fusionnées dans chaque environnement d'exécution.", + "settings.environment.global.empty": "Aucune variable globale enregistrée pour l'instant.", + "settings.environment.global.failed": "Impossible de charger les variables enregistrées : {error}", + "settings.environment.global.loading": "Chargement des variables enregistrées...", + "settings.environment.global.save": "Enregistrer global", + "settings.environment.global.saved": "{count, plural, one {# variable enregistrée} other {# variables enregistrées}}", + "settings.environment.global.title": "Variables globales", + "settings.environment.project.description": "Enregistrées par projet et fusionnées par-dessus l'ensemble global lorsque ce projet lance un fournisseur, un terminal ou une commande utilitaire.", + "settings.environment.project.editorDescription": "Ouvrez ou créez un projet pour modifier les variables du projet.", + "settings.environment.project.editorDescriptionWithProject": "Les valeurs du projet remplacent les valeurs globales pour {projectName}.", + "settings.environment.project.empty": "Aucune variable de projet enregistrée pour l'instant.", + "settings.environment.project.loading": "Chargement des variables du projet...", + "settings.environment.project.noProject": "Ouvrez un projet pour modifier les variables du projet.", + "settings.environment.project.noProjects": "Aucun projet disponible.", + "settings.environment.project.save": "Enregistrer le projet", + "settings.environment.project.saveErrorNoProject": "Sélectionnez un projet avant d'enregistrer les variables du projet.", + "settings.environment.project.selectPlaceholder": "Sélectionner un projet", + "settings.environment.project.title": "Variables du projet", + "settings.general.assistantOutput.aria": "Diffuser les messages de l'assistant", + "settings.general.assistantOutput.description": "Affiche la sortie jeton par jeton pendant qu'une réponse est en cours.", + "settings.general.assistantOutput.title": "Sortie de l'assistant", + "settings.general.borderRadius.aria": "Rayon des bordures", + "settings.general.borderRadius.description": "Ajustez l'arrondi des coins des éléments d'interface.", + "settings.general.borderRadius.title": "Rayon des bordures", + "settings.general.colorTheme.aria": "Thème de couleur", + "settings.general.colorTheme.description": "Choisissez une palette de couleurs pour les modes clair et sombre.", + "settings.general.colorTheme.importAria": "Importer un thème personnalisé", + "settings.general.colorTheme.importTooltip": "Importer depuis tweakcn.com", + "settings.general.colorTheme.option.custom": "Personnalisé", + "settings.general.colorTheme.option.default": "Par défaut", + "settings.general.colorTheme.title": "Thème de couleur", + "settings.general.deleteConfirmation.aria": "Confirmer la suppression du fil", + "settings.general.deleteConfirmation.description": "Demander confirmation avant de supprimer un fil et son historique de discussion.", + "settings.general.deleteConfirmation.title": "Confirmation de suppression", + "settings.general.diffWordWrap.aria": "Activer le retour à la ligne du diff par défaut", + "settings.general.diffWordWrap.description": "Définit l'état de retour à la ligne par défaut à l'ouverture du panneau diff. Le bouton dans le panneau n'affecte que la session diff en cours.", + "settings.general.diffWordWrap.title": "Retour à la ligne du diff", + "settings.general.font.description": "Choisissez la police de l'interface.", + "settings.general.font.title": "Police", + "settings.general.fontFamilyOverride.aria": "Remplacement de la famille de police", + "settings.general.fontFamilyOverride.description": "Remplacez la police de l'interface. Utilisez n'importe quel nom de Google Font.", + "settings.general.fontFamilyOverride.placeholder": "ex. Inter, sans-serif", + "settings.general.fontFamilyOverride.title": "Famille de police", + "settings.general.language.aria": "Préférence de langue", + "settings.general.language.description": "Choisissez la langue utilisée pour l'interface de l'application.", + "settings.general.language.option.en": "English", + "settings.general.language.option.es": "Español", + "settings.general.language.option.fr": "Français", + "settings.general.language.option.system": "Par défaut du système", + "settings.general.language.option.zh-CN": "简体中文", + "settings.general.language.title": "Langue", + "settings.general.newThreads.aria": "Mode de fil par défaut", + "settings.general.newThreads.description": "Choisissez le mode d'espace de travail par défaut pour les nouveaux brouillons de fil.", + "settings.general.newThreads.title": "Nouveaux fils", + "settings.general.openLinksExternally.aria": "Ouvrir les liens à l'extérieur", + "settings.general.openLinksExternally.description": "Ouvrez les URL du terminal dans votre navigateur par défaut au lieu du panneau d'aperçu intégré.", + "settings.general.openLinksExternally.title": "Ouvrir les liens à l'extérieur", + "settings.general.sidebarOpacity.aria": "Opacité de la barre latérale", + "settings.general.sidebarOpacity.description": "Ajustez la transparence du panneau latéral et de la liste des projets.", + "settings.general.sidebarOpacity.title": "Opacité de la barre latérale", + "settings.general.theme.aria": "Préférence de thème", + "settings.general.theme.description": "Choisissez l'apparence d'OK Code dans toute l'application.", + "settings.general.theme.title": "Thème", + "settings.general.timeFormat.aria": "Format d'horodatage", + "settings.general.timeFormat.description": "Le mode système suit la préférence d'horloge de votre navigateur ou système d'exploitation.", + "settings.general.timeFormat.option.12Hour": "12 heures", + "settings.general.timeFormat.option.24Hour": "24 heures", + "settings.general.timeFormat.option.locale": "Par défaut du système", + "settings.general.timeFormat.title": "Format de l'heure", + "settings.general.windowOpacity.aria": "Opacité de la fenêtre", + "settings.general.windowOpacity.description": "Ajustez la transparence de toute la fenêtre de l'application.", + "settings.general.windowOpacity.title": "Opacité de la fenêtre", + "settings.models.customModels.addButton": "Ajouter", + "settings.models.customModels.description": "Ajoutez des slugs de modèles personnalisés pour Codex ou Anthropic. Le sélecteur de chat regroupe les modèles par fournisseur.", + "settings.models.customModels.providerAria": "Fournisseur de modèle personnalisé", + "settings.models.customModels.removeAria": "Supprimer {slug}", + "settings.models.customModels.showMore": "Afficher plus ({count})", + "settings.models.customModels.title": "Modèles personnalisés", + "settings.models.customModels.validation.alreadySaved": "Ce modèle personnalisé est déjà enregistré.", + "settings.models.customModels.validation.builtIn": "Ce modèle est déjà intégré.", + "settings.models.customModels.validation.enterSlug": "Saisissez un slug de modèle.", + "settings.models.customModels.validation.tooLong": "Les slugs de modèle doivent contenir au maximum {max} caractères.", + "settings.models.gitWritingModel.aria": "Modèle de génération de texte Git", + "settings.models.gitWritingModel.description": "Utilisé pour générer les messages de commit, titres de PR et noms de branches.", + "settings.models.gitWritingModel.title": "Modèle d'écriture Git", + "settings.reset.aria": "Réinitialiser {label} à la valeur par défaut", + "settings.reset.tooltip": "Réinitialiser à la valeur par défaut", + "settings.restoreDialog.description": "Cela réinitialisera : {changes}.", + "settings.restoreDialog.title": "Restaurer les paramètres par défaut ?", + "settings.section.advanced": "Avancé", + "settings.section.environment": "Environnement", + "settings.section.general": "Général", + "settings.section.models": "Modèles", + "settings.title": "Paramètres" +} diff --git a/apps/web/src/i18n/messages/zh-CN.json b/apps/web/src/i18n/messages/zh-CN.json new file mode 100644 index 000000000..c5fe781dd --- /dev/null +++ b/apps/web/src/i18n/messages/zh-CN.json @@ -0,0 +1,260 @@ +{ + "common.actions.add": "添加", + "common.actions.back": "返回", + "common.actions.cancel": "取消", + "common.actions.discard": "放弃", + "common.actions.getStarted": "开始使用", + "common.actions.next": "下一步", + "common.actions.openFile": "打开文件", + "common.actions.opening": "打开中...", + "common.actions.reloadApp": "重新加载应用", + "common.actions.restoreDefaults": "恢复默认值", + "common.actions.showLess": "收起", + "common.actions.skip": "跳过", + "common.actions.tryAgain": "重试", + "common.custom": "自定义", + "common.dark": "深色", + "common.default": "默认", + "common.light": "浅色", + "common.local": "本地", + "common.newWorktree": "新建 worktree", + "common.system": "系统", + "common.systemDefault": "系统默认", + "common.unknownError": "未知错误", + "customThemeDialog.action.apply": "应用主题", + "customThemeDialog.action.loading": "加载中...", + "customThemeDialog.action.parse": "解析主题", + "customThemeDialog.color.accent": "强调色", + "customThemeDialog.color.background": "背景", + "customThemeDialog.color.border": "边框", + "customThemeDialog.color.card": "卡片", + "customThemeDialog.color.destructive": "危险", + "customThemeDialog.color.muted": "弱化", + "customThemeDialog.color.primary": "主色", + "customThemeDialog.color.secondary": "次色", + "customThemeDialog.description": "在下方粘贴 CSS 或 tweakcn.com 主题链接。", + "customThemeDialog.error.parseFailed": "无法解析主题。", + "customThemeDialog.placeholder": "粘贴主题 CSS、JSON 或 tweakcn.com 链接...\n\n示例:\nhttps://tweakcn.com/themes/catppuccin\n\n或\n\n:root {\n --background: oklch(1 0 0);\n --primary: oklch(0.58 0.2 277);\n ...\n}", + "customThemeDialog.preview.title": "预览", + "customThemeDialog.preview.titleWithName": "预览 - {name}", + "customThemeDialog.title": "导入自定义主题", + "customThemeDialog.tokens.font": "字体:{value}", + "customThemeDialog.tokens.mono": "等宽:{value}", + "customThemeDialog.tokens.radius": "圆角:{value}", + "customThemeDialog.urlBadge": "链接", + "customThemeDialog.variablesCount": "{lightCount} 个浅色变量 + {darkCount} 个深色变量", + "envEditor.action.saving": "保存中...", + "envEditor.blankRowHint": "空行在包含键名之前会被忽略。", + "envEditor.encryptionNotice": "这些值会在写入本地状态数据库之前以静态加密方式保存。", + "envEditor.keyLabel": "键 {index}", + "envEditor.keyPlaceholder": "API_KEY", + "envEditor.removeAria": "删除变量", + "envEditor.saveError": "无法保存环境变量。", + "envEditor.savedCount": "{count}/{max} 个已保存变量", + "envEditor.scopeHint": "此值将在对应作用域内的启动过程中可用。", + "envEditor.validation.keyDuplicate": "该变量名重复。", + "envEditor.validation.keyPattern": "请使用字母、数字和下划线,并以字母或下划线开头。", + "envEditor.validation.keyTooLong": "键名长度不能超过 {max} 个字符。", + "envEditor.validation.nameRequired": "必须填写变量名。", + "envEditor.validation.valueTooLong": "值长度不能超过 {max} 个字符。", + "envEditor.valueLabel": "值", + "envEditor.valuePlaceholder": "secret value", + "mobilePairing.bridgeUnavailable": "移动配对桥接不可用。", + "mobilePairing.clearFailed": "无法清除配对信息。", + "mobilePairing.clearSaved": "清除已保存的配对", + "mobilePairing.description": "粘贴类似 okcode://pair?server=…&token=… 的配对链接,或包含 ?token=… 的服务器 URL。", + "mobilePairing.pair": "配对此设备", + "mobilePairing.pairFailed": "无法配对此设备。", + "mobilePairing.pairing": "配对中...", + "mobilePairing.placeholder": "okcode://pair?server=…&token=…", + "mobilePairing.title": "配对此设备", + "onboarding.actions.getStarted": "开始使用", + "onboarding.actions.next": "下一步", + "onboarding.actions.skip": "跳过", + "onboarding.progress.label": "引导进度", + "onboarding.progress.stepAria": "转到第 {index} / {total} 步:{title}", + "onboarding.step.approvals.description": "由你决定执行什么。代理在进行更改前会请求你的批准,因此没有你的同意就不会发生任何事。", + "onboarding.step.approvals.detail1": "可以批准、请求修改或取消任何提议的操作", + "onboarding.step.approvals.detail2": "按线程切换完全访问和需要批准的模式", + "onboarding.step.approvals.detail3": "在应用前查看待处理的文件更改", + "onboarding.step.approvals.title": "保持掌控", + "onboarding.step.chat.description": "与 AI 编码代理实时对话。可以提问、请求修改,或让代理主导完整功能开发。", + "onboarding.step.chat.detail1": "可在多个提供方之间切换:Codex 和 Claude", + "onboarding.step.chat.detail2": "代理工作时实时流式展示回复", + "onboarding.step.chat.detail3": "可直接在提示中附加图片和终端上下文", + "onboarding.step.chat.title": "AI 对话", + "onboarding.step.diff.description": "在接受任何内容之前,使用内置 diff 查看器检查代理所做的每一次代码修改。", + "onboarding.step.diff.detail1": "支持行内和并排 diff 视图,并带语法高亮", + "onboarding.step.diff.detail2": "一键按文件接受或拒绝更改", + "onboarding.step.diff.detail3": "词级高亮可准确显示变更内容", + "onboarding.step.diff.title": "并排审查改动", + "onboarding.step.getStarted.description": "你已经准备好开始构建。这里有几个快捷方式可以帮助你更快上手。", + "onboarding.step.getStarted.detail1": "按 Cmd+N(或 Ctrl+N)即可立即创建新线程", + "onboarding.step.getStarted.detail2": "使用侧边栏在项目和线程之间切换", + "onboarding.step.getStarted.detail3": "打开设置以自定义模型、主题和快捷键", + "onboarding.step.getStarted.title": "一切就绪!", + "onboarding.step.git.description": "每个线程都可以在自己的 git worktree 中运行,让你的主分支保持安全,同时允许代理自由实验。", + "onboarding.step.git.detail1": "新线程会自动创建隔离的 worktree", + "onboarding.step.git.detail2": "可从工具栏切换分支、创建 PR 并管理 worktree", + "onboarding.step.git.detail3": "将线程链接到现有 pull request 以进行更聚焦的代码审查", + "onboarding.step.git.title": "内置 Git 工作流", + "onboarding.step.plan.description": "切换到 Plan 模式,让代理先给出结构化的实现方案,再开始写任何代码。", + "onboarding.step.plan.detail1": "分步骤计划,并在工作推进时跟踪状态", + "onboarding.step.plan.detail2": "可查看、复制或导出为 Markdown", + "onboarding.step.plan.detail3": "点击“Implement Plan”即可在新线程中启动执行", + "onboarding.step.plan.title": "AI 生成计划", + "onboarding.step.terminal.description": "每个线程都内置完整终端,可运行命令、查看输出,并将上下文反馈给代理。", + "onboarding.step.terminal.detail1": "每个线程最多支持四个终端标签页并行工作", + "onboarding.step.terminal.detail2": "可选取终端输出并直接添加到提示中", + "onboarding.step.terminal.detail3": "通过实时活动指示器跟踪正在运行的子进程", + "onboarding.step.terminal.title": "集成终端", + "onboarding.step.welcome.description": "你的 AI 编码搭档。让我们快速浏览一下能显著提升工作流的功能。", + "onboarding.step.welcome.detail1": "与能够阅读、编写并理解你代码的 AI 代理协作", + "onboarding.step.welcome.detail2": "每次对话默认都在隔离的 git worktree 中运行", + "onboarding.step.welcome.detail3": "本次引导大约只需一分钟,你可随时跳过", + "onboarding.step.welcome.title": "欢迎使用 OK Code", + "root.error.detailsUnavailable": "没有更多错误详情可用。", + "root.error.hideDetails": "隐藏错误详情", + "root.error.showDetails": "显示错误详情", + "root.error.title": "出现问题。", + "root.error.unexpected": "发生了意外的路由错误。", + "root.loading.connectingServer": "正在连接到 {appName} 服务器...", + "root.loading.restoringMobilePairing": "正在恢复移动配对...", + "root.toast.invalidKeybindings.action": "打开 keybindings.json", + "root.toast.invalidKeybindings.title": "快捷键配置无效", + "root.toast.keybindingsUpdated.description": "快捷键配置已成功重新加载。", + "root.toast.keybindingsUpdated.title": "快捷键已更新", + "root.toast.openKeybindingsFailed.title": "无法打开快捷键文件", + "root.toast.unknownFileOpenError": "打开文件时发生未知错误。", + "settings.advanced.keybindings.description": "打开已持久化的 `keybindings.json` 文件以直接编辑高级快捷键。", + "settings.advanced.keybindings.noEditors": "没有可用的编辑器。", + "settings.advanced.keybindings.openFile": "打开文件", + "settings.advanced.keybindings.opensInPreferredEditor": "将在你偏好的编辑器中打开。", + "settings.advanced.keybindings.opening": "打开中...", + "settings.advanced.keybindings.resolvingPath": "正在解析 keybindings 路径...", + "settings.advanced.keybindings.title": "快捷键", + "settings.advanced.providerInstalls.claude.binaryDescription": "留空则使用 PATH 中的 claude。认证使用 claude auth login。", + "settings.advanced.providerInstalls.claude.binaryPathLabel": "Anthropic 二进制路径", + "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude 二进制路径", + "settings.advanced.providerInstalls.codex.binaryDescription": "留空则使用 PATH 中的 codex。认证通常使用 codex login,除非你的 Codex 配置指向了自定义模型提供方。", + "settings.advanced.providerInstalls.codex.binaryPathLabel": "Codex 二进制路径", + "settings.advanced.providerInstalls.codex.binaryPlaceholder": "Codex 二进制路径", + "settings.advanced.providerInstalls.codex.homeDescription": "可选的自定义 Codex 主目录和配置目录。", + "settings.advanced.providerInstalls.codex.homePathLabel": "CODEX_HOME 路径", + "settings.advanced.providerInstalls.codex.homePlaceholder": "CODEX_HOME", + "settings.advanced.providerInstalls.customBadge": "自定义", + "settings.advanced.providerInstalls.description": "覆盖新会话使用的 CLI 二进制和认证主目录。", + "settings.advanced.providerInstalls.title": "提供方安装配置", + "settings.advanced.version.description": "当前应用版本。", + "settings.advanced.version.title": "版本", + "settings.changed.assistantOutput": "助手输出", + "settings.changed.borderRadius": "边框圆角", + "settings.changed.colorTheme": "配色主题", + "settings.changed.customModels": "自定义模型", + "settings.changed.deleteConfirmation": "删除确认", + "settings.changed.diffWordWrap": "Diff 自动换行", + "settings.changed.font": "字体", + "settings.changed.fontFamily": "字体族", + "settings.changed.gitWritingModel": "Git 写作模型", + "settings.changed.language": "语言", + "settings.changed.newThreadMode": "新线程模式", + "settings.changed.openLinksExternally": "在外部打开链接", + "settings.changed.providerInstalls": "提供方安装配置", + "settings.changed.theme": "主题", + "settings.changed.timeFormat": "时间格式", + "settings.environment.global.description": "适用于此机器上启动的每个提供方会话、终端、Git 命令和健康检查。", + "settings.environment.global.editorDescription": "全局值会在本地加密,并合并到每个运行环境中。", + "settings.environment.global.empty": "还没有保存任何全局变量。", + "settings.environment.global.failed": "加载已保存变量失败:{error}", + "settings.environment.global.loading": "正在加载已保存变量...", + "settings.environment.global.save": "保存全局", + "settings.environment.global.saved": "{count} 个已保存变量", + "settings.environment.global.title": "全局变量", + "settings.environment.project.description": "按项目保存,并在该项目启动提供方、终端或辅助命令时覆盖全局集合。", + "settings.environment.project.editorDescription": "打开或创建项目以编辑项目变量。", + "settings.environment.project.editorDescriptionWithProject": "{projectName} 的项目值会覆盖全局值。", + "settings.environment.project.empty": "还没有保存任何项目变量。", + "settings.environment.project.loading": "正在加载项目变量...", + "settings.environment.project.noProject": "打开项目以编辑项目变量。", + "settings.environment.project.noProjects": "没有可用项目。", + "settings.environment.project.save": "保存项目", + "settings.environment.project.saveErrorNoProject": "请先选择项目再保存项目变量。", + "settings.environment.project.selectPlaceholder": "选择项目", + "settings.environment.project.title": "项目变量", + "settings.general.assistantOutput.aria": "流式显示助手消息", + "settings.general.assistantOutput.description": "在回复生成过程中逐 token 显示输出。", + "settings.general.assistantOutput.title": "助手输出", + "settings.general.borderRadius.aria": "边框圆角", + "settings.general.borderRadius.description": "调整界面元素圆角的弧度。", + "settings.general.borderRadius.title": "边框圆角", + "settings.general.colorTheme.aria": "配色主题", + "settings.general.colorTheme.description": "为浅色和深色模式选择配色方案。", + "settings.general.colorTheme.importAria": "导入自定义主题", + "settings.general.colorTheme.importTooltip": "从 tweakcn.com 导入", + "settings.general.colorTheme.option.custom": "自定义", + "settings.general.colorTheme.option.default": "默认", + "settings.general.colorTheme.title": "配色主题", + "settings.general.deleteConfirmation.aria": "确认删除线程", + "settings.general.deleteConfirmation.description": "删除线程及其聊天记录前先询问。", + "settings.general.deleteConfirmation.title": "删除确认", + "settings.general.diffWordWrap.aria": "默认换行显示 diff 行", + "settings.general.diffWordWrap.description": "设置 diff 面板打开时的默认换行状态。面板内的换行开关只影响当前 diff 会话。", + "settings.general.diffWordWrap.title": "Diff 自动换行", + "settings.general.font.description": "选择界面使用的字体。", + "settings.general.font.title": "字体", + "settings.general.fontFamilyOverride.aria": "字体族覆盖", + "settings.general.fontFamilyOverride.description": "覆盖界面字体。可使用任意 Google Font 名称。", + "settings.general.fontFamilyOverride.placeholder": "例如:Inter, sans-serif", + "settings.general.fontFamilyOverride.title": "字体族", + "settings.general.language.aria": "语言偏好", + "settings.general.language.description": "选择应用界面使用的语言。", + "settings.general.language.option.en": "English", + "settings.general.language.option.es": "Español", + "settings.general.language.option.fr": "Français", + "settings.general.language.option.system": "系统默认", + "settings.general.language.option.zh-CN": "简体中文", + "settings.general.language.title": "语言", + "settings.general.newThreads.aria": "默认线程模式", + "settings.general.newThreads.description": "为新建草稿线程选择默认工作区模式。", + "settings.general.newThreads.title": "新线程", + "settings.general.openLinksExternally.aria": "在外部打开链接", + "settings.general.openLinksExternally.description": "终端 URL 将在默认浏览器中打开,而不是嵌入式预览面板。", + "settings.general.openLinksExternally.title": "在外部打开链接", + "settings.general.sidebarOpacity.aria": "侧边栏透明度", + "settings.general.sidebarOpacity.description": "调整侧边面板和项目列表的透明度。", + "settings.general.sidebarOpacity.title": "侧边栏透明度", + "settings.general.theme.aria": "主题偏好", + "settings.general.theme.description": "选择 OK Code 在整个应用中的外观。", + "settings.general.theme.title": "主题", + "settings.general.timeFormat.aria": "时间戳格式", + "settings.general.timeFormat.description": "系统默认会遵循浏览器或操作系统的时钟偏好设置。", + "settings.general.timeFormat.option.12Hour": "12 小时制", + "settings.general.timeFormat.option.24Hour": "24 小时制", + "settings.general.timeFormat.option.locale": "系统默认", + "settings.general.timeFormat.title": "时间格式", + "settings.general.windowOpacity.aria": "窗口透明度", + "settings.general.windowOpacity.description": "调整整个应用窗口的透明度。", + "settings.general.windowOpacity.title": "窗口透明度", + "settings.models.customModels.addButton": "添加", + "settings.models.customModels.description": "为 Codex 或 Anthropic 添加自定义模型 slug。聊天选择器会按提供方对模型分组。", + "settings.models.customModels.providerAria": "自定义模型提供方", + "settings.models.customModels.removeAria": "移除 {slug}", + "settings.models.customModels.showMore": "显示更多({count})", + "settings.models.customModels.title": "自定义模型", + "settings.models.customModels.validation.alreadySaved": "该自定义模型已保存。", + "settings.models.customModels.validation.builtIn": "该模型已内置。", + "settings.models.customModels.validation.enterSlug": "请输入模型 slug。", + "settings.models.customModels.validation.tooLong": "模型 slug 长度不能超过 {max} 个字符。", + "settings.models.gitWritingModel.aria": "Git 文本生成模型", + "settings.models.gitWritingModel.description": "用于生成提交信息、PR 标题和分支名。", + "settings.models.gitWritingModel.title": "Git 写作模型", + "settings.reset.aria": "将 {label} 重置为默认值", + "settings.reset.tooltip": "重置为默认值", + "settings.restoreDialog.description": "这将重置:{changes}。", + "settings.restoreDialog.title": "恢复默认设置?", + "settings.section.advanced": "高级", + "settings.section.environment": "环境", + "settings.section.general": "常规", + "settings.section.models": "模型", + "settings.title": "设置" +} diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts new file mode 100644 index 000000000..6c617f78c --- /dev/null +++ b/apps/web/src/i18n/types.ts @@ -0,0 +1,8 @@ +export const APP_LOCALE_PREFERENCES = ["system", "en", "es", "fr", "zh-CN"] as const; +export type AppLocalePreference = (typeof APP_LOCALE_PREFERENCES)[number]; + +export const RESOLVED_APP_LOCALES = ["en", "es", "fr", "zh-CN"] as const; +export type ResolvedAppLocale = (typeof RESOLVED_APP_LOCALES)[number]; + +export type AppMessages = Record; +export type TranslationValues = Record; diff --git a/apps/web/src/i18n/useI18n.ts b/apps/web/src/i18n/useI18n.ts new file mode 100644 index 000000000..16601c7a5 --- /dev/null +++ b/apps/web/src/i18n/useI18n.ts @@ -0,0 +1,46 @@ +import { useCallback } from "react"; +import { useIntl, type IntlShape } from "react-intl"; +import { useI18nContext } from "./I18nProvider"; +import type { TranslationValues } from "./types"; + +type FormatDateOptions = Parameters[1]; +type FormatTimeOptions = Parameters[1]; +type FormatNumberOptions = Parameters[1]; + +export function useI18n() { + return useI18nContext(); +} + +export function useT() { + const intl = useIntl(); + const context = useI18nContext(); + + const t = useCallback( + (id: string, values?: TranslationValues) => intl.formatMessage({ id }, values as never), + [intl], + ); + const formatDate = useCallback( + (value: Parameters[0], options?: FormatDateOptions) => + intl.formatDate(value, options), + [intl], + ); + const formatTime = useCallback( + (value: Parameters[0], options?: FormatTimeOptions) => + intl.formatTime(value, options), + [intl], + ); + const formatNumber = useCallback( + (value: Parameters[0], options?: FormatNumberOptions) => + intl.formatNumber(value, options), + [intl], + ); + + return { + ...context, + intl, + t, + formatDate, + formatTime, + formatNumber, + } as const; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 049a747de..9395734bb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { OnboardingDialog } from "../components/onboarding/OnboardingDialog"; import { MobilePairingScreen } from "../components/mobile/MobilePairingScreen"; import { useMobilePairingState } from "../hooks/useMobilePairingState"; +import { I18nProvider } from "../i18n/I18nProvider"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -39,6 +40,14 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + return ( + + + + ); +} + +function RootRouteContent() { const { isMobileShell, isLoading, pairingState } = useMobilePairingState(); if (isMobileShell && isLoading) { @@ -80,6 +89,14 @@ function RootRouteView() { } function RootRouteErrorView({ error, reset }: ErrorComponentProps) { + return ( + + + + ); +} + +function RootRouteErrorContent({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index f45ada734..aac9b5166 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getTimestampFormatOptions } from "./timestampFormat"; +import { formatShortTimestamp, getTimestampFormatOptions } from "./timestampFormat"; describe("getTimestampFormatOptions", () => { it("omits hour12 when locale formatting is requested", () => { @@ -28,3 +28,25 @@ describe("getTimestampFormatOptions", () => { }); }); }); + +describe("formatShortTimestamp", () => { + it("formats against the selected app locale instead of the ambient locale", () => { + const isoDate = "2026-03-31T13:05:06.000Z"; + + const english = formatShortTimestamp(isoDate, "locale", "en"); + const french = formatShortTimestamp(isoDate, "locale", "fr"); + + expect(english).not.toEqual(french); + }); + + it("uses locale-specific formatter caches", () => { + const isoDate = "2026-03-31T13:05:06.000Z"; + + const firstEnglish = formatShortTimestamp(isoDate, "locale", "en"); + const secondEnglish = formatShortTimestamp(isoDate, "locale", "en"); + const chinese = formatShortTimestamp(isoDate, "locale", "zh-CN"); + + expect(firstEnglish).toEqual(secondEnglish); + expect(chinese).not.toEqual(firstEnglish); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index d4ffa3c37..56ed04da2 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,4 +1,5 @@ import { type TimestampFormat } from "./appSettings"; +import type { ResolvedAppLocale } from "./i18n/types"; export function getTimestampFormatOptions( timestampFormat: TimestampFormat, @@ -23,27 +24,36 @@ export function getTimestampFormatOptions( const timestampFormatterCache = new Map(); function getTimestampFormatter( + locale: ResolvedAppLocale, timestampFormat: TimestampFormat, includeSeconds: boolean, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; + const cacheKey = `${locale}:${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } const formatter = new Intl.DateTimeFormat( - undefined, + locale, getTimestampFormatOptions(timestampFormat, includeSeconds), ); timestampFormatterCache.set(cacheKey, formatter); return formatter; } -export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); +export function formatTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + locale: ResolvedAppLocale, +): string { + return getTimestampFormatter(locale, timestampFormat, true).format(new Date(isoDate)); } -export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); +export function formatShortTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + locale: ResolvedAppLocale, +): string { + return getTimestampFormatter(locale, timestampFormat, false).format(new Date(isoDate)); } diff --git a/bun.lock b/bun.lock index 5f151621f..abac9bfc2 100644 --- a/bun.lock +++ b/bun.lock @@ -129,6 +129,7 @@ "oxfmt": "^0.42.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-intl": "^10.1.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", @@ -469,6 +470,20 @@ "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.2.8", "", {}, "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g=="], + "@formatjs/bigdecimal": ["@formatjs/bigdecimal@0.2.0", "", {}, "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w=="], + + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.2.0", "", { "dependencies": { "@formatjs/bigdecimal": "0.2.0", "@formatjs/fast-memoize": "3.1.1", "@formatjs/intl-localematcher": "0.8.2" } }, "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.1", "", {}, "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.3", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.2.0", "@formatjs/icu-skeleton-parser": "2.1.3" } }, "sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.3", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.2.0" } }, "sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ=="], + + "@formatjs/intl": ["@formatjs/intl@4.1.5", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.2.0", "@formatjs/fast-memoize": "3.1.1", "@formatjs/icu-messageformat-parser": "3.5.3", "intl-messageformat": "11.2.0" } }, "sha512-ybF/NIB/sIgP2oAq6KdJD6TQgUEJQL7F/ry4d4AbJ6zV0dfkwpQqycnIgpvChM9wRQ6IMr3XEdFevNjMNlC4WA=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.2", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.1" } }, "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ=="], + "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], @@ -1407,6 +1422,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "intl-messageformat": ["intl-messageformat@11.2.0", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.2.0", "@formatjs/fast-memoize": "3.1.1", "@formatjs/icu-messageformat-parser": "3.5.3" } }, "sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -1789,6 +1806,8 @@ "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + "react-intl": ["react-intl@10.1.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.2.0", "@formatjs/icu-messageformat-parser": "3.5.3", "@formatjs/intl": "4.1.5", "intl-messageformat": "11.2.0" }, "peerDependencies": { "@types/react": "19", "react": "19" } }, "sha512-B4rVLYxYHNE8NgPF6eoNbXQZ9uk2KLN7cCVSz1UaEIVeMp2aCstYTOyN3zNY5K3dF5iReDNiCrPaGA6lNpoZxQ=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],