summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-20 11:15:45 -0600
committerAdam <[email protected]>2026-01-20 17:58:06 -0600
commitf86c37f5799811d4e3a865c60e07c6fbd8293afd (patch)
treef21459958b71c838293b6bbb8252216bc8cc3335
parent9b7d9c8173c222c880cf731b859fc78fed5265fc (diff)
downloadopencode-f86c37f5799811d4e3a865c60e07c6fbd8293afd.tar.gz
opencode-f86c37f5799811d4e3a865c60e07c6fbd8293afd.zip
wip(app): i18n
-rw-r--r--packages/app/src/components/dialog-select-server.tsx2
-rw-r--r--packages/app/src/entry.tsx18
-rw-r--r--packages/app/src/i18n/en.ts4
-rw-r--r--packages/app/src/i18n/zh.ts3
-rw-r--r--specs/06-app-i18n-audit.md16
-rw-r--r--specs/07-ui-i18n-audit.md140
6 files changed, 171 insertions, 12 deletions
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index 0b3967b76..9b401afbd 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 8c4662926..c366ad27f 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 3d35e8f86..9487dc0ef 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 242d8a170..e066dbcd9 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 c933d842d..c4c735226 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 000000000..280818c00
--- /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).