Skip to content

feat(i18n): add i18next foundation with English + zh-CN locales#508

Merged
jamiepine merged 15 commits intomainfrom
feat/i18n-foundation
Apr 20, 2026
Merged

feat(i18n): add i18next foundation with English + zh-CN locales#508
jamiepine merged 15 commits intomainfrom
feat/i18n-foundation

Conversation

@jamiepine
Copy link
Copy Markdown
Owner

@jamiepine jamiepine commented Apr 20, 2026

Closes #411.

Summary

  • Adds i18next + react-i18next + i18next-browser-languagedetector to @voicebox/app
  • Creates src/i18n/ with a side-effect init (no extra Provider — import from main.tsx)
  • Ships four starter locales:
    • 🇺🇸 English (en) — baseline, 554 keys
    • 🇨🇳 Simplified Chinese (zh-CN)
    • 🇹🇼 Traditional Chinese (zh-TW) — Taiwan vocabulary (儲存 / 匯入 / 伺服器 / 裝置 …)
    • 🇯🇵 Japanese (ja)
  • Adds a language selector dropdown in Settings → General (persists via localStorage)
  • Relative-date formatting (history, stories) respects the selected locale via date-fns

English stays the default. The detector auto-picks a matching locale when navigator.language matches, and a manual override is persisted under voicebox:lang.

Coverage

Essentially all user-visible surfaces are translated in this PR:

  • Settings tabs (General, Generation, GPU, Logs, Changelog, About)
  • ModelManagement + model storage picker
  • Sidebar nav + update badge
  • MainEditor top bar + import dialog
  • FloatingGenerateBox placeholders, buttons, and effects selector
  • Voices tab (table, search, inspector, create/edit dialog, audio sample panels)
  • Audio Channels tab (list, dialogs, device picker)
  • Stories tab (list, content, dialogs, toasts)
  • Effects tab (list, detail, chain editor, built-in preset names, dialogs, toasts)
  • History table (actions, delete/clear/import/effects dialogs, dropdown menu)
  • Profile cards, profile list empty/unsupported states
  • Date/relative-time strings in History and Stories rows

554 keys per locale, verified at parity (ja, zh-CN, zh-TW all match en exactly — zero missing, zero extra).

Key conventions

  • Hierarchical dot-keys by feature (settings.general.serverUrl.title, models.toast.deleted)
  • Technical terms kept untranslated: MLX, Whisper, Qwen, CUDA, MPS, engine names
  • Errors, toast titles, and button labels prioritized over tooltips/help text
  • <Trans> used only where inline elements must be preserved (<code>, <strong>, <a>)
  • SUPPORTED_LANGUAGES is a typed const tuple; adding a locale = one import + one entry in the tuple + one entry in resources. The dropdown and detector pick it up automatically.

Test plan

  • bun run typecheck — passes
  • bun run build — passes (app bundle)
  • vite dev — starts cleanly, i18n config loads
  • Key-parity validation: en / zh-CN / zh-TW / ja all at 554 keys with zero drift
  • Manual: Settings → General → switch between English / 简体中文 / 繁體中文 / 日本語 → confirm surfaces translate live
  • Manual: Reload and confirm choice persists
  • Manual: DevTools navigator.language = "ja" before first load → detector picks it
  • Manual: Open History → confirm relative dates render in the active locale (3分钟前 / 3 分鐘前 / 3 分前)

🤖 Generated with Claude Code

Installs i18next + react-i18next + language detector and wires up a
language selector in the General settings page. Extracts strings from
the highest-visibility surfaces: all settings tabs, model management,
sidebar nav, main editor, and the floating generate box. Remaining
strings (profile forms, history, stories/effects/voices/audio tabs)
can land in follow-up PRs.

Closes #411.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds application-wide internationalization: new i18n dependencies, an i18n bootstrap (en + zh-CN), startup i18n initialization, a LanguageSelect component, and replacement of many hard-coded UI strings with translation keys across the frontend.

Changes

