Skip to content

Ppw stack/汉化支持 - #6627

Closed
ppw-stack wants to merge 26 commits into
stablyai:mainfrom
ppw-stack:ppw-stack/汉化支持

Hidden character warning

The head ref may contain hidden characters: "ppw-stack/\u6c49\u5316\u652f\u6301"
Closed

ppw-stack wants to merge 26 commits into
stablyai:mainfrom
ppw-stack:ppw-stack/汉化支持

Conversation

@ppw-stack

Copy link
Copy Markdown
Contributor

概述

为 Orca 移动端添加中文(zh)汉化能力,与桌面端共享同一份翻译资源。

关键决策:使用 i18next + react-i18next(与桌面端同款),通过自定义 <T> React Native 包装组件实现渐进式迁移,通过 Metro watchFolders 直接引用桌面端 zh.json(零冗余)。

主要改动

新增(7 个文件)

  • mobile/src/i18n/init.ts — i18next 启动模块(幂等)
  • mobile/src/i18n/I18nProvider.tsx — react-i18next <I18nextProvider> 薄封装
  • mobile/src/i18n/T.tsx — 核心抽象:<T> RN 包装组件,children-as-fallback 模式
  • mobile/src/i18n/useT.ts — 组件外用的翻译 hook
  • mobile/src/i18n/README.md — i18n 模块说明文档
  • mobile/app/language-settings.tsx — 语言切换 UI 屏(系统 / 英文 / 中文)
  • 3 个组件抽取文件(troubleshoot-sections.tsx, pair-scan-steps.tsx, custom-key-modal-data.ts)— 仅为满足 max-lines 限制

修改(约 40 个文件)

  • 23 个 Expo Router 屏全部接入 <T>
  • 14 个组件(5 个命名组件 + 9 个面板组件)接入 <T>
  • mobile/app/_layout.tsx — 启动时初始化 i18n(带 try/catch 降级)
  • mobile/app/settings.tsx — 新增 Language 入口
  • mobile/src/storage/preferences.ts — 新增 loadUiLanguage/saveUiLanguage
  • mobile/metro.config.js — watchFolders: ['..'] 支持跨包引用
  • mobile/package.json — 新增 3 个依赖(i18next, react-i18next, expo-localization)

架构亮点

  • 零翻译冗余:移动端直接引用桌面端 src/renderer/src/i18n/locales/zh.json
  • children-as-fallback:<T> 接收 string literal 作为 fallback,编译期保证缺失 key 时显示英文
  • 类型安全:MobileUiLanguage 严格枚举('system' | 'en' | 'zh'),无运行时传错空间
  • 独立设置:移动端语言偏好与桌面端不互通(按用户决策,避免协议变更)

测试

  • ✅ pnpm test 145 文件 / 973 通过 / 2 跳过(无回归)
  • ✅ pnpm oxlint 0 errors
  • ✅ pnpm oxfmt --check 0 errors
  • ✅ pnpm exec tsc --noEmit 无新增 errors(pre-existing tsconfig baseUrl 弃用警告无关)

待手动验证(评审者)

按 plan Task 17 步骤:

  • 启动 Expo dev client → 进入 Settings → Language 屏可见
  • 选 "中文(简体)" → UI 立即切换,无需重启
  • 重启 App → 中文选择被保留(持久化工作)
  • 修改桌面端 src/renderer/src/i18n/locales/zh.json → 移动端热重载生效

已知限制(v1 范围外)

  1. 无 RN 组件单测:本仓库无 RN 组件测试基础设施(无 @testing-library/react-native)。<T> 和 useT 跳过单测,依赖手动 smoke check。详见 mobile/src/i18n/README.md。
  2. 部分组件未迁移:约 16 个 mobile/src/components/ 下的子组件仍有硬编码英文(如 AccountUsage, PickerModal, MobileDictationSetupSheet)。这些在 v1 范围外,需要后续单独 PR。
  3. CLI 不支持 i18n:与桌面端一致,CLI 工具保持英文。

Spec / Plan

  • 设计 spec:docs/superpowers/specs/2026-06-28-mobile-i18n-design.md
  • 实施 plan:docs/superpowers/plans/2026-06-28-mobile-i18n.md

