fix(mobile): complete localized state copy

This commit is contained in:
Brennan Benson
2026-08-01 03:28:41 -07:00
parent 1abd6eae91
commit 514b75d22d
14 changed files with 179 additions and 43 deletions
+3 -2
View File
@@ -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
@@ -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
}
]
@@ -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> = {}): 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)
+6 -5
View File
@@ -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
+14
View File
@@ -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}}"
+15 -1
View File
@@ -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}}"
}
}
+15 -1
View File
@@ -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}}後にリセット"
}
}
+15 -1
View File
@@ -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}} 후 재설정"
}
}
+15 -1
View File
@@ -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}}后重置"
}
}
@@ -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<DiffComment> & Pick<DiffComment, 'id'>): 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')
+3 -4
View File
@@ -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')
}
@@ -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(
@@ -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
+2 -18
View File
@@ -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<string, NativeChatEmptyStateCopy>
/** 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)
}
}