Cohort / File(s) Summary
Dependencies & Entry
app/package.json, app/src/main.tsx
Added i18next, i18next-browser-languagedetector, react-i18next; app entry imports ./i18n to initialize i18n at startup.
i18n Core & Resources
app/src/i18n/index.ts, app/src/i18n/locales/en/translation.json, app/src/i18n/locales/zh-CN/translation.json
New i18n bootstrap with browser language detection, SUPPORTED_LANGUAGES/LanguageCode exports, fallbackLng: 'en', and comprehensive en/zh-CN translation JSON resources (namespaces, interpolation, pluralization).
Language UI
app/src/components/ServerTab/LanguageSelect.tsx
New exported LanguageSelect component that resolves current language and calls i18n.changeLanguage(); renders Select/SelectItem list from SUPPORTED_LANGUAGES.
Navigation & Layout
app/src/components/Sidebar.tsx, app/src/components/ServerTab/ServerTab.tsx
Tabs/nav switched from literal label to labelKey; labels/title/aria and update badge now use t(...); SettingsTab updated to use labelKey.
Generation & Editor UI
app/src/components/Generation/FloatingGenerateBox.tsx, app/src/components/MainEditor/MainEditor.tsx
Integrated useTranslation() and replaced placeholders, tooltips, aria-labels, button/toast texts with translation keys while preserving conditional/pending-state logic.
Server Settings — Model & Pages
app/src/components/ServerSettings/ModelManagement.tsx, app/src/components/ServerTab/{AboutPage,ChangelogPage,GeneralPage,GenerationPage,GpuPage,LogsPage}.tsx
Replaced static titles, buttons, badges, toasts, modal text and status strings with t(...)/Trans, added LanguageSelect into GeneralPage, and injected i18n into connection validation and endpoints labels.
History, Profiles, Stories & Misc UI
app/src/components/History/HistoryTable.tsx, app/src/components/VoiceProfiles/ProfileCard.tsx, app/src/components/StoriesTab/..., app/src/components/Sidebar.tsx, app/src/lib/utils/format.ts
Localized many dialogs, aria-labels, toast messages, placeholders and button labels; formatDate now supplies date-fns locale when zh-CN. No public API changes except new i18n exports and LanguageSelect.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant LocalStorage as localStorage
  participant i18n as i18n(init)
  participant ReactApp as React App
  participant Component as UI Component

  Browser->>i18n: import './i18n' (from main.tsx)
  i18n->>LocalStorage: read "voicebox:lang"
  alt cached language
    LocalStorage-->>i18n: return lang
  else no cache
    i18n->>Browser: read navigator.languages
    Browser-->>i18n: return preferred language
  end
  i18n->>i18n: init resources (en, zh-CN), set fallback 'en'
  i18n-->>ReactApp: initialization complete
  ReactApp->>Component: render
  Component->>i18n: useTranslation() -> t(...)
  Component-->>Browser: localized UI rendered
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nudged the strings from plain to keys,
Sprinkled en and zh so UI speaks with ease.
I hopped through menus, tooltips, titles too,
Now voices bloom in many shades and hue.
🥕 Hop, translate—little changes, big view.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(i18n): add i18next foundation with English + zh-CN locales' accurately describes the main change—introducing i18n infrastructure with two language locales.
Linked Issues check ✅ Passed The PR fulfills all coding requirements from issue #411: i18n foundation introduced with English default, hardcoded strings replaced with translation keys, zh-CN locale provided, maintainable structure established, and lightweight approach using i18next.
Out of Scope Changes check ✅ Passed All changes directly support the stated i18n objectives: library additions, initialization module, translation files, component localization across visible surfaces (Settings, navigation, generation, main editor, history), and supporting utilities. No unrelated changes detected.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/i18n-foundation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a205086. Configure here.

},
"updates": {
"title": "应用更新",
"devSuffix": "(开发版)",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chinese devSuffix translation missing leading space

Low Severity

The zh-CN devSuffix value is "(开发版)" but the English value is " (dev)" — note the leading space. The template string concatenates directly: `v${currentVersion}${isDev ? t('...devSuffix') : ''}`. Chinese users in dev mode will see v0.4.2(开发版) (no space) instead of the intended v0.4.2 (开发版).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a205086. Configure here.

setMode(checked ? 'remote' : 'local');
toast({
title: 'Setting updated',
title: t('settings.general.keepServerRunning.updatedTitle'),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Network access toast uses wrong translation key path

Low Severity

The network access toggle toast title uses t('settings.general.keepServerRunning.updatedTitle') — a key scoped under keepServerRunning. While the translated value ("Setting updated") happens to be correct, it semantically belongs to a different setting. If a future translator customizes the keepServerRunning toast title, the network access toast would unintentionally change too. A shared key like common.settingUpdated or one under networkAccess would be less fragile.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a205086. Configure here.

eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
}, [cudaDownloading, serverUrl, refetchCudaStatus, t]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding t to useEffect deps reconnects EventSource unnecessarily

Low Severity

The t function was added to the CUDA progress EventSource useEffect dependency array. Since t changes reference on every language switch, changing language during an active CUDA download will tear down and recreate the SSE connection, potentially dropping in-flight progress events. The t call inside the effect is only used for a fallback error string in a rare branch.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a205086. Configure here.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/components/ServerTab/GeneralPage.tsx (1)

253-258: ⚠️ Potential issue | 🟡 Minor

Fallback "Unknown" version string is stale after language change.

setCurrentVersion(t('common.unknown')) stores the translated string into state at the time of the error. If the user later switches language, the section still renders v<old-language-Unknown> until the component remounts. Consider storing a sentinel (e.g. null) and translating at render time:

🧹 Proposed fix
-  const [currentVersion, setCurrentVersion] = useState<string>('');
+  const [currentVersion, setCurrentVersion] = useState<string | null>('');
   const isDev = !import.meta.env?.PROD;

   useEffect(() => {
     platform.metadata
       .getVersion()
       .then(setCurrentVersion)
-      .catch(() => setCurrentVersion(t('common.unknown')));
-  }, [platform, t]);
+      .catch(() => setCurrentVersion(null));
+  }, [platform]);
+
+  const versionLabel = currentVersion ?? t('common.unknown');