ppw-stack and others added 26 commits June 28, 2026 18:25
- Force-added under docs/superpowers/specs/ which is gitignored by default
- The path matches the brainstorming skill's default spec location convention
- Date-prefixed filename for chronological ordering
17 tasks across 5 phases:
- Phase 1: Foundation (deps, Metro, AsyncStorage prefs)
- Phase 2: i18n core (init, I18nProvider, T, useT) with TDD
- Phase 3: Settings UI (language screen + entry + root wiring)
- Phase 4: Screen migration (23 .tsx files in 6 grouped commits)
- Phase 5: Verification (tests, lint, manual smoke)

Force-added under docs/superpowers/plans/ which is gitignored by default.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements Chinese (zh) localization for the Orca mobile app (Expo/React Native). It updates metro.config.js to resolve packages from both mobile and workspace-root node_modules and watch the monorepo root, enabling the desktop zh.json/en.json catalogs to be imported directly. New modules mobile/src/i18n/init.ts, I18nProvider.tsx, T.tsx, and useT.ts provide i18next initialization with language resolution, a React Native text wrapper, and a translation hook. UI language is persisted in AsyncStorage via new loadUiLanguage/saveUiLanguage functions in preferences.ts. A new LanguageSettingsScreen lets users switch languages. The root layout now initializes i18n on mount before rendering the navigator. Two modules (troubleshoot-sections.tsx, custom-key-modal-data.ts, pair-scan-steps.tsx) are extracted from their parent screens. All existing screen UI strings are migrated from Text to the new T component across approximately 30 files.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the feature, but it misses several required template sections, including Screenshots, AI Review Report, Security Audit, and Notes. Reformat it to the template and add the missing sections, especially screenshots, the testing checklist, a cross-platform AI review report, security audit, and notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 4.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title mentions localization support, but 'Ppw stack' is cryptic and doesn't clearly describe the main change. Rename it to a specific summary like 'Add Chinese localization support to the mobile app'.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 16

🧹 Nitpick comments (2)
mobile/package.json (1)

46-47: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Align react and react-dom in mobile/package.json. react is ranged as ^19.2.6 while react-dom is pinned to 19.2.6; keeping the same spec avoids future install drift.

mobile/src/storage/preferences.test.ts (1)

151-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the write-failure case here too.

saveUiLanguage() is intentionally non-throwing when AsyncStorage.setItem rejects (mobile/src/storage/preferences.ts:203-212), but this suite only verifies the success path. Add one rejection test so a future refactor can't block language switching on a storage error.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fd392509-0a4e-4f9c-a88a-9b37113f6cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 6b1a449 and b545197.

⛔ Files ignored due to path filters (1)
  • mobile/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (42)
  • docs/superpowers/plans/2026-06-28-mobile-i18n.md
  • docs/superpowers/specs/2026-06-28-mobile-i18n-design.md
  • mobile/app/_layout.tsx
  • mobile/app/about.tsx
  • mobile/app/browser-settings.tsx
  • mobile/app/h/[hostId]/accounts.tsx
  • mobile/app/h/[hostId]/history/[worktreeId].tsx
  • mobile/app/h/[hostId]/index.tsx
  • mobile/app/h/[hostId]/session/[worktreeId].tsx
  • mobile/app/h/[hostId]/tasks.tsx
  • mobile/app/index.tsx
  • mobile/app/language-settings.tsx
  • mobile/app/notifications.tsx
  • mobile/app/pair-confirm.tsx
  • mobile/app/pair-scan-steps.tsx
  • mobile/app/pair-scan.tsx
  • mobile/app/pair.tsx
  • mobile/app/settings.tsx
  • mobile/app/terminal-settings.tsx
  • mobile/app/troubleshoot-sections.tsx
  • mobile/app/troubleshoot.tsx
  • mobile/app/voice-settings.tsx
  • mobile/metro.config.js
  • mobile/package.json
  • mobile/src/components/CustomKeyModal.tsx
  • mobile/src/components/MobileDiffReviewFileSummary.tsx
  • mobile/src/components/MobileDiffReviewFooter.tsx
  • mobile/src/components/MobileHtmlPreview.tsx
  • mobile/src/components/NewWorktreeModal.tsx
  • mobile/src/components/ProtocolBlockScreen.tsx
  • mobile/src/components/TerminalShortcutSettings.tsx
  • mobile/src/components/TextInputModal.tsx
  • mobile/src/components/VoiceModelList.tsx
  • mobile/src/components/custom-key-modal-data.ts
  • mobile/src/i18n/I18nProvider.tsx
  • mobile/src/i18n/README.md
  • mobile/src/i18n/T.tsx
  • mobile/src/i18n/init.test.ts
  • mobile/src/i18n/init.ts
  • mobile/src/i18n/useT.ts
  • mobile/src/storage/preferences.test.ts
  • mobile/src/storage/preferences.ts

