mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(mobile): complete localized state copy
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}後にリセット"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}} 후 재설정"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user