…and use versionLabel in the description template below.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerTab/GeneralPage.tsx` around lines 253 - 258, The
fallback stores a translated string into state causing the displayed "Unknown"
to remain in the old language after i18n changes; update the useEffect that
calls platform.metadata.getVersion() / setCurrentVersion to set a sentinel (e.g.
null) on error instead of t('common.unknown'), keep currentVersion state as
nullable, and perform translation at render time (compute versionLabel =
currentVersion ? `v${currentVersion}` : t('common.unknown')) and use that
versionLabel in the description template so language switches update the UI
without remounting.
🧹 Nitpick comments (5)
app/src/components/ServerSettings/ModelManagement.tsx (1)

431-434: Variable shadowing: callback parameter t shadows useTranslation().t.

In the license derivation, the find callback parameter t shadows the t function from useTranslation() scoped to this component. No functional bug today (only .startsWith is called on it), but it's a subtle footgun if someone later adds a t('...') call inside that arrow. Rename the parameter:

♻️ Suggested change
-  const license =
-    hfModelInfo?.cardData?.license ||
-    hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
+  const license =
+    hfModelInfo?.cardData?.license ||
+    hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerSettings/ModelManagement.tsx` around lines 431 -
434, The callback parameter `t` in the `hfModelInfo?.tags?.find((t) =>
t.startsWith('license:'))` expression shadows the `t` translation function from
useTranslation(); rename the callback parameter (e.g., to `tag` or `tg`) so it
no longer collides with the component-scoped `t`, updating the expression where
`startsWith` is called (the `license` derivation that reads from
`hfModelInfo.cardData` and `hfModelInfo.tags`) to use the new name.
app/src/components/ServerTab/LanguageSelect.tsx (1)

11-28: Optional: type-narrow onValueChange and persist via detector.

onValueChange receives string but i18n.changeLanguage(value) only makes sense for codes in SUPPORTED_LANGUAGES. Minor typing + ergonomics refactor to reuse the exported LanguageCode union:

♻️ Suggested refactor
-import { SUPPORTED_LANGUAGES } from '@/i18n';
+import { SUPPORTED_LANGUAGES, type LanguageCode } from '@/i18n';
@@
-    <Select value={current} onValueChange={(value) => i18n.changeLanguage(value)}>
+    <Select
+      value={current}
+      onValueChange={(value) => i18n.changeLanguage(value as LanguageCode)}
+    >

Also note: the language detector caches selection under voicebox:lang automatically after changeLanguage, so no extra persistence is needed — good.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerTab/LanguageSelect.tsx` around lines 11 - 28, The
onValueChange handler in LanguageSelect accepts a general string but should be
narrowed to the exported LanguageCode union so only valid SUPPORTED_LANGUAGES
codes are passed to i18n.changeLanguage; update LanguageSelect to import/consume
the LanguageCode type and type the handler parameter as (value: LanguageCode) =>
void (or cast the incoming value to LanguageCode) before calling
i18n.changeLanguage(value), keeping SUPPORTED_LANGUAGES and i18n.changeLanguage
as the single source of truth (no extra persistence needed).
app/src/components/ServerTab/GenerationPage.tsx (1)

60-63: Use non-plural interpolation name for maxChunkChars to prevent accidental pluralization changes.

The { count: maxChunkChars } parameter activates i18next's pluralization resolver, which searches for value_one/value_other (and locale-specific plural categories) before falling back to value. Currently only value is defined, so rendering is correct. However, when translators later add plural forms, the behavior will silently change for all languages. Since the slider is bounded (100–5000), singular never applies.

Use a non-plural interpolation name instead:

♻️ Suggested change
-              {t('settings.generation.chunkLimit.value', { count: maxChunkChars })}
+              {t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}

and in en/translation.json:

-      "value": "{{count}} chars"
+      "value": "{{chars}} chars"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerTab/GenerationPage.tsx` around lines 60 - 63, The
translation call currently passes { count: maxChunkChars } to
t('settings.generation.chunkLimit.value'), which triggers i18next pluralization;
change the interpolation key to a non-plural name (e.g., { maxChunkChars }) in
the GenerationPage component where t(...) is called and update the corresponding
translation entry in translation.json to use the same non-plural placeholder
(e.g., "{{maxChunkChars}}") so plural resolution is not accidentally activated.
app/src/i18n/index.ts (1)

14-31: Add useSuspense: false to react-i18next config.

react-i18next defaults to useSuspense: true, which causes components using useTranslation() to suspend during render if translations aren't ready. The app has no <Suspense> boundary wrapping the root, so any suspension would crash. Since resources are bundled inline today, this works but becomes a trap if someone later switches to backend-loaded JSON. Set useSuspense: false explicitly:

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: en },
      'zh-CN': { translation: zhCN },
    },
    fallbackLng: 'en',
    supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
    nonExplicitSupportedLngs: true,
+   react: { useSuspense: false },
    interpolation: { escapeValue: false },
    detection: {
      order: ['localStorage', 'navigator'],
      lookupLocalStorage: 'voicebox:lang',
      caches: ['localStorage'],
    },
  });