Comment thread mobile/app/_layout.tsx
Comment on lines +49 to +63
useEffect(() => {
void (async () => {
try {
const lang = await loadUiLanguage()
await initI18n(lang)
} catch (error) {
console.warn('[i18n] init failed; defaulting to en', error)
try {
await initI18n('en')
} catch (fallbackError) {
console.error('[i18n] en fallback also failed', fallbackError)
}
}
setReady(true)
})()

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The English fallback cannot actually recover from a failed first init.

This code assumes initI18n('en') will retry after the first initI18n(lang) throws, but mobile/src/i18n/init.ts memoizes instance before awaiting .init(...). After the first failure, the fallback call returns that already-assigned singleton instead of reinitializing in English, so boot can continue with broken i18n state.

Comment thread mobile/app/_layout.tsx
Comment on lines +203 to +213
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Register language-settings with headerShown: false.

mobile/app/language-settings.tsx renders its own top row/back button like the other settings subpages, but this stack omits that route. Expo Router will fall back to the default stack header there, which gives the new screen a duplicate header.

Proposed fix
             <Stack.Screen name="browser-settings" options={{ headerShown: false }} />
+            <Stack.Screen name="language-settings" options={{ headerShown: false }} />
             <Stack.Screen name="voice-settings" options={{ headerShown: false }} />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="language-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />

Comment on lines +55 to +70
<T style={styles.heading}>Browser</T>
</View>

<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.groupHeading}>LINKS</Text>
<Text style={styles.groupDescription}>
<T style={styles.groupHeading}>LINKS</T>
<T style={styles.groupDescription}>
Choose where HTTP(S) links tapped in terminal output open.
</Text>
</T>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setPickerOpen(true)}
>
<Globe size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open terminal links</Text>
<T style={styles.rowLabel}>Open terminal links</T>

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

<T> is a no-op here without i18nKey.

mobile/src/i18n/T.tsx only calls t(...) when i18nKey is set. These replacements still render the English children verbatim, so this screen will not localize.

Suggested fix
-        <T style={styles.heading}>Browser</T>
+        <T i18nKey="Browser" style={styles.heading}>Browser</T>
...
-        <T style={styles.rowLabel}>Open terminal links</T>
+        <T i18nKey="Open terminal links" style={styles.rowLabel}>Open terminal links</T>

