Skip to content

feat(i18n): add Simplified Chinese UI localization - #152

Open
alanpeng wants to merge 20 commits into
deer-flow:mainfrom
alanpeng:feat/i18n-pr
Open

feat(i18n): add Simplified Chinese UI localization#152
alanpeng wants to merge 20 commits into
deer-flow:mainfrom
alanpeng:feat/i18n-pr

Conversation

@alanpeng

@alanpeng alanpeng commented Aug 22, 2026

Copy link
Copy Markdown

Summary

Adds a lightweight i18n layer to the desktop app with Simplified Chinese as the first non-English locale. No new dependencies — the dictionary is pure-data TypeScript, and the runtime is a React context + hook.

What changes

  • i18n core (packages/ui/src/lib/i18n): message dictionary (en + zh) with plural/param templates, I18nProvider + useI18n hook, language preference persisted to settings.
  • Language switching: desktop Settings → General language selector, applied at startup and hot-switched at runtime (via the command layer).
  • Localized surfaces: native menu (including role items), command palette, feature reminders, error toasts, thread playground (message area, tools, models, dialogs), file-system tree view, and the shared-thread web viewer.
  • English is unchanged: the en dictionary restores provider-detected copy verbatim — every string rendered with the English locale is byte-identical to today's hardcoded text.
  • Tests: render-level tests wrapped in I18nProvider; plural-template tests.

Review fixes

  • Persisted language restored at startup — the bun process reads llm-space-app-lang from the local-storage mirror before the native menu is read, so menus and error copy no longer come back in the OS locale after a restart.
  • Role menu items localized — Electrobun's fixed-English role labels (app/edit/window submenus) now come from the dictionary.
  • Remaining hardcoded surfaces localized — thread-playground toasts and the web viewer chrome (thread-viewer, not-found) resolve through the shared messages tree; a new getMessages() helper serves non-React consumers (stores, imperative toasts).
  • Test-DOM leak fixed — the i18n provider tests restore the fake global Event after the suite.
  • languageChanged RPC message replaced by a setLanguage command routed through the command layer.
  • Minor items: memoized provider context value, plural one/other branch tests, locale-registry + zh-* fallback documentation.

Notes

  • The language set is a registry, not just dictionaries: adding a locale also touches LANGUAGES (picker + Lang union), locale detection (isZhLocale/resolveInitialLang), langToTimeago, the native menu's locale mapping, and isAppLang (startup restore) — see the LANGUAGES doc in packages/ui/src/lib/i18n/messages.ts.
  • Every zh-* variant (zh, zh-CN, zh-TW, zh-Hans, …) resolves to the single zh locale, whose text is Simplified Chinese — including zh-TW, until a Traditional-Chinese locale ships.
  • The web viewer renders display-only strings in the same way as the desktop.

alanpeng and others added 16 commits August 22, 2026 07:33
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…tionary

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The update service and native menu are macOS-only concepts on upstream
main, but the guards themselves shipped from the Windows base. Remove
them so this branch is i18n-only: platform flows match upstream verbatim,
and the darwin branches were no-ops anyway.
…t invoke

The share menu items now call useI18n(), so plain function invocation
(no render context) and unwrapped trees crash. Render the items through
I18nProvider like the parent wiring test does; keep bun's process-wide
mock.module registration away from @llm-space/ui/lib/i18n so later test
files still resolve the real module.
@foreleven

Copy link
Copy Markdown
Collaborator

I verified this PR at b61ad95 against the current main (69349e1). The core language switch works, but I found several blockers before merge.

Blocking findings

  1. The persisted language is not restored in the Bun process at startup.
    The renderer restores llm-space-app-lang, but menu.ts and locales.ts initialize from the OS locale. onLanguageChanged only runs after an explicit user switch. On a zh_CN system, I switched the UI to English and restarted: the renderer correctly came back in English, while the Bun-side native menu/error locale still initializes as Chinese until the user switches again.

  2. The native menu is only partially localized.
    Role-only items such as Undo, Redo, Cut, Copy, Hide, Minimize, and Toggle Full Screen do not receive translated labels. Electrobun supplies fixed English labels for those roles, so the Chinese menu remains mixed-language.

  3. Some surfaces named in the PR description remain hardcoded in English.

    • Thread Playground error toasts in thread-store.ts (for example around lines 389, 502, 581, and 1080).
    • Shared viewer chrome in thread-viewer.tsx and not-found.tsx, including failure text, Open/fullscreen labels, Shared thread, Untitled thread, and the not-found page.
  4. The merged test suite has a PR-specific regression.
    packages/ui/tests/lib/i18n/provider.test.tsx calls installReactTestDom() without invoking the returned restore(). This leaks the fake global Event and causes the newly merged message-scroll regression test to fail with:

    TypeError: Argument 1 (event) to EventTarget.dispatchEvent must be an instance of Event
    

    This reproduces in the full suite on the clean merge result.