One more note: with nonExplicitSupportedLngs: true and only zh-CN in supportedLngs, browsers reporting zh-TW or zh-HK will fall back to en (not zh-CN) since the base language zh is not explicitly listed. If broader Simplified Chinese support is intended, either add zh as an explicit language code or document this behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/i18n/index.ts` around lines 14 - 31, The i18n init call should
explicitly disable react-i18next suspense and optionally add a base 'zh' entry
to supported languages: update the i18n.init({...}) configuration used in this
file to include useSuspense: false (so components using useTranslation() won't
suspend without a Suspense boundary) and, if you want broader Simplified Chinese
matching, add 'zh' to SUPPORTED_LANGUAGES (or otherwise document that only
'zh-CN' is supported) so browsers reporting zh-TW/zh-HK don't fall back to
English; target the i18n.init invocation and the SUPPORTED_LANGUAGES mapping in
this module when making the changes.
app/src/components/ServerTab/GeneralPage.tsx (1)

20-43: Resolver captures stale translation string at mount; form validation won't update when language changes.

The resolver passed to useForm is captured once at mount and stored internally. If a user changes language via LanguageSelect while this page is open with an active validation error, the error message will display the previous language's translation until the field is re-validated.

Memoize the resolver on t and trigger re-validation on language changes:

+  const resolver = useMemo(
+    () => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
+    [t],
+  );
   const form = useForm<ConnectionFormValues>({
-    resolver: zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
+    resolver,
     defaultValues: { serverUrl },
   });
+  // Re-run validation when the locale changes so existing error messages get retranslated.
+  useEffect(() => {
+    if (form.formState.isSubmitted || form.formState.errors.serverUrl) {
+      form.trigger('serverUrl');
+    }
+  }, [t, form]);

(Add useMemo to the react import.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerTab/GeneralPage.tsx` around lines 20 - 43, Memoize
the zod resolver based on the translation function and trigger re-validation
when language changes: import useMemo and useEffect from react, create const
resolver = useMemo(() =>
zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
[t]), pass resolver to useForm({ resolver, ... }), and add a useEffect(() => {
form.trigger(); }, [t]); reference makeConnectionSchema, zodResolver, useForm
and form.trigger so the validation messages update when t changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/ServerTab/GeneralPage.tsx`:
- Around line 171-179: The toast in the onCheckedChange handler for the Network
Access toggle is using the wrong i18n key
(settings.general.keepServerRunning.updatedTitle); update the title to use
settings.general.networkAccess.updatedTitle in the onCheckedChange callback (the
same place that calls setMode and toast), and add the corresponding translation
entries for settings.general.networkAccess.updatedTitle to both
en/translation.json and zh-CN/translation.json so the toast shows the correct
localized title.

---

Outside diff comments:
In `@app/src/components/ServerTab/GeneralPage.tsx`:
- Around line 253-258: The fallback stores a translated string into state
causing the displayed "Unknown" to remain in the old language after i18n
changes; update the useEffect that calls platform.metadata.getVersion() /
setCurrentVersion to set a sentinel (e.g. null) on error instead of
t('common.unknown'), keep currentVersion state as nullable, and perform
translation at render time (compute versionLabel = currentVersion ?
`v${currentVersion}` : t('common.unknown')) and use that versionLabel in the
description template so language switches update the UI without remounting.

---

Nitpick comments:
In `@app/src/components/ServerSettings/ModelManagement.tsx`:
- Around line 431-434: The callback parameter `t` in the
`hfModelInfo?.tags?.find((t) => t.startsWith('license:'))` expression shadows
the `t` translation function from useTranslation(); rename the callback
parameter (e.g., to `tag` or `tg`) so it no longer collides with the
component-scoped `t`, updating the expression where `startsWith` is called (the
`license` derivation that reads from `hfModelInfo.cardData` and
`hfModelInfo.tags`) to use the new name.

In `@app/src/components/ServerTab/GeneralPage.tsx`:
- Around line 20-43: Memoize the zod resolver based on the translation function
and trigger re-validation when language changes: import useMemo and useEffect
from react, create const resolver = useMemo(() =>
zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
[t]), pass resolver to useForm({ resolver, ... }), and add a useEffect(() => {
form.trigger(); }, [t]); reference makeConnectionSchema, zodResolver, useForm
and form.trigger so the validation messages update when t changes.

In `@app/src/components/ServerTab/GenerationPage.tsx`:
- Around line 60-63: The translation call currently passes { count:
maxChunkChars } to t('settings.generation.chunkLimit.value'), which triggers
i18next pluralization; change the interpolation key to a non-plural name (e.g.,
{ maxChunkChars }) in the GenerationPage component where t(...) is called and
update the corresponding translation entry in translation.json to use the same
non-plural placeholder (e.g., "{{maxChunkChars}}") so plural resolution is not
accidentally activated.

In `@app/src/components/ServerTab/LanguageSelect.tsx`:
- Around line 11-28: The onValueChange handler in LanguageSelect accepts a
general string but should be narrowed to the exported LanguageCode union so only
valid SUPPORTED_LANGUAGES codes are passed to i18n.changeLanguage; update
LanguageSelect to import/consume the LanguageCode type and type the handler
parameter as (value: LanguageCode) => void (or cast the incoming value to
LanguageCode) before calling i18n.changeLanguage(value), keeping
SUPPORTED_LANGUAGES and i18n.changeLanguage as the single source of truth (no
extra persistence needed).

In `@app/src/i18n/index.ts`:
- Around line 14-31: The i18n init call should explicitly disable react-i18next
suspense and optionally add a base 'zh' entry to supported languages: update the
i18n.init({...}) configuration used in this file to include useSuspense: false
(so components using useTranslation() won't suspend without a Suspense boundary)
and, if you want broader Simplified Chinese matching, add 'zh' to
SUPPORTED_LANGUAGES (or otherwise document that only 'zh-CN' is supported) so
browsers reporting zh-TW/zh-HK don't fall back to English; target the i18n.init
invocation and the SUPPORTED_LANGUAGES mapping in this module when making the
changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 947356b1-69d4-4d4b-9e4e-a5556d583efe

📥 Commits

Reviewing files that changed from the base of the PR and between 5aa1677 and a205086.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • app/package.json
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/MainEditor/MainEditor.tsx
  • app/src/components/ServerSettings/ModelManagement.tsx
  • app/src/components/ServerTab/AboutPage.tsx
  • app/src/components/ServerTab/ChangelogPage.tsx
  • app/src/components/ServerTab/GeneralPage.tsx
  • app/src/components/ServerTab/GenerationPage.tsx
  • app/src/components/ServerTab/GpuPage.tsx
  • app/src/components/ServerTab/LanguageSelect.tsx
  • app/src/components/ServerTab/LogsPage.tsx
  • app/src/components/ServerTab/ServerTab.tsx
  • app/src/components/Sidebar.tsx
  • app/src/i18n/index.ts
  • app/src/i18n/locales/en/translation.json
  • app/src/i18n/locales/zh-CN/translation.json
  • app/src/main.tsx

Comment thread app/src/components/ServerTab/GeneralPage.tsx
jamiepine and others added 2 commits April 20, 2026 02:52
- `nonExplicitSupportedLngs: true` was normalizing `zh-CN` → `zh` in
  some code paths; since we have explicit `zh-CN` resources, swap it
  for `load: 'currentOnly'` which keeps the code as-is.
- `react: { useSuspense: false }` — react-i18next v17 defaults Suspense
  on, which can silently suspend components mid-switch and look like
  "nothing happens" to the user.
- Use `i18n.language` (the raw current code) instead of
  `resolvedLanguage` in the selector so the dropdown always mirrors
  what we just set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ProfileCard: "No description", "designed" badge, aria-labels, and
  delete dialog.
- HistoryTable: delete / clear-failed / import / effects dialogs.
- formatDate: switch date-fns `formatDistance` locale based on
  `i18n.language` so "5 minutes ago" becomes "5 分钟前" under zh-CN.
  HistoryTable now subscribes via useTranslation so the table
  re-renders when language flips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/components/History/HistoryTable.tsx (2)

893-939: ⚠️ Potential issue | 🟡 Minor

Effects dialog is only half-localized.

The title and description use t(...), but the "Source" label (Line 901), "Select source version" placeholder (Line 907), "Cancel" (Line 929), and "Applying..."/"Apply" (Line 935) are still hardcoded English. For a zh-CN user this dialog will render with mixed languages. Given the PR commit message states HistoryTable strings were localized, this looks like a missed spot rather than an intentional deferral.

Proposed fix
-              <label className="text-xs font-medium text-muted-foreground">Source</label>
+              <label className="text-xs font-medium text-muted-foreground">
+                {t('history.effectsDialog.source')}
+              </label>
               <Select
                 value={effectsSourceVersionId ?? ''}
                 onValueChange={(val) => setEffectsSourceVersionId(val || null)}
               >
                 <SelectTrigger className="h-8 text-xs">
-                  <SelectValue placeholder="Select source version" />
+                  <SelectValue placeholder={t('history.effectsDialog.sourcePlaceholder')} />
@@
-            <Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
-              Cancel
-            </Button>
+            <Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
+              {t('common.cancel')}
+            </Button>
             <Button
               onClick={handleApplyEffectsConfirm}
               disabled={applyingEffects || effectsChain.length === 0}
             >
-              {applyingEffects ? 'Applying...' : 'Apply'}
+              {applyingEffects
+                ? t('history.effectsDialog.applying')
+                : t('history.effectsDialog.apply')}
             </Button>

Corresponding keys will need to be added to en/translation.json (and zh-CN).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 893 - 939, The
dialog is half-localized: replace hardcoded strings in the Effects dialog with
translation calls and add keys to the locale files. Specifically, wrap "Source"
label, the SelectValue placeholder "Select source version", the Cancel button
text, and the Apply/Applying... texts (used in the Button that calls
handleApplyEffectsConfirm and depends on applyingEffects and effectsChain) with
t('history.effectsDialog.source'),
t('history.effectsDialog.selectSourcePlaceholder'),
t('history.effectsDialog.cancel'), and t('history.effectsDialog.apply') /
t('history.effectsDialog.applying') respectively; ensure components/elements
referenced (effectsDialogOpen, effectsSourceVersionId, EffectsChainEditor,
handleApplyEffectsConfirm, applyingEffects, effectsChain) use the new t() keys
and add corresponding keys to the translation JSONs for all locales.

465-483: ⚠️ Potential issue | 🟡 Minor

Remaining hardcoded English strings in the table surface.

Beyond the effects dialog, a number of user-visible strings in this component are still hardcoded and will not respect the selected language:

  • Line 465: empty state "No voice generations, yet..."
  • Lines 472, 482: failed-count label + pluralization and "Clearing..."/"Clear failed" button (prefer i18next plural t('history.failedCount', { count: failedCount }) over the manual ternary).
  • Lines 525–530, 592, 609, 625, 638, 647, 659, 676: aria-labels ("Generating speech for …", "Favorite"/"Unfavorite", "Toggle versions", "Retry generation", "Delete generation", "Cancel generation", "Actions", transcript aria-label).
  • Lines 568, 578: "Failed", "Loading model...", "Generating..." status text.
  • Lines 687, 694, 701, 705, 709, 717: dropdown menu items (Play / Export Audio / Export Package / Apply Effects / Regenerate / Delete).
  • Lines 774, 799: "active" badge and "You've reached the end".
  • All toast title/description strings in cancelGeneration, handleDownloadAudio, handleExportPackage, handleRetry, handleRegenerate, handleToggleFavorite, handleApplyEffectsConfirm, handleSwitchVersion, handleImportConfirm, handleClearFailedConfirm (lines 139–149, 253, 268, 297, 311, 324, 369–374, 387, 414–423, 446–455).

Since the commit message advertises HistoryTable as localized and the component now subscribes via useTranslation(), it would be worth finishing the pass (or narrowing the claim). Toasts and aria-labels in particular are important for accessibility parity across locales.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 465 - 483, The
HistoryTable component still contains many hardcoded user-facing strings;
replace them with i18next lookups using useTranslation() (e.g.,
t('history.empty'), t('history.failedCount', { count: failedCount })) and use
pluralization instead of ternaries; update button labels and state texts (e.g.,
replacing "Clearing..." / "Clear failed", "Failed", "Loading model...",
"Generating...") and all aria-label values (e.g., "Generating speech for …",
"Favorite"/"Unfavorite", "Toggle versions", "Retry generation", "Delete
generation", "Cancel generation", transcript aria-label) to use t(...) keys;
localize dropdown menu items (Play, Export Audio, Export Package, Apply Effects,
Regenerate, Delete), badges ("active") and end-of-list text ("You've reached the
end"); and replace all toast title/description strings emitted by
cancelGeneration, handleDownloadAudio, handleExportPackage, handleRetry,
handleRegenerate, handleToggleFavorite, handleApplyEffectsConfirm,
handleSwitchVersion, handleImportConfirm, handleClearFailedConfirm with t(...)
keys so toasts are localized; ensure keys support interpolation/plurals where
needed and update any tests/fixtures accordingly.
🧹 Nitpick comments (1)
app/src/components/History/HistoryTable.tsx (1)

469-473: Use i18next pluralization instead of manual ternary.

The locale file already defines history.clearFailedDialog.body_one/body_other. For consistency, this failed-count label (and similar spots) should rely on i18next's count plural rules rather than an inline English-only ternary, so zh-CN (and future locales with different plural rules) render correctly.

-              <span className="text-xs text-muted-foreground">
-                {failedCount} failed {failedCount === 1 ? 'generation' : 'generations'}
-              </span>
+              <span className="text-xs text-muted-foreground">
+                {t('history.failedCount', { count: failedCount })}
+              </span>

Same pattern applies to Line 447 in handleClearFailedConfirm where generation/generations is selected via a JS ternary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 469 - 473, Replace
the manual English-only ternary pluralization with i18next pluralization:
anywhere the UI uses failedCount with a ternary (e.g., the label rendering that
references failedCount and the function handleClearFailedConfirm), call the i18n
translator with the base key and count option (use the existing locale keys like
history.clearFailedDialog.body with count: failedCount so i18next will pick
body_one/body_other automatically) instead of computing
"generation"/"generations" in JS; update the render span that references
failedCount and the string in handleClearFailedConfirm to use
t('history.clearFailedDialog.body', { count: failedCount }) (or the project’s t
wrapper) so plural rules are handled by i18next.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/src/components/History/HistoryTable.tsx`:
- Around line 893-939: The dialog is half-localized: replace hardcoded strings
in the Effects dialog with translation calls and add keys to the locale files.
Specifically, wrap "Source" label, the SelectValue placeholder "Select source
version", the Cancel button text, and the Apply/Applying... texts (used in the
Button that calls handleApplyEffectsConfirm and depends on applyingEffects and
effectsChain) with t('history.effectsDialog.source'),
t('history.effectsDialog.selectSourcePlaceholder'),
t('history.effectsDialog.cancel'), and t('history.effectsDialog.apply') /
t('history.effectsDialog.applying') respectively; ensure components/elements
referenced (effectsDialogOpen, effectsSourceVersionId, EffectsChainEditor,
handleApplyEffectsConfirm, applyingEffects, effectsChain) use the new t() keys
and add corresponding keys to the translation JSONs for all locales.
- Around line 465-483: The HistoryTable component still contains many hardcoded
user-facing strings; replace them with i18next lookups using useTranslation()
(e.g., t('history.empty'), t('history.failedCount', { count: failedCount })) and
use pluralization instead of ternaries; update button labels and state texts
(e.g., replacing "Clearing..." / "Clear failed", "Failed", "Loading model...",
"Generating...") and all aria-label values (e.g., "Generating speech for …",
"Favorite"/"Unfavorite", "Toggle versions", "Retry generation", "Delete
generation", "Cancel generation", transcript aria-label) to use t(...) keys;
localize dropdown menu items (Play, Export Audio, Export Package, Apply Effects,
Regenerate, Delete), badges ("active") and end-of-list text ("You've reached the
end"); and replace all toast title/description strings emitted by
cancelGeneration, handleDownloadAudio, handleExportPackage, handleRetry,
handleRegenerate, handleToggleFavorite, handleApplyEffectsConfirm,
handleSwitchVersion, handleImportConfirm, handleClearFailedConfirm with t(...)
keys so toasts are localized; ensure keys support interpolation/plurals where
needed and update any tests/fixtures accordingly.

---

Nitpick comments:
In `@app/src/components/History/HistoryTable.tsx`:
- Around line 469-473: Replace the manual English-only ternary pluralization
with i18next pluralization: anywhere the UI uses failedCount with a ternary
(e.g., the label rendering that references failedCount and the function
handleClearFailedConfirm), call the i18n translator with the base key and count
option (use the existing locale keys like history.clearFailedDialog.body with
count: failedCount so i18next will pick body_one/body_other automatically)
instead of computing "generation"/"generations" in JS; update the render span
that references failedCount and the string in handleClearFailedConfirm to use
t('history.clearFailedDialog.body', { count: failedCount }) (or the project’s t
wrapper) so plural rules are handled by i18next.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 424633cd-d3d3-42c9-acd8-7775a47e0083

📥 Commits

Reviewing files that changed from the base of the PR and between 302e28e and dd4119e.

📒 Files selected for processing (5)
  • app/src/components/History/HistoryTable.tsx
  • app/src/components/VoiceProfiles/ProfileCard.tsx
  • app/src/i18n/locales/en/translation.json
  • app/src/i18n/locales/zh-CN/translation.json
  • app/src/lib/utils/format.ts
✅ Files skipped from review due to trivial changes (1)
  • app/src/i18n/locales/zh-CN/translation.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/i18n/locales/en/translation.json

jamiepine and others added 2 commits April 20, 2026 03:02
Covers the title + "New Story" button, empty states, story row
metadata (item count, updated time), the create/edit/delete dialogs,
and all toast notifications. Also handles StoryContent: "Select a
story" placeholder, search popover, "Export Audio" button, and the
"Generating N audios" pending indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the "..." action menu on both the History table (Play, Export
Audio, Export Package, Apply Effects, Regenerate, Delete) and on
individual story chat items (Play from here, Remove from Story).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/StoriesTab/StoryList.tsx`:
- Around line 230-234: The date formatting currently uses formatDate and
getDateLocale with i18n.language but only maps 'zh-CN' explicitly, causing other
languages to fall back to English; update getDateLocale (and any locale map used
by formatDate) to include additional locale mappings for languages your app
supports (e.g., 'en-US'→enUS, 'fr'→fr, 'de'→de, etc.), ensure i18n.language keys
are normalized to the map keys, and add a clear fallback (e.g., enUS) so
StoryList.tsx's aria-label uses localized date strings reliably for all
supported languages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a40331ca-5260-45a0-9966-553ac4de3e80

📥 Commits

Reviewing files that changed from the base of the PR and between dd4119e and 992ad96.

📒 Files selected for processing (4)
  • app/src/components/StoriesTab/StoryContent.tsx
  • app/src/components/StoriesTab/StoryList.tsx
  • app/src/i18n/locales/en/translation.json
  • app/src/i18n/locales/zh-CN/translation.json
✅ Files skipped from review due to trivial changes (2)
  • app/src/i18n/locales/en/translation.json
  • app/src/i18n/locales/zh-CN/translation.json

Comment on lines +230 to +234
aria-label={t('stories.row.ariaLabel', {
name: story.name,
count: story.item_count,
updated: formatDate(story.updated_at),
})}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the imported formatDate implementation to confirm whether it honors the app-selected i18n language.
# Expected: formatDate either accepts a locale parameter, uses the current i18n language, or documents why browser-locale formatting is intentional.

fd -a 'format\.(ts|tsx|js|jsx)$' app/src/lib/utils --exec sed -n '1,220p' {}
rg -n -C3 'formatDate\s*\(' app/src/lib/utils app/src/components

Repository: jamiepine/voicebox

Length of output: 5031


formatDate respects the app's i18n language setting—no hard-coding or browser locale reliance.

The implementation checks i18n.language and passes it to date-fns via getDateLocale(). However, localization coverage is limited: only 'zh-CN' has an explicit locale mapping; other language selections default to English formatting. Consider expanding locale mappings for additional languages if multilingual date formatting is a priority.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/StoriesTab/StoryList.tsx` around lines 230 - 234, The date
formatting currently uses formatDate and getDateLocale with i18n.language but
only maps 'zh-CN' explicitly, causing other languages to fall back to English;
update getDateLocale (and any locale map used by formatDate) to include
additional locale mappings for languages your app supports (e.g., 'en-US'→enUS,
'fr'→fr, 'de'→de, etc.), ensure i18n.language keys are normalized to the map
keys, and add a clear fallback (e.g., enUS) so StoryList.tsx's aria-label uses
localized date strings reliably for all supported languages.

jamiepine and others added 10 commits April 20, 2026 03:07
Covers EffectsList (title, "New Preset", section headers, preset
cards) and EffectsDetail (header buttons for Save / Save as Custom /
Delete, name/description fields, preview section, Save as Custom
dialog, all toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Effect type labels (Chorus/Flanger, Reverb, Delay, Compressor, Gain,
  High-Pass, Low-Pass, Pitch Shift) and every param label (LFO speed,
  Modulation depth, Threshold, Ratio, etc.) go through
  `effects.types.<type>.{label,params.<param>}` with the backend string
  as defaultValue fallback.
- Chain-level controls: "Load preset…", "Add effect…", "Clear",
  Power/Remove button titles.
- Built-in preset names + descriptions (Robotic, Radio, Echo Chamber,
  Deep Voice) are translated client-side; user-created presets keep
  their original names.

Backend keeps returning English — frontend intercepts and translates
via key lookup, defaulting to the backend string so unknown
effects/params don't break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProfileForm now routes its title, description, voice-source toggle
(Clone from audio / Built-in voice), field labels (Name, Description,
Language, Engine, Voice, Reference Text, Default Engine, Default
Effects), sample tabs (Upload / Record / System Audio), action
buttons, and every toast + Zod validation message through i18n.

Also covers the three AudioSample panels (Upload/Record/System) — the
choose-file / start-recording / start-capture call-to-actions, the
"N remaining" countdown, "Recording complete" / "Capture complete"
states, and the Play / Transcribe / Remove / Record Again buttons.

SampleList too — the "No samples yet" empty state, per-sample edit
mode, mini-player aria labels, Delete Sample dialog, and toasts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the "Audio Channels" title and "New Channel" button, the empty
state, per-channel section labels (Output Devices / Assigned Voices),
the Available Devices right pane with its three contextual hints, the
"No voices assigned" fallback, and both Create/Edit dialogs (titles,
descriptions, field labels, Select placeholders, and the "(default)"
badge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VoicesTab now translates the "Voices" title, search placeholder,
"New Voice" button, all six table column headers (Name, Language,
Generations, Samples, Effects, Channels), the avatar alt text, and
the per-row channel MultiSelect (placeholder + "(Default)" suffix).

VoiceInspector routes its form labels through the existing
`profileForm.fields.*` keys, has its own "Default Effects" hint and
avatar/save toasts, and reuses the ProfileForm Zod validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a zh-TW translation with Taiwan vocabulary conventions
(e.g. 預設 / 儲存 / 載入 / 匯入 / 匯出 / 設定 / 檔案 / 伺服器 / 裝置 / 網路).
Registers it alongside en and zh-CN; the language dropdown picks it up
automatically from SUPPORTED_LANGUAGES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
formatDate only mapped zh-CN, so history timestamps stayed in English for
ja and zh-TW users even after the rest of the UI translated. Extend the
switch to ja and zhTW from date-fns/locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bugs:
- GeneralPage: network access toast used the keep-server-running title key
  (wrong semantic scope). Add networkAccess.updatedTitle and use it.
- GeneralPage: fallback "Unknown" version was stored as a translated string
  in state, so it stayed stale across language switches. Store null, resolve
  the label at render time.
- GeneralPage: memoize the zod resolver on t and retrigger validation when
  the locale changes so existing error messages retranslate.
- GpuPage: adding t to the CUDA progress EventSource effect deps caused the
  SSE connection to be torn down and reopened on every language change,
  potentially dropping in-flight download events. Capture t in a ref.
- HistoryTable: Effects dialog still rendered English "Source" / "Select
  source version" / "Cancel" / "Apply" / "Applying..." — localize them.
- Locales: zh-CN / zh-TW / ja devSuffix was missing the leading space before
  "(开发版)"/"(開發版)"/"(開発版)", so dev builds rendered "v0.4.2(开发版)"
  instead of "v0.4.2 (开发版)".

Nits:
- ModelManagement: rename .find((t) => ...) callback param to avoid
  shadowing useTranslation().t.
- GenerationPage: rename chunkLimit.value interpolation key from count →
  chars so i18next doesn't silently activate pluralization if a translator
  later adds _one/_other forms.
- LanguageSelect: narrow onValueChange handler param to LanguageCode.

Key count now 559 across en/zh-CN/zh-TW/ja (added 4 effectsDialog keys
plus networkAccess.updatedTitle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jamiepine jamiepine merged commit a72ef81 into main Apr 20, 2026
2 checks passed
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.

Would you be open to an i18n foundation PR for the UI?

1 participant