Comment on lines +12 to +16
const LANGUAGE_CHOICES: Array<{ value: MobileUiLanguage; label: string }> = [
{ value: 'system', label: 'Follow System' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文(简体)' }
]

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

These labels still bypass i18next.

mobile/src/i18n/T.tsx only translates when i18nKey is set, and the choice rows are plain Text, so this screen will keep showing hardcoded strings instead of switching with the selected locale. Give the heading and each option a real translation key.

Also applies to: 40-52

Comment on lines +27 to +31
const onPick = useCallback(async (next: MobileUiLanguage) => {
setLang(next)
await saveUiLanguage(next)
const i18n = getI18n()
await i18n.changeLanguage(resolveLanguage(next))

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don't block the language switch on AsyncStorage.

saveUiLanguage() is best-effort persistence, but awaiting it here delays changeLanguage() on every tap. Switch i18n first and persist in the background so the UI updates immediately.

Proposed fix
   const onPick = useCallback(async (next: MobileUiLanguage) => {
     setLang(next)
-    await saveUiLanguage(next)
     const i18n = getI18n()
     await i18n.changeLanguage(resolveLanguage(next))
+    void saveUiLanguage(next)
   }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onPick = useCallback(async (next: MobileUiLanguage) => {
setLang(next)
await saveUiLanguage(next)
const i18n = getI18n()
await i18n.changeLanguage(resolveLanguage(next))
const onPick = useCallback(async (next: MobileUiLanguage) => {
setLang(next)
const i18n = getI18n()
await i18n.changeLanguage(resolveLanguage(next))
void saveUiLanguage(next)

Comment on lines +77 to +78
{item.isReviewed ? <T style={styles.reviewedPill}>Reviewed</T> : null}
{item.changedSinceReview ? <T style={styles.stalePill}>Changed</T> : null}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

These new status labels won't localize.

These T usages are also children-only, so Reviewed, Changed, Stale, and Hunk render as raw English instead of going through i18next. Add explicit i18nKey values for each label.

Also applies to: 101-101, 115-115, 125-125

onPress={onCancel}
>
<Text style={styles.cancelText}>Cancel</Text>
<T style={styles.cancelText}>Cancel</T>

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cancel still won't translate in this shared modal.

T only translates when i18nKey is provided, so this new label remains hard-coded English across every TextInputModal caller.

Comment on lines +69 to +95
describe('initI18n', () => {
it('initializes with the requested language', async () => {
const i18n = await initI18n('zh')
expect(i18n.language).toBe('zh')
})

it('initializes with the resolved system language', async () => {
vi.mocked(Localization.getLocales).mockReturnValue([
{
languageCode: 'zh',
languageTag: 'zh-CN',
regionCode: 'CN',
textDirection: 'ltr',
decimalSeparator: '.',
digitGroupingSeparator: ','
}
] as unknown as Array<{ languageCode: string }>)
const i18n = await initI18n('system')
expect(i18n.language).toBe('zh')
})

it('returns the same instance on repeated calls', async () => {
const a = await initI18n('en')
const b = await initI18n('zh')
expect(a).toBe(b)
})
})

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset the module singleton between initI18n cases.

Line 71 seeds the module-scoped singleton from mobile/src/i18n/init.ts:44-66, so the later cases never exercise a fresh initialization. The assertion at Line 86 will still pass even if the system-language branch regresses, as long as the first test already initialized 'zh'. Re-import the module per test or add a reset hook before each initI18n case.

Comment thread mobile/src/i18n/init.ts
Comment on lines +46 to +66
export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> {
// Why: single-caller contract — _layout.tsx is the only invoker at boot,
// so this short-circuit is sufficient. A concurrent caller would race past
// the guard and trip i18next's "already initialized" error.
if (instance) {
return instance
}
const resolved = resolveLanguage(language)
instance = i18next.createInstance()
await instance.use(initReactI18next).init({
lng: resolved,
fallbackLng: FALLBACK_LANGUAGE,
defaultNS: 'translation',
interpolation: { escapeValue: false },
returnEmptyString: false,
resources: {
zh: { translation: zh },
en: { translation: en }
}
})
return instance

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't publish the singleton before init completes.

Line 54 stores instance before the awaited init(). If that call rejects, the English fallback in mobile/app/_layout.tsx:37-59 immediately hits if (instance) and reuses a half-initialized object instead of retrying; a second caller can observe the same broken instance before initialization finishes. Keep an in-flight promise separate from the ready singleton, and clear/reset state on failure.

Suggested fix
 let instance: I18nInstance | null = null
+let initPromise: Promise<I18nInstance> | null = null

 export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> {
-  // Why: single-caller contract — _layout.tsx is the only invoker at boot,
-  // so this short-circuit is sufficient. A concurrent caller would race past
-  // the guard and trip i18next's "already initialized" error.
   if (instance) {
     return instance
   }
-  const resolved = resolveLanguage(language)
-  instance = i18next.createInstance()
-  await instance.use(initReactI18next).init({
-    lng: resolved,
-    fallbackLng: FALLBACK_LANGUAGE,
-    defaultNS: 'translation',
-    interpolation: { escapeValue: false },
-    returnEmptyString: false,
-    resources: {
-      zh: { translation: zh },
-      en: { translation: en }
-    }
-  })
-  return instance
+  if (initPromise) {
+    return initPromise
+  }
+
+  initPromise = (async () => {
+    const resolved = resolveLanguage(language)
+    const next = i18next.createInstance()
+
+    try {
+      await next.use(initReactI18next).init({
+        lng: resolved,
+        fallbackLng: FALLBACK_LANGUAGE,
+        defaultNS: 'translation',
+        interpolation: { escapeValue: false },
+        returnEmptyString: false,
+        resources: {
+          zh: { translation: zh },
+          en: { translation: en }
+        }
+      })
+      instance = next
+      return next
+    } catch (error) {
+      instance = null
+      initPromise = null
+      throw error
+    }
+  })()
+
+  return initPromise
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> {
// Why: single-caller contract — _layout.tsx is the only invoker at boot,
// so this short-circuit is sufficient. A concurrent caller would race past
// the guard and trip i18next's "already initialized" error.
if (instance) {
return instance
}
const resolved = resolveLanguage(language)
instance = i18next.createInstance()
await instance.use(initReactI18next).init({
lng: resolved,
fallbackLng: FALLBACK_LANGUAGE,
defaultNS: 'translation',
interpolation: { escapeValue: false },
returnEmptyString: false,
resources: {
zh: { translation: zh },
en: { translation: en }
}
})
return instance
let instance: I18nInstance | null = null
let initPromise: Promise<I18nInstance> | null = null
export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> {
if (instance) {
return instance
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const resolved = resolveLanguage(language)
const next = i18next.createInstance()
try {
await next.use(initReactI18next).init({
lng: resolved,
fallbackLng: FALLBACK_LANGUAGE,
defaultNS: 'translation',
interpolation: { escapeValue: false },
returnEmptyString: false,
resources: {
zh: { translation: zh },
en: { translation: en }
}
})
instance = next
return next
} catch (error) {
instance = null
initPromise = null
throw error
}
})()
return initPromise
}

Comment thread mobile/src/i18n/T.tsx
Comment on lines +23 to +29
export function T({ i18nKey, children, values, ns, ...rest }: TProps) {
const { t } = useTranslation(ns ? [ns] : undefined)
if (i18nKey) {
const translated = t(i18nKey, { defaultValue: children, ...values })
return <Text {...rest}>{translated}</Text>
}
return <Text {...rest}>{children}</Text>

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Children-only <T> calls never translate.

When i18nKey is omitted, this returns children directly instead of consulting i18next. Screens already use <T>Tasks</T> (for example mobile/app/index.tsx:643-695), so those labels stay English after switching to zh. Either route the no-key path through t() or make i18nKey mandatory.

Proposed fix
 export function T({ i18nKey, children, values, ns, ...rest }: TProps) {
   const { t } = useTranslation(ns ? [ns] : undefined)
-  if (i18nKey) {
-    const translated = t(i18nKey, { defaultValue: children, ...values })
-    return <Text {...rest}>{translated}</Text>
-  }
-  return <Text {...rest}>{children}</Text>
+  const key = i18nKey ?? children
+  const translated = t(key, { defaultValue: children, ...values })
+  return <Text {...rest}>{translated}</Text>
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function T({ i18nKey, children, values, ns, ...rest }: TProps) {
const { t } = useTranslation(ns ? [ns] : undefined)
if (i18nKey) {
const translated = t(i18nKey, { defaultValue: children, ...values })
return <Text {...rest}>{translated}</Text>
}
return <Text {...rest}>{children}</Text>
export function T({ i18nKey, children, values, ns, ...rest }: TProps) {
const { t } = useTranslation(ns ? [ns] : undefined)
const key = i18nKey ?? children
const translated = t(key, { defaultValue: children, ...values })
return <Text {...rest}>{translated}</Text>
}

@ppw-stack

Copy link
Copy Markdown
Contributor Author

Closing this PR to re-design. Two fundamental issues that mean the current
implementation does not actually deliver Chinese localization:

  1. <T> without i18nKey is a no-op. mobile/src/i18n/T.tsx only calls
    i18next.t(...) when i18nKey is set, otherwise it just renders
    children verbatim. The bulk of the migration replaced <Text>Foo</Text>
    with <T>Foo</T> without i18nKey, so the wrapper is a no-op and the
    app still shows English when the user switches to 中文.

  2. Desktop zh.json has no mobile-specific keys. The shared catalog covers
    desktop menus, settings, and business logic (e.g. menu.settings: 设置).
    None of the 23 mobile screens' strings exist as keys, so even fixing the
    wrapper alone would not produce any visible translation.

Thanks to @coderabbitai for surfacing both issues. The ppw-stack/汉化支持
branch will be kept for reference; a v2 design with a real mobile.* key
namespace and an enforced <T> contract will follow in a new PR.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@ppw-stack Thanks for the detailed write-up — that makes sense, and closing this one for a redesign sounds like the right call.

Those two constraints are decisive:

  • mobile/src/i18n/T.tsx is effectively a pass-through unless i18nKey is provided.
  • The shared desktop locale catalog does not currently contain the mobile UI strings needed for the migrated screens.

A v2 with an enforced <T> contract plus a dedicated mobile.* namespace sounds like a much stronger foundation. If you'd like, I can also open a follow-up issue that captures these two blockers and the acceptance criteria for the redesign.

(。•́︿•̀。)🐇


✏️ Learnings added
Learnt from: ppw-stack
URL: https://github.com/stablyai/orca/pull/6627

Timestamp: 2026-06-28T17:26:57.052Z
Learning: In the Orca mobile app, `mobile/src/i18n/T.tsx` only performs translation when the `i18nKey` prop is provided; when `i18nKey` is omitted, the component renders `children` verbatim, so replacing `Text` with `T` without keys is a no-op for localization.

Learnt from: ppw-stack
URL: https://github.com/stablyai/orca/pull/6627

Timestamp: 2026-06-28T17:26:57.052Z
Learning: For the Orca mobile i18n redesign, the shared desktop translation catalog does not contain the mobile-specific strings needed by the migrated mobile screens, so using the desktop `zh.json` alone does not deliver visible Chinese localization for the mobile UI.

Learnt from: ppw-stack
URL: https://github.com/stablyai/orca/pull/6627

Timestamp: 2026-06-28T17:26:57.052Z
Learning: The abandoned mobile i18n PR for Orca is being redesigned on branch `ppw-stack/汉化支持`; the planned v2 direction is to introduce a dedicated `mobile.*` translation namespace and enforce correct `T` component usage rather than relying on keyless `T` wrappers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@ppw-stack ppw-stack closed this Jun 28, 2026
ppw-stack added a commit to ppw-stack/orca that referenced this pull request Jun 29, 2026
Replaces the v1 no-op <T> wrapper (closed at PR stablyai#6627) with a translate()
function pattern mirroring the desktop i18n flow (src/renderer/src/i18n/i18n.ts:43).
Every mobile screen, component, and modal now renders through the same
useTranslate() hook used by the desktop app.

What ships in this PR:
- mobile/src/i18n/{init,translate,useTranslate,I18nProvider}.{ts,tsx}
  + init test (F11 — translate() returns fallback when uninit)
- 1004 mobile.* keys added to all 5 locales (en, zh, es, ja, ko) with
  full parity. Single-brace {{var}} → {var} interpolation fixed in
  3 pair error keys (F1).
- All mobile screens migrated (app/about.tsx, browser-settings.tsx,
  notifications.tsx, troubleshoot.tsx, voice-settings.tsx,
  terminal-settings.tsx, pair-confirm.tsx, index.tsx,
  h/[hostId]/{index,accounts,history/[worktreeId],tasks,session/[worktreeId]}.tsx).
- All mobile components migrated (NewWorktreeModal, CustomKeyModal,
  MobileMarkdown, TerminalShortcutSettings, MobileDictationSetupSheet,
  WorktreeListRow, MobileDiffReview*, VoiceModelList, ProtocolBlockScreen,
  ConnectionLog, TextInputModal + 8 pr-sidebar files).
- Language picker at app/language-settings.tsx with race-safe async
  changeLanguage (F3).
- storage/preferences.ts: loadUiLanguage uses normalizeUiLanguage to
  coerce legacy or corrupt values to 'system' (F12).
- metro.config.js: narrowed watchFolders to src/shared + i18n locales
  to avoid hot-reload cache thrash (F6).
- config/scripts/audit-localization-coverage.mjs: extended to scan mobile/
  with allowlist for prop-driven strings (F4, P20).
- Desktop zh.json: full Chinese translation of 236 keys (F5). Japanese
  and Korean translations across all mobile.* blocks.

Mobile: 1004/1004 zh keys (60 byte-identical to en — all brand names).
Verified on Android emulator (Pixel, Android 14, x86_64):
- language switch via UI Settings → language-settings → 中/英
- persistence across app cold restart (AsyncStorage round-trip)
- all migrated screens render Chinese
- coverage gate passes (0 candidates, 198 allowlist entries with per-
  sub-block reasons)

Refs: supersedes stablyai#6627 (closed: <T> no-op bug, desktop had zero mobile.*
keys). PR2 plan: docs/superpowers/plans/2026-06-29-mobile-i18n-v2-pr2.md
Design spec: docs/superpowers/specs/2026-06-29-mobile-i18n-v2-design.md
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.

1 participant