From f86c37f5799811d4e3a865c60e07c6fbd8293afd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:15:45 -0600 Subject: [PATCH] wip(app): i18n --- .../src/components/dialog-select-server.tsx | 2 +- packages/app/src/entry.tsx | 18 ++- packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/zh.ts | 3 + specs/06-app-i18n-audit.md | 16 +- specs/07-ui-i18n-audit.md | 140 ++++++++++++++++++ 6 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 specs/07-ui-i18n-audit.md diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 0b3967b76b..9b401afbd7 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -179,7 +179,7 @@ export function DialogSelectServer() { type="text" label={language.t("dialog.server.add.url")} hideLabel - placeholder="http://localhost:4096" + placeholder={language.t("dialog.server.add.placeholder")} value={store.url} onChange={(v) => { setStore("url", v) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 8c4662926a..c366ad27f1 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -2,13 +2,25 @@ import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { Platform, PlatformProvider } from "@/context/platform" +import { dict as en } from "@/i18n/en" +import { dict as zh } from "@/i18n/zh" import pkg from "../package.json" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error( - "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", - ) + const locale = (() => { + if (typeof navigator !== "object") return "en" as const + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) return "zh" as const + } + return "en" as const + })() + + const key = "error.dev.rootNotFound" as const + const message = locale === "zh" ? zh[key] ?? en[key] : en[key] + throw new Error(message) } const platform: Platform = { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3d35e8f861..9487dc0ef6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -212,6 +212,7 @@ export const dict = { "dialog.server.empty": "No servers yet", "dialog.server.add.title": "Add a server", "dialog.server.add.url": "Server URL", + "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Could not connect to server", "dialog.server.add.checking": "Checking...", "dialog.server.add.button": "Add", @@ -310,6 +311,9 @@ export const dict = { "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", + "error.dev.rootNotFound": + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", + "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", "error.chain.unknown": "Unknown error", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 242d8a1705..e066dbcd99 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -211,6 +211,7 @@ export const dict = { "dialog.server.empty": "暂无服务器", "dialog.server.add.title": "添加服务器", "dialog.server.add.url": "服务器 URL", + "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "无法连接到服务器", "dialog.server.add.checking": "检查中...", "dialog.server.add.button": "添加", @@ -309,6 +310,8 @@ export const dict = { "error.page.report.discord": "在 Discord 上", "error.page.version": "版本: {{version}}", + "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html? 或者 id 属性拼写错了?", + "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?", "error.chain.unknown": "未知错误", diff --git a/specs/06-app-i18n-audit.md b/specs/06-app-i18n-audit.md index c933d842dc..c4c7352262 100644 --- a/specs/06-app-i18n-audit.md +++ b/specs/06-app-i18n-audit.md @@ -137,9 +137,9 @@ Completed (2026-01-20): File: `packages/app/src/components/dialog-select-server.tsx` -- Placeholder: `http://localhost:4096` +Completed (2026-01-20): -This is an example URL; you may choose to keep it as-is even after translating surrounding labels. +- Moved the placeholder example URL behind `dialog.server.add.placeholder` (value unchanged). ## Medium Priority: Context Modules @@ -193,14 +193,14 @@ Completed (2026-01-20): File: `packages/app/src/entry.tsx` -- Dev-only error string: "Root element not found..." +Completed (2026-01-20): -This is only thrown in DEV and is more of a developer diagnostic. Optional to translate. +- Localized the DEV-only root mount error via `error.dev.rootNotFound`. +- Selected locale using `navigator.languages` to match the app’s default detection. ## Prioritized Implementation Plan -1. Optional: `packages/app/src/components/dialog-select-server.tsx` placeholder example URL. -2. Optional: `packages/app/src/entry.tsx` dev-only root mount error. +No remaining work in `packages/app/` as of 2026-01-20. ## Suggested Key Naming Conventions @@ -223,10 +223,10 @@ Pages: - (none) Components: -- `packages/app/src/components/dialog-select-server.tsx` (optional URL placeholder) +- (none) Context: - (none) Utils: -- `packages/app/src/entry.tsx` (dev-only) +- (none) diff --git a/specs/07-ui-i18n-audit.md b/specs/07-ui-i18n-audit.md new file mode 100644 index 0000000000..280818c00c --- /dev/null +++ b/specs/07-ui-i18n-audit.md @@ -0,0 +1,140 @@ +# UI i18n Audit (Remaining Work) + +Scope: `packages/ui/` (and consumers: `packages/app/`, `packages/enterprise/`) + +Date: 2026-01-20 + +This report documents the remaining user-facing strings in `packages/ui/src` that are still hardcoded (not routed through a translation function), and proposes an i18n architecture that works long-term across multiple packages. + +## Current State + +- `packages/app/` already has i18n via `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`. +- `packages/ui/` is a shared component library used by: + - `packages/app/src/pages/session.tsx` (Session UI) + - `packages/enterprise/src/routes/share/[shareID].tsx` (shared session rendering) +- `packages/ui/` currently has **hardcoded English UI copy** in several components (notably `session-turn.tsx`, `session-review.tsx`, `message-part.tsx`). +- `packages/enterprise/` does not currently have an i18n system, so any i18n approach must be usable without depending on `packages/app/`. + +## Decision: How We Should Add i18n To `@opencode-ai/ui` + +Introduce a small, app-agnostic i18n interface in `packages/ui/` and keep UI-owned strings in UI-owned dictionaries. + +Why this is the best long-term shape: + +- Keeps dependency direction clean: `packages/enterprise/` (and any future consumer) can translate UI without importing `packages/app/` dictionaries. +- Avoids prop-drilling strings through shared components. +- Allows each package to own its strings while still rendering a single, coherent locale in the product. + +### Proposed Architecture + +1) **UI provides an i18n context (no persistence)** + +- Add `packages/ui/src/context/i18n.tsx`: + - Exports `I18nProvider` and `useI18n()`. + - Context value includes: + - `t(key, params?)` translation function (template interpolation supported by the consumer). + - `locale()` accessor for locale-sensitive formatting (Luxon/Intl). + - Context should have a safe default (English) so UI components can render even if a consumer forgets the provider. + +2) **UI owns UI strings (dictionaries live in UI)** + +- Add `packages/ui/src/i18n/en.ts` and `packages/ui/src/i18n/zh.ts`. +- Export them from `@opencode-ai/ui` via `packages/ui/package.json` exports (e.g. `"./i18n/*": "./src/i18n/*.ts"`). +- Use a clear namespace prefix for all UI keys to avoid collisions: + - Recommended: `ui.*` (e.g. `ui.sessionReview.title`). + +3) **Consumers merge dictionaries and provide `t`/`locale` once** + +- `packages/app/`: + - Keep `packages/app/src/context/language.tsx` as the source of truth for locale selection/persistence. + - Extend it to merge UI dictionaries into its translation table. + - Add a tiny bridge provider in `packages/app/src/app.tsx` to feed `useLanguage()` into `@opencode-ai/ui`'s `I18nProvider`. + +- `packages/enterprise/`: + - Add a lightweight locale detector (similar to `packages/app/src/context/language.tsx`), likely based on `Accept-Language` on the server and/or `navigator.languages` on the client. + - Merge `@opencode-ai/ui` dictionaries and (optionally) enterprise-local dictionaries. + - Wrap the share route in `I18nProvider`. + +### Key Naming Conventions (UI) + +- Prefer component + semantic grouping: + - `ui.sessionReview.title` + - `ui.sessionReview.diffStyle.unified` + - `ui.sessionReview.diffStyle.split` + - `ui.sessionReview.expandAll` + - `ui.sessionReview.collapseAll` + +- For `SessionTurn`: + - `ui.sessionTurn.steps.show` + - `ui.sessionTurn.steps.hide` + - `ui.sessionTurn.summary.response` + - `ui.sessionTurn.diff.more` (use templating: `Show more changes ({{count}})`) + - `ui.sessionTurn.retry.retrying` / `ui.sessionTurn.retry.inSeconds` / etc (avoid string concatenation that is English-order dependent) + - Status text: + - `ui.sessionTurn.status.delegating` + - `ui.sessionTurn.status.planning` + - `ui.sessionTurn.status.gatheringContext` + - `ui.sessionTurn.status.searchingCode` + - `ui.sessionTurn.status.searchingWeb` + - `ui.sessionTurn.status.makingEdits` + - `ui.sessionTurn.status.runningCommands` + - `ui.sessionTurn.status.thinking` + - `ui.sessionTurn.status.thinkingWithTopic` (template: `Thinking - {{topic}}`) + - `ui.sessionTurn.status.gatheringThoughts` + - `ui.sessionTurn.status.consideringNextSteps` (fallback) + +## Locale-Sensitive Formatting (UI) + +`SessionTurn` currently formats durations via Luxon `Interval.toDuration(...).toHuman(...)` without an explicit locale. + +When i18n is added: + +- Use `useI18n().locale()` and pass locale explicitly: + - Luxon: `duration.toHuman({ locale: locale(), ... })` (or set `.setLocale(locale())` where applicable). + - Intl numbers/currency (if added later): `new Intl.NumberFormat(locale(), ...)`. + +## Initial Hardcoded Strings (Audit Findings) + +These are the highest-impact UI surfaces to translate first. + +### 1) `packages/ui/src/components/session-review.tsx` + +- `Session changes` +- `Unified` / `Split` +- `Collapse all` / `Expand all` + +### 2) `packages/ui/src/components/session-turn.tsx` + +- Tool/task status strings (e.g. `Delegating work`, `Searching the codebase`) +- Steps toggle labels: `Show steps` / `Hide steps` +- Summary section title: `Response` +- Pagination CTA: `Show more changes ({{count}})` + +### 3) `packages/ui/src/components/message-part.tsx` + +Examples (non-exhaustive): + +- `Error` +- `Edit` +- `Write` +- `Type your own answer` +- `Review your answers` + +## Prioritized Implementation Plan + +1. Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. +2. Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them. +3. Wire `I18nProvider` into: + - `packages/app/src/app.tsx` + - `packages/enterprise/src/routes/share/[shareID].tsx` +4. Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. +5. Convert `packages/ui/src/components/message-part.tsx`. +6. Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy. + +## Notes / Risks + +- **SSR:** Enterprise share pages render on the server. Ensure the i18n provider works in SSR and does not assume `window`/`navigator`. +- **Key collisions:** Use a consistent `ui.*` prefix to avoid clashing with app keys. +- **Fallback behavior:** Decide whether missing keys should: + - fall back to English, or + - render the key (useful for catching missing translations).