From 514b75d22d06dac61f31caa891a29eb87ffaf3d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:28:41 -0700 Subject: [PATCH] fix(mobile): complete localized state copy --- config/localization-audit.md | 5 ++-- ...obile-localization-coverage-allowlist.json | 21 ++++++++++++++++ .../components/account-usage-state.test.ts | 24 ++++++++++++++++++- mobile/src/components/account-usage-state.ts | 11 +++++---- mobile/src/i18n/locales/en.json | 14 +++++++++++ mobile/src/i18n/locales/es.json | 16 ++++++++++++- mobile/src/i18n/locales/ja.json | 16 ++++++++++++- mobile/src/i18n/locales/ko.json | 16 ++++++++++++- mobile/src/i18n/locales/zh.json | 16 ++++++++++++- .../src/session/mobile-diff-comments.test.ts | 18 +++++++++++++- mobile/src/session/mobile-diff-comments.ts | 7 +++--- .../mobile-native-chat-render-data.test.ts | 22 ++++++++++++++++- .../session/mobile-native-chat-render-data.ts | 16 +++++++------ src/shared/native-chat-empty-state.ts | 20 ++-------------- 14 files changed, 179 insertions(+), 43 deletions(-) diff --git a/config/localization-audit.md b/config/localization-audit.md index 154c6fe55c5..daa46682199 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -50,8 +50,9 @@ pnpm run verify:localization-coverage ``` The gate scans the desktop renderer plus both mobile source roots. Mobile uses -`config/mobile-localization-coverage-allowlist.json`, which is also committed -empty. +`config/mobile-localization-coverage-allowlist.json`, which contains one reviewed +internal pairing-keychain diagnostic and three canonical diff-note envelope +labels sent to agents; none is translated UI copy. Verify mobile catalog references, conditional keys, interpolation options, protected technical literals, and non-empty translations. The same gate checks diff --git a/config/mobile-localization-coverage-allowlist.json b/config/mobile-localization-coverage-allowlist.json index 3d8a09b12b1..2d9e5f3ce0a 100644 --- a/config/mobile-localization-coverage-allowlist.json +++ b/config/mobile-localization-coverage-allowlist.json @@ -5,5 +5,26 @@ "text": "pairing keychain generation record is invalid", "dynamic": false, "count": 1 + }, + { + "filePath": "mobile/src/session/mobile-diff-comments.ts", + "kind": "user-visible-variable", + "text": "Scope: file", + "dynamic": false, + "count": 1 + }, + { + "filePath": "mobile/src/session/mobile-diff-comments.ts", + "kind": "user-visible-variable", + "text": "Lines:", + "dynamic": true, + "count": 1 + }, + { + "filePath": "mobile/src/session/mobile-diff-comments.ts", + "kind": "user-visible-variable", + "text": "Line:", + "dynamic": true, + "count": 1 } ] diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index 3f019ebfef4..a99098290bd 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { mobileI18n } from '../i18n/mobile-i18n' import { getInactiveProviderUsage, getUsageBarState, @@ -11,6 +12,12 @@ import { type ProviderRateLimits } from './account-usage-state' +const INITIAL_LOCALE = mobileI18n.language + +afterEach(async () => { + await mobileI18n.changeLanguage(INITIAL_LOCALE) +}) + function makeLimits(overrides: Partial = {}): ProviderRateLimits { return { provider: 'claude', @@ -181,6 +188,21 @@ describe('getWindowResetLabel', () => { ).toBe('Resets now') }) + it('localizes the complete reset countdown sentence', async () => { + await mobileI18n.changeLanguage('es') + + expect( + getWindowResetLabel( + makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }), + 'session', + now + ) + ).toBe('Se restablece en 3h 54m') + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now) + ).toBe('Se restablece ahora') + }) + it('reads the requested window only', () => { const limits = makeLimits({ session: makeWindow(now + hour) }) expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null) diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index bc6f81e5e36..2316235c220 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -5,12 +5,13 @@ // Pure state/selectors live here (no React Native imports) so they can be // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. -import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' +import { formatResetDuration } from '../../../src/shared/rate-limit-reset-format' import type { AccountsSnapshot, InactiveAccountUsage, ProviderRateLimits } from './accounts-snapshot' +import { t } from '@/i18n/mobile-i18n' export { AccountsSnapshotSchema, @@ -96,9 +97,8 @@ export function getUsageBarState( * or null when the window has no reset timestamp (so the UI degrades to * today's bars-only layout). * - * Why: shares formatResetCountdown with the desktop status-bar tooltip so the - * copy stays identical across surfaces. `now` is a parameter so the function - * stays pure and unit-testable. + * Why: shares compact duration rules with desktop while the surrounding mobile + * sentence remains localizable. `now` is a parameter for deterministic tests. */ export function getWindowResetLabel( limits: ProviderRateLimits | null, @@ -109,7 +109,8 @@ export function getWindowResetLabel( if (resetsAt == null) { return null } - return formatResetCountdown(resetsAt - now) + const duration = formatResetDuration(resetsAt - now) + return duration === 'now' ? t('accountUsage.resetNow') : t('accountUsage.resetIn', { duration }) } // Why: the usage UI must render for the system-default login, not only for diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 9fcebb0972e..3ad86dd3dd4 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -2213,6 +2213,20 @@ "selectAll": "Select All" } }, + "nativeChat": { + "empty": { + "title": "Start a chat with {{agentLabel}}", + "subtitle": "Ask {{agentLabel}} to inspect code, explain output, or make a change." + }, + "error": { + "title": "Could not load conversation", + "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." + } + }, + "accountUsage": { + "resetNow": "Resets now", + "resetIn": "Resets in {{duration}}" + }, "session": { "quickCommand": { "runFailed": "Couldn't run {{commandLabel}}" diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index f629e83f363..0d61090f75d 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1540,7 +1540,7 @@ "0dIGtV8": "Línea: {{value0}}", "ZueS87g": "Líneas: {{value0}}-{{value1}}", "hLNcWyo": "Alcance: archivo", - "57DUHqU": "el agent", + "57DUHqU": "un agente", "0hhZYns": "No se puede abrir en sesión", "1Z6vya0": "No se pueden cargar archivos", "FuhqCI4": "Esperando el escritorio...", @@ -2208,5 +2208,19 @@ "copy": "Copiar", "selectAll": "Seleccionar todo" } + }, + "nativeChat": { + "empty": { + "title": "Inicia un chat con {{agentLabel}}", + "subtitle": "Pide a {{agentLabel}} que inspeccione código, explique resultados o haga un cambio." + }, + "error": { + "title": "No se pudo cargar la conversación", + "subtitle": "No se pudo leer la transcripción. Vuelve al terminal para seguir trabajando." + } + }, + "accountUsage": { + "resetNow": "Se restablece ahora", + "resetIn": "Se restablece en {{duration}}" } } diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index eed03972894..b42071611a6 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1540,7 +1540,7 @@ "0dIGtV8": "行: {{value0}}", "ZueS87g": "行数: {{value0}}-{{value1}}", "hLNcWyo": "スコープ: ファイル", - "57DUHqU": "agent", + "57DUHqU": "エージェント", "0hhZYns": "セッション中に開けません", "1Z6vya0": "ファイルをロードできません", "FuhqCI4": "デスクトップを待っています...", @@ -2208,5 +2208,19 @@ "copy": "コピー", "selectAll": "すべて選択" } + }, + "nativeChat": { + "empty": { + "title": "{{agentLabel}} とチャットを開始", + "subtitle": "{{agentLabel}} にコードの確認、出力の説明、変更を依頼できます。" + }, + "error": { + "title": "会話を読み込めませんでした", + "subtitle": "トランスクリプトを読み取れませんでした。ターミナルに戻って作業を続けてください。" + } + }, + "accountUsage": { + "resetNow": "今すぐリセット", + "resetIn": "{{duration}}後にリセット" } } diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 31b106ac68f..260f0f6d618 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1540,7 +1540,7 @@ "0dIGtV8": "줄: {{value0}}", "ZueS87g": "줄: {{value0}}-{{value1}}", "hLNcWyo": "범위: 파일", - "57DUHqU": "대리인", + "57DUHqU": "에이전트", "0hhZYns": "세션에서 열 수 없습니다.", "1Z6vya0": "파일을 로드할 수 없습니다.", "FuhqCI4": "데스크톱을 기다리는 중...", @@ -2208,5 +2208,19 @@ "copy": "복사", "selectAll": "모두 선택" } + }, + "nativeChat": { + "empty": { + "title": "{{agentLabel}}와 채팅 시작", + "subtitle": "{{agentLabel}}에게 코드 검사, 출력 설명 또는 변경 작업을 요청하세요." + }, + "error": { + "title": "대화를 불러올 수 없음", + "subtitle": "대화 기록을 읽을 수 없습니다. 계속 작업하려면 터미널로 돌아가세요." + } + }, + "accountUsage": { + "resetNow": "지금 재설정", + "resetIn": "{{duration}} 후 재설정" } } diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 4e982fa510d..dfc402f9ebc 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1538,7 +1538,7 @@ "0dIGtV8": "行: {{value0}}", "ZueS87g": "行: {{value0}}-{{value1}}", "hLNcWyo": "范围:文件", - "57DUHqU": "agent 人", + "57DUHqU": "智能体", "0hhZYns": "无法在会话中打开", "1Z6vya0": "无法加载文件", "FuhqCI4": "等待桌面...", @@ -2205,5 +2205,19 @@ "copy": "复制", "selectAll": "全选" } + }, + "nativeChat": { + "empty": { + "title": "与 {{agentLabel}} 开始聊天", + "subtitle": "让 {{agentLabel}} 检查代码、解释输出或进行更改。" + }, + "error": { + "title": "无法加载对话", + "subtitle": "无法读取对话记录。请切换回终端以继续工作。" + } + }, + "accountUsage": { + "resetNow": "立即重置", + "resetIn": "{{duration}}后重置" } } diff --git a/mobile/src/session/mobile-diff-comments.test.ts b/mobile/src/session/mobile-diff-comments.test.ts index e86d6e81bab..bfa708c1687 100644 --- a/mobile/src/session/mobile-diff-comments.test.ts +++ b/mobile/src/session/mobile-diff-comments.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { formatDiffComment as formatDesktopDiffComment } from '../../../src/shared/diff-comments-format' import type { DiffComment } from '../../../src/shared/types' +import { mobileI18n } from '../i18n/mobile-i18n' import { addMobileDiffComment, formatDiffComments, @@ -9,6 +11,12 @@ import { removeMobileDiffComments } from './mobile-diff-comments' +const INITIAL_LOCALE = mobileI18n.language + +afterEach(async () => { + await mobileI18n.changeLanguage(INITIAL_LOCALE) +}) + function comment(overrides: Partial & Pick): DiffComment { const { id, ...rest } = overrides return { @@ -123,6 +131,14 @@ describe('mobile diff comments', () => { ) }) + it('keeps the agent envelope canonical when the mobile UI is translated', async () => { + const note = comment({ id: 'a', startLine: 2, lineNumber: 4 }) + await mobileI18n.changeLanguage('es') + + expect(mobileI18n.t('m.ZueS87g', { value0: 2, value1: 4 })).toBe('Líneas: 2-4') + expect(formatDiffComments([note])).toBe(formatDesktopDiffComment(note)) + }) + it('formats file-level notes with file scope', () => { expect(formatDiffComments([comment({ id: 'a', lineNumber: 0 })])).toBe( ['File: src/app.ts', 'Scope: file', 'User comment: "check this"'].join('\n') diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts index 0452d50ac72..b204cba19c5 100644 --- a/mobile/src/session/mobile-diff-comments.ts +++ b/mobile/src/session/mobile-diff-comments.ts @@ -1,5 +1,4 @@ import type { DiffComment, DiffReviewScope } from '../../../src/shared/types' -import { t } from '@/i18n/mobile-i18n' export type CreateMobileDiffCommentInput = { worktreeId: string @@ -36,10 +35,10 @@ export function formatDiffComment(c: DiffComment): string { .replace(/\n/g, '\\n') const locationLabel = c.lineNumber === 0 - ? t('m.hLNcWyo') + ? 'Scope: file' : c.startLine !== undefined && c.startLine !== c.lineNumber - ? t('m.ZueS87g', { value0: c.startLine, value1: c.lineNumber }) - : t('m.0dIGtV8', { value0: c.lineNumber }) + ? `Lines: ${c.startLine}-${c.lineNumber}` + : `Line: ${c.lineNumber}` if (!isMarkdownComment(c)) { return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n') } diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 4dabff8f600..9d18a30bcbe 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -1,10 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mobileI18n } from '../i18n/mobile-i18n' import { buildMobileNativeChatData, mobileNativeChatEmptyState } from './mobile-native-chat-render-data' +const INITIAL_LOCALE = mobileI18n.language + +afterEach(async () => { + await mobileI18n.changeLanguage(INITIAL_LOCALE) +}) + function assistant(id: string, text: string): NativeChatMessage { return { id, @@ -37,6 +44,19 @@ describe('mobileNativeChatEmptyState', () => { ) }) + it('localizes the complete empty-state templates', async () => { + await mobileI18n.changeLanguage('es') + + expect(mobileNativeChatEmptyState('waiting-session', null)).toEqual({ + title: 'Inicia un chat con un agente', + subtitle: 'Pide a un agente que inspeccione código, explique resultados o haga un cambio.' + }) + expect(mobileNativeChatEmptyState('error', 'claude')).toEqual({ + title: 'No se pudo cargar la conversación', + subtitle: 'No se pudo leer la transcripción. Vuelve al terminal para seguir trabajando.' + }) + }) + it('prefers the provided error message over the default subtitle', () => { expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index ed9c9eb339f..8418c8c32c1 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -1,8 +1,5 @@ import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' -import { - formatNativeChatEmptyStateCopy, - type NativeChatEmptyStateCopy -} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' @@ -26,10 +23,15 @@ export function mobileNativeChatEmptyState( // agent is still starting up. case 'waiting-session': case 'ready': - return formatNativeChatEmptyStateCopy('empty', agentLabel) + return { + title: t('nativeChat.empty.title', { agentLabel }), + subtitle: t('nativeChat.empty.subtitle', { agentLabel }) + } case 'error': { - const copy = formatNativeChatEmptyStateCopy('error', agentLabel) - return error ? { ...copy, subtitle: error } : copy + return { + title: t('nativeChat.error.title'), + subtitle: error || t('nativeChat.error.subtitle') + } } default: return null diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts index 2797648afd0..aa416fa1b43 100644 --- a/src/shared/native-chat-empty-state.ts +++ b/src/shared/native-chat-empty-state.ts @@ -1,8 +1,5 @@ -// Canonical English copy for the native-chat empty/loading/error states, shared -// by the desktop renderer (as its i18n fallback strings) and the mobile app -// (used directly — mobile ships English only) so the two surfaces never drift. -// `{{value0}}` is the agent label; each caller substitutes it (i18n on desktop, -// `formatNativeChatEmptyStateCopy` on mobile). +// Canonical English fallback copy for the desktop native-chat states. +// `{{value0}}` is the agent label substituted by the renderer i18n layer. export type NativeChatEmptyStateCopy = { title: string; subtitle: string } @@ -24,16 +21,3 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = { subtitle: 'This terminal is not running a recognized coding agent.' } } as const satisfies Record - -/** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. - * For platforms without an i18n layer (mobile). */ -export function formatNativeChatEmptyStateCopy( - kind: keyof typeof NATIVE_CHAT_EMPTY_STATE_COPY, - agentLabel: string -): NativeChatEmptyStateCopy { - const copy = NATIVE_CHAT_EMPTY_STATE_COPY[kind] - return { - title: copy.title.replaceAll('{{value0}}', agentLabel), - subtitle: copy.subtitle.replaceAll('{{value0}}', agentLabel) - } -}