fix(mobile): stabilize localized error and key handling

This commit is contained in:
Brennan Benson
2026-08-01 02:02:29 -07:00
parent 05d1f008ac
commit 351bb9cc52
18 changed files with 151 additions and 68 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ import {
type AccountsSnapshot,
type ProviderKey,
decodeAccountsSnapshot,
isInvalidAccountsSnapshotError,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
@@ -142,7 +143,7 @@ export default function AccountsScreen() {
setError(res.error.message)
}
} catch (e) {
if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') {
if (isInvalidAccountsSnapshotError(e)) {
rejectInvalidSnapshot()
} else {
setError(e instanceof Error ? e.message : String(e))
+1
View File
@@ -15,6 +15,7 @@ export type {
} from './account-usage-state'
export {
decodeAccountsSnapshot,
isInvalidAccountsSnapshotError,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
+4 -3
View File
@@ -8,6 +8,7 @@ import {
buildTerminalShortcutKey,
normalizeShortcutKeyInput,
TERMINAL_SHORTCUT_SPECIAL_KEYS,
TERMINAL_SHORTCUT_MODIFIER_LABELS,
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
@@ -60,9 +61,9 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
const [macroEnter, setMacroEnter] = useState(true)
const [previousVisible, setPreviousVisible] = useState(visible)
const shortcutModifierOptions = [
{ id: 'ctrl', label: t('m.OZLCEBo') },
{ id: 'alt', label: t('m.Mz-qIO4'), glyph: '⌥' },
{ id: 'shift', label: t('m.Dl9mAnI') }
{ id: 'ctrl', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.ctrl },
{ id: 'alt', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.alt, glyph: '⌥' },
{ id: 'shift', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.shift }
] satisfies readonly { id: TerminalShortcutModifier; label: string; glyph?: string }[]
const specialKeyGroups: { title: string; ids: string[]; columns: number }[] = [
{
@@ -15,6 +15,7 @@ import type {
export {
AccountsSnapshotSchema,
decodeAccountsSnapshot,
isInvalidAccountsSnapshotError,
ProviderRateLimitsSchema,
RateLimitRuntimeTargetSchema,
type AccountsSnapshot,
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { decodeAccountsSnapshot } from './accounts-snapshot'
import { mobileI18n } from '../i18n/mobile-i18n'
import { decodeAccountsSnapshot, isInvalidAccountsSnapshotError } from './accounts-snapshot'
const INITIAL_LOCALE = mobileI18n.language
afterEach(async () => {
await mobileI18n.changeLanguage(INITIAL_LOCALE)
})
function makeSnapshot(): unknown {
return {
@@ -81,6 +88,21 @@ function setPath(root: unknown, path: string[], value: unknown): void {
}
describe('decodeAccountsSnapshot', () => {
it('classifies an invalid snapshot independently of its localized message', async () => {
await mobileI18n.changeLanguage('es')
let thrown: unknown
try {
decodeAccountsSnapshot(null)
} catch (error) {
thrown = error
}
expect(isInvalidAccountsSnapshotError(thrown)).toBe(true)
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).not.toBe('Invalid accounts snapshot from host')
})
it('validates nested account/rate-limit state and preserves forward-compatible fields', () => {
const snapshot = decodeAccountsSnapshot(makeSnapshot())
+12 -1
View File
@@ -228,10 +228,21 @@ export type ClaudeAccountSummary = z.infer<typeof ClaudeAccountSummarySchema>
export type CodexAccountSummary = z.infer<typeof CodexAccountSummarySchema>
export type AccountsSnapshot = z.infer<typeof AccountsSnapshotSchema>
export class InvalidAccountsSnapshotError extends Error {
constructor() {
super(t('m.l0xDyns'))
this.name = 'InvalidAccountsSnapshotError'
}
}
export function isInvalidAccountsSnapshotError(error: unknown): boolean {
return error instanceof InvalidAccountsSnapshotError
}
export function decodeAccountsSnapshot(value: unknown): AccountsSnapshot {
const result = AccountsSnapshotSchema.safeParse(value)
if (!result.success) {
throw new Error(t('m.l0xDyns'))
throw new InvalidAccountsSnapshotError()
}
return result.data
}
+19
View File
@@ -71,6 +71,25 @@ describe('mobile i18n', () => {
])
})
it('keeps generated terminal shortcut labels canonical in translated locales', async () => {
await mobileI18n.changeLanguage('zh')
const {
buildTerminalShortcutKey,
TERMINAL_SHORTCUT_MODIFIER_LABELS,
TERMINAL_SHORTCUT_SPECIAL_KEYS
} = await import('../terminal/terminal-accessory-keys')
expect(TERMINAL_SHORTCUT_MODIFIER_LABELS).toEqual({
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift'
})
expect(TERMINAL_SHORTCUT_SPECIAL_KEYS.find((key) => key.id === 'pageDown')?.label).toBe('PgDn')
expect(buildTerminalShortcutKey({ key: 'pageDown', modifiers: ['shift'] })?.label).toBe(
'Shift+PgDn'
)
})
it.each([
['es', ['Sin comprobaciones', 'En staging', 'Nota de revisión']],
['ja', ['チェックなし', 'ステージ済み', 'レビューメモ']],
@@ -10,6 +10,8 @@ import {
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image'
import { mobileI18n } from '../i18n/mobile-i18n'
function ok(id: string, result: unknown): RpcSuccess {
return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
@@ -37,6 +39,21 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
}
describe('mobile clipboard image paste helpers', () => {
it('keeps the oversized-image classifier stable in a translated locale', async () => {
const initialLocale = mobileI18n.language
await mobileI18n.changeLanguage('es')
let thrown: unknown
try {
normalizeMobileClipboardImageBase64('a'.repeat(24 * 1024 * 1024 + 1))
} catch (error) {
thrown = error
} finally {
await mobileI18n.changeLanguage(initialLocale)
}
expect(isClipboardImageTooLargeError(thrown)).toBe(true)
})
it('strips data URL image prefixes', () => {
expect(normalizeMobileClipboardImageBase64('data:image/png;base64,aGVsbG8=')).toBe('aGVsbG8=')
})
+2 -1
View File
@@ -1,6 +1,7 @@
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { t } from '@/i18n/mobile-i18n'
export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024
@@ -18,7 +19,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
export function normalizeMobileClipboardImageBase64(data: string): string {
const contentBase64 = data.replace(DATA_URL_PREFIX_RE, '')
if (contentBase64.length > MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS) {
throw new Error(t('m.Oht8bkc'))
throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
}
if (contentBase64.length % 4 === 1 || !BASE64_PATTERN.test(contentBase64)) {
throw new Error(t('m.64b63WI'))
@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image'
import { attachMobileImageToTerminal } from './mobile-image-attachment'
import {
ImageLibraryPermissionError,
@@ -35,10 +36,6 @@ type MobileImageAttachment = {
readonly isAttaching: boolean
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export function useMobileImageAttachment({
client,
activeHandle,
@@ -81,7 +78,7 @@ export function useMobileImageAttachment({
showToast(t('m.JdGd1Ic'), 1500)
return
}
if (getErrorMessage(error) === 'Clipboard image is too large') {
if (isClipboardImageTooLargeError(error)) {
showToast(t('m.hJYA7AQ'), 1500)
return
}
@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -79,10 +79,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise<boolean>
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
@@ -174,7 +170,7 @@ export function useMobileNativeChatImageAttachments({
showToast(t('m.jdkmIaw'), 1500)
return
}
if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
if (isClipboardImageTooLargeError(error)) {
showToast(t('m.-YjNIcU'), 1500)
return
}
@@ -5,6 +5,7 @@ import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import type { TerminalModes } from '../terminal/terminal-webview-contract'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image'
import {
buildMobileImagePastePayload,
prepareMobileClipboardImageBase64,
@@ -174,7 +175,7 @@ export function useMobileTerminalPaste({
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast(t('m.gG0saX0'), 1500)
} else if (err.message === 'Clipboard image is too large') {
} else if (isClipboardImageTooLargeError(e)) {
showToast(t('m.5dhlsXo'), 1500)
} else {
showToast(t('m.weTQMew'), 1500)
@@ -2,12 +2,12 @@ import { t } from '@/i18n/mobile-i18n'
import type { TerminalAccessoryKey } from './terminal-accessory-keys'
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ id: 'escape', label: t('m.bNejRmQ'), bytes: '\x1b', accessibilityLabel: t('m.jHe6UUU') },
{ id: 'tab', label: t('m.hM5KUAw'), bytes: '\t', accessibilityLabel: t('m.hM5KUAw') },
{ id: 'enter', label: t('m.9KFo5zM'), bytes: '\r', accessibilityLabel: t('m.9KFo5zM') },
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: t('m.jHe6UUU') },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: t('m.hM5KUAw') },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: t('m.9KFo5zM') },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: t('m.S9DH8y4'), bytes: '\x1b[Z', accessibilityLabel: t('m.p7_d2FU') },
{ id: 'space', label: t('m.l6KJT3M'), bytes: ' ', accessibilityLabel: t('m.l6KJT3M') },
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: t('m.p7_d2FU') },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: t('m.l6KJT3M') },
{
id: 'backspace',
label: '⌫',
@@ -17,7 +17,7 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
},
{
id: 'delete',
label: t('m.gmy3A7M'),
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: t('m.fNgDk9o'),
repeatable: true
@@ -50,13 +50,13 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
accessibilityLabel: t('m.v5WSGO0'),
repeatable: true
},
{ id: 'ctrlC', label: t('m.ke03J5M'), bytes: '\x03', accessibilityLabel: t('m.AzHoQzM') },
{ id: 'ctrlD', label: t('m.vFYSo0s'), bytes: '\x04', accessibilityLabel: t('m.PBPnPeQ') },
{ id: 'ctrlL', label: t('m.R6ZcIz8'), bytes: '\x0c', accessibilityLabel: t('m.Eqaj8oY') },
{ id: 'ctrlZ', label: t('m.NIdsbYo'), bytes: '\x1a', accessibilityLabel: t('m.hqWfNmA') },
{ id: 'ctrlR', label: t('m.a2Cb3GE'), bytes: '\x12', accessibilityLabel: t('m.mcvHf4o') },
{ id: 'ctrlA', label: t('m.u9q9lPQ'), bytes: '\x01', accessibilityLabel: t('m.CKFp_QM') },
{ id: 'ctrlE', label: t('m.5m_ucwE'), bytes: '\x05', accessibilityLabel: t('m.0VWtVO8') },
{ id: 'ctrlW', label: t('m.jVVj75Y'), bytes: '\x17', accessibilityLabel: t('m.Bd9qfdk') },
{ id: 'ctrlU', label: t('m.sL5fKGQ'), bytes: '\x15', accessibilityLabel: t('m.HrWsdDM') }
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: t('m.AzHoQzM') },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: t('m.PBPnPeQ') },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: t('m.Eqaj8oY') },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: t('m.hqWfNmA') },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: t('m.mcvHf4o') },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: t('m.CKFp_QM') },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: t('m.0VWtVO8') },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: t('m.Bd9qfdk') },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: t('m.HrWsdDM') }
]
+29 -28
View File
@@ -29,11 +29,12 @@ export type TerminalShortcutSpecialKey = {
const ESC = '\x1b'
const MODIFIER_LABELS: Record<TerminalShortcutModifier, string> = {
ctrl: t('m.mthhwXw'),
alt: t('m.zN_6g9g'),
shift: t('m.lCZrWn4')
}
export const TERMINAL_SHORTCUT_MODIFIER_LABELS: Readonly<Record<TerminalShortcutModifier, string>> =
{
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift'
}
const MODIFIER_ORDER: TerminalShortcutModifier[] = ['ctrl', 'alt', 'shift']
@@ -78,33 +79,33 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: t('m.bNejRmQ'),
tab: t('m.hM5KUAw'),
enter: t('m.9KFo5zM'),
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: t('m.gmy3A7M'),
insert: t('m.bh9E9Gk'),
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: t('m.r2L-IvQ'),
end: t('m.6UajMWQ'),
pageUp: t('m.1qkhXPI'),
pageDown: t('m.Wt9qvqo'),
space: t('m.l6KJT3M'),
f1: t('m.wPNSaMQ'),
f2: t('m.5OQeDMc'),
f3: t('m.fNbslzw'),
f4: t('m.dMpOhHI'),
f5: t('m.MmdqLFU'),
f6: t('m.xOj4ngY'),
f7: t('m.EbNp-x4'),
f8: t('m.DR9i7SU'),
f9: t('m.rI3MEx0'),
f10: t('m.d6orzBg'),
f11: t('m.T7RTOC4'),
f12: t('m._dDm4ic')
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
@@ -350,7 +351,7 @@ function isPrintableShortcutKey(key: string): boolean {
}
function formatShortcutLabel(key: string, modifiers: TerminalShortcutModifier[]): string {
const modifierLabels = modifiers.map((modifier) => MODIFIER_LABELS[modifier])
const modifierLabels = modifiers.map((modifier) => TERMINAL_SHORTCUT_MODIFIER_LABELS[modifier])
return [...modifierLabels, displayKeyLabel(key)].join('+')
}
+7 -1
View File
@@ -5,7 +5,8 @@ import {
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
assertClipboardImageBase64LengthWithinLimit,
assertClipboardImageByteLengthWithinLimit,
assertClipboardImageDimensionsWithinLimit
assertClipboardImageDimensionsWithinLimit,
isClipboardImageTooLargeError
} from './clipboard-image'
describe('clipboard image limits', () => {
@@ -28,6 +29,11 @@ describe('clipboard image limits', () => {
).toThrow('Clipboard image is too large')
})
it('classifies only the stable internal oversized-image sentinel', () => {
expect(isClipboardImageTooLargeError(new Error('Clipboard image is too large'))).toBe(true)
expect(isClipboardImageTooLargeError(new Error('La imagen es demasiado grande'))).toBe(false)
})
it('rejects invalid or oversized image dimensions before pixel allocation', () => {
expect(() =>
assertClipboardImageDimensionsWithinLimit({
+4
View File
@@ -5,6 +5,10 @@ export const CLIPBOARD_IMAGE_MAX_SOURCE_BYTES = Math.floor(
export const CLIPBOARD_IMAGE_MAX_PIXELS = 32 * 1024 * 1024
export const CLIPBOARD_IMAGE_TOO_LARGE_ERROR = 'Clipboard image is too large'
export function isClipboardImageTooLargeError(error: unknown): boolean {
return error instanceof Error && error.message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR
}
export type ClipboardImageDimensions = {
height: number
width: number
@@ -28,14 +28,16 @@ import {
stopDevApp,
waitForStoreReady
} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs'
import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
import {
createCompletedOnboardingProfile,
safeRemoveLocalDirectory
} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
import {
pollUntil,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs
} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs'
import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const PARK_DELAY_MS = 1_500
@@ -34,14 +34,16 @@ import {
stopDevApp,
waitForStoreReady
} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs'
import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
import {
createCompletedOnboardingProfile,
safeRemoveLocalDirectory
} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
import {
pollUntil,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs
} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs'
import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const scenarioTimeoutMs = 300_000