Additional issues

  • The claim that another locale requires only a new dictionary file is not currently true: LANGUAGES, MESSAGES, locale resolution, timeago mapping, and the desktop RPC type also require code changes.
  • All zh-* locales, including zh-TW, resolve to Simplified Chinese.
  • The PR mentions plural-template tests, but the tests cover generic placeholder replacement rather than actual plural branches such as templateCount.
  • languageChanged is sent directly over RPC instead of going through the repository Command layer.
  • I18nProvider recreates setLang and its context value on every render; stabilizing these is important because many hot message/tool rows now consume this context.

Verification performed

  • Full lint: passed
  • Full typecheck: passed
  • Web production build: passed
  • Real Electrobun CEF desktop build/start: passed, no renderer console errors
  • Chinese startup, runtime switch to English, persistence, and restart: verified
  • Focused i18n + Thread Playground tests: 93 passed
  • Merge with current main: clean, no conflicts
  • Full merged suite: 785 passed / 3 failed; one failure is the test-global leak above, while the other two reproduce on current main in this local Python environment (jinja2 missing / Python version incompatibility).

…ng surfaces

Addresses the PR deer-flow#152 review: restore the persisted UI language in the bun
process before the native menu is read (menu and error copy no longer come
back in the OS locale across restarts), and give Electrobun's fixed-English
role items explicit labels so the zh menu is fully localized.

- bun: read llm-space-app-lang from the local-storage mirror at startup;
  restoreAppLocale() + preselectMenuLanguage() set the locale without
  rebuilding the menu (B1)
- menu: label every role item (app/edit/window submenus) from the i18n tree
  (B2)
- playground: toasts and web viewer chrome now resolve through the shared
  messages tree via a new getMessages() helper for non-React consumers (B3)
- provider.test: capture and restore the fake test DOM so the i18n suite no
  longer leaks a global Event into other suites (B4)
- languageChanged RPC message replaced by a setLanguage command through the
  command layer (minor b); I18nProvider context value memoized (minor a);
  plural one/other branch tests (minor c); locale-registry and zh-* -> zh-CN
  documentation (minor d/e)

Co-Authored-By: Claude <noreply@anthropic.com>
@alanpeng

Copy link
Copy Markdown
Author

Thanks for the thorough review — all 4 blockers and the 5 minor items are addressed in commit 8443eac (pushed to this PR).

Blockers

  1. Persisted language not restored at startup — fixed. The bun process now reads llm-space-app-lang from its LocalStorageManager mirror in start-desktop-app.ts (before the menu is built), then applies it via restoreAppLocale() + preselectMenuLanguage(). preselectMenuLanguage sets the module locale without rebuilding the menu, so startup doesn't need a window/menu to exist yet. Covered by new unit tests in apps/desktop/src/bun/app/locales.test.ts and commands.test.ts.
  2. Role menu items stayed English — fixed. All Electrobun role items (app: Hide/Hide Others/Show All; edit: Undo…Select All; window: Minimize/Bring All to Front/Toggle Full Screen) now carry explicit labels from the dictionary; the role still wires the OS behavior. The _editSubmenu helper now receives Messages instead of reading a stale module constant.
  3. Hardcoded toasts + web viewer chrome — fixed. All 9 toast.error sites in thread-store.ts resolve through the shared tree via a new getMessages() helper (reads the persisted language at call time, for non-React consumers). The web viewer (thread-viewer.tsx, not-found.tsx) and both bare <NotFound /> routes in App.tsx are wrapped in the shared I18nProvider and render from a new top-level viewer message section (en + zh).
  4. Test-DOM leak in provider.test.tsx — fixed. installReactTestDom()'s returned restore() now runs in afterAll, so the fake global Event no longer leaks into other suites.

Minors

  • (a) I18nProvider context value is memoized (useCallback + useMemo) — consumers re-render only when the language actually changes.
  • (b) The languageChanged RPC message is gone; the switch travels as a setLanguage command through the command layer (shared/commands.ts + executeCommandInBun, with a palette blacklist entry and dictionary labels).
  • (c) messages.test.ts now exercises the real one/other branches (including 0other) with formatString, in both locales.
  • (d) Documented that every zh-* variant resolves to Simplified Chinese until a Traditional locale ships (isZhLocale docstring).
  • (e) PR body corrected — adding a locale is a registry change (LANGUAGES, detection, timeago map, menu mapping, isAppLang), not just a dictionary file; the registry is documented on LANGUAGES in messages.ts.

Verification: bun run lint (0 warnings) and full tsc --noEmit pass; packages/ui suite 107/107, desktop bun-side suite passes apart from the pre-existing ssh-host-key failures (fail on a clean tree too — environment, not this PR); apps/web production build passes.

@foreleven

Copy link
Copy Markdown
Collaborator

Two setLanguage tests fail on a zh-locale machine

Thanks for the great work on this PR! While re-reviewing I ran the desktop test suite locally (macOS, system locale zh_CN) and hit failures that CI (en locale) doesn't catch:

bun test ./apps/desktop/src/bun/commands.test.ts

(fail) executeCommandInBun setLanguage > applies the locale and rebuilds the native menu
  expect(MENU_REBUILD_COUNT.count).toBe(1)  // Received: 0
(fail) executeCommandInBun setLanguage > switching back to en clears the Chinese locale
  expect(MENU_REBUILD_COUNT.count).toBe(2)  // Received: 1

Root cause: _menuLang in apps/desktop/src/bun/app/menu.ts is initialized at module-load time from the OS locale:

let _menuLang: "en" | "zh" = isChineseLocale() ? "zh" : "en";

On a machine whose OS display language is Chinese, _menuLang starts as "zh", so the first test's setMenuLanguage("zh") hits the if (_menuLang === lang) return; early-return and never calls setApplicationMenu — the rebuild counter stays at 0. The second test then also under-counts.

The tests don't control the menu's initial language, so their outcome depends on the machine the suite runs on. Suggested fix: reset both pieces of module state deterministically in the test setup, e.g.

import { preselectMenuLanguage, setAppLocale } from "./app/locales";
// or via the imported locales module
setAppLocale("en");
preselectMenuLanguage("en");
MENU_REBUILD_COUNT.count = 0;

before each setLanguage test (and note preselectMenuLanguage currently also early-returns when the language already matches — resetting to a known state avoids relying on either branch).

Also flagged separately in the review: several en labels changed ASCII ... → typographic ("Check for Updates...", "Settings...", "New from Examples...", etc.), which conflicts with the "en copy byte-identical" claim in the PR description. Worth either reverting those or adjusting the claim.

alanpeng and others added 2 commits August 22, 2026 17:34
thread-share-button.test.tsx installed installReactTestDom() at module
scope but never ran restore(), so the fake global Event could leak into
a later suite that dispatched real events on the same worker thread
(CI hit this as the message-scroll TypeError on PR deer-flow#152; main is immune
because its version of the file installs no DOM).

provider.test.tsx mirrored window.localStorage onto globalThis without
restoring it — same leak class. Capture the previous value and put it
back (or delete the mirror) in afterAll, matching the restore
discipline of the other installers.

Co-Authored-By: Claude <noreply@anthropic.com>
…I ellipsis in en copy

The setLanguage tests in commands.test.ts failed on zh-locale machines:
the menu module seeds _menuLang from the OS locale at load time, so a
zh_CN machine starts at 'zh' and setMenuLanguage('zh') early-returned,
leaving the rebuild counter at 0. Reset both pieces of module state
(setAppLocale('en') + preselectMenuLanguage('en') + counter) in
beforeEach, and make the second test drive both transitions itself
instead of relying on the first test's rebuild count.

Also revert 12 en menu/command labels that had drifted from upstream's
ASCII '...' to typographic ellipsis ('Check for Updates...', 'Settings...',
'New from Examples...', etc.) — the en copy stays byte-identical to
main. Strings where upstream genuinely uses a typographic ellipsis
(Loading…, Getting version…, Codex…) are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
@alanpeng

Copy link
Copy Markdown
Author

Both issues are fixed in commit 1f0ac16 (pushed to this PR).

1. zh-locale-dependent setLanguage tests

The tests now reset both pieces of module state in beforeEach:

beforeEach(() => {
  setAppLocale("en");
  preselectMenuLanguage("en");
  MENU_REBUILD_COUNT.count = 0;
});

so they no longer depend on what the OS locale seeded into _menuLang at module load. I also made the second test drive both transitions itself (zh → rebuild 1, then en → rebuild 2) instead of relying on the first test's counter — each test is now deterministic in isolation.

Verification note: I'm on Windows, where getOsLocale() reads the real UI culture and can't be flipped. To prove the fix works on a zh-seeded machine, I ran a throwaway probe that calls setAppLocale("zh") before importing ./app/menu (so _menuLang seeds to "zh" exactly as on a zh_CN macOS) and then executes the same setLanguage flow with the beforeEach reset. It passes 2/2 with the fix, and — after temporarily commenting out the preselectMenuLanguage("en") reset — reproduces your exact failures (expected 1, received 0 / expected 2, received 1), confirming the simulation is faithful. The probe was removed afterwards; the shipped tests are commands.test.ts (13 pass, including app/locales.test.ts).

2. ASCII ... in en labels

Reverted the 12 menu/command labels that had drifted from upstream's ASCII ellipsis: Check for Updates..., Settings..., New from Examples..., Import from Files..., Share..., Command Palette... (menu) and New from Examples..., Import from Files..., Import Langfuse Export..., Onboard..., Share..., Check for Updates... (commands). The en copy is byte-identical to main again. Strings where upstream genuinely uses a typographic ellipsis — Loading…, Getting version {version} ready to install…, (Claude Code, Cursor, Codex…), Save to…, etc. — are untouched.

…oned the locale registry

`timeago.js/lib/lang/zh_CN` is a compiled-CJS deep path; under Vite's
CJS interop its default export resolves to the `{ __esModule, default }`
exports object instead of the formatter function. Re-registering it
overwrote the working ESM `zh_CN` that timeago.js's own barrel had
already installed, so any Chinese-locale relative-time render
(`format(ts, "zh_CN")`) threw "TypeError: localeFunc is not a function".
With no error boundary above it, opening a thread with run history —
e.g. restoring a Deep Research tab — unmounted the entire React tree,
leaving a blank white/black window.

The barrel import every call site already does registers both mapped
locales (`en_US`, `zh_CN`), so drop our re-registration entirely and
pin the contract with a test covering both ids.

Co-Authored-By: Claude <noreply@anthropic.com>
@alanpeng

Copy link
Copy Markdown
Author

Found and fixed a serious bug in this PR's code: it could blank the entire app window when opening a thread that has run history while the UI language is Chinese (3918b11).

Root cause

i18n/index.tsx re-registered zh_CN via the CJS deep path:

import zh_CN from "timeago.js/lib/lang/zh_CN";
register("zh_CN", zh_CN);

That deep path is a compiled-CJS module (exports.default = …; exports.__esModule = true). Under Vite's CJS interop its default export resolves to the whole exports object, not the formatter function. Registering it overwrote the working ESM zh_CN that timeago.js's own barrel had already installed at import time.

From then on, every Chinese-locale relative-time render threw:

TypeError: localeFunc is not a function

With no error boundary above _RunHistoryItem, React unmounted the entire tree → a blank window (white under light/system theme, black under dark). English sessions never hit it: "en" maps to an id we never overrode, so the registry's en_US fallback kept working — which is why this survived testing.

Reproduction was deterministic: restore a Deep Research tab (any thread with run history) in a Chinese-locale session → white screen within seconds of window open.

Fix

Drop our re-registration entirely — importing timeago.js (which every format(ts, langToTimeago(lang)) call site does) evaluates the package barrel, which already registers both mapped locales (en_US, zh_CN) as real ESM functions. Added a contract test pinning that both ids format without throwing after the i18n module loads.

Verification

Built app (no HMR), Chinese session, scratch data home with a tab restoring into the deep-research thread:

  • before: TypeError: localeFunc is not a function immediately after NavigationCompleted, empty document
  • after: zero errors, run history renders normally

Note for reviewers: bun's CJS interop unwraps __esModule correctly, so bun test cannot catch this class of bug — it only manifests under Vite's interop in a real renderer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants