From 02866adbd0f5ca8134661dbbbee6f02652f201ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:07:43 -0700 Subject: [PATCH] fix(mobile): close localization review findings --- config/scripts/localize-mobile-strings.mjs | 359 ------------------ .../scripts/localize-mobile-strings.test.mjs | 41 -- .../mobile-localization-candidate-rules.mjs | 60 +-- ...bile-localization-candidate-rules.test.mjs | 41 ++ .../mobile-localization-rendered-variable.mjs | 119 ++++++ .../verify-mobile-localization-catalog.mjs | 34 +- ...erify-mobile-localization-catalog.test.mjs | 10 + mobile/src/components/ProtocolBlockScreen.tsx | 19 +- .../pr-sidebar/PRConflictingFilesSection.tsx | 7 +- mobile/src/files/MobileFilePreviewScreen.tsx | 2 +- mobile/src/i18n/locales/en.json | 21 + mobile/src/i18n/locales/es.json | 29 +- mobile/src/i18n/locales/ja.json | 27 +- mobile/src/i18n/locales/ko.json | 29 +- mobile/src/i18n/locales/zh.json | 29 +- mobile/src/i18n/mobile-i18n.test.ts | 13 + .../src/session/MobileNativeChatMessage.tsx | 2 +- ...se-mobile-diff-review-send-actions.test.ts | 49 +-- .../use-mobile-diff-review-send-actions.ts | 66 +--- .../src/source-control/mobile-git-history.ts | 2 +- 20 files changed, 407 insertions(+), 552 deletions(-) delete mode 100644 config/scripts/localize-mobile-strings.mjs delete mode 100644 config/scripts/localize-mobile-strings.test.mjs create mode 100644 config/scripts/mobile-localization-rendered-variable.mjs diff --git a/config/scripts/localize-mobile-strings.mjs b/config/scripts/localize-mobile-strings.mjs deleted file mode 100644 index bf9e2e54f47..00000000000 --- a/config/scripts/localize-mobile-strings.mjs +++ /dev/null @@ -1,359 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { createHash } from 'node:crypto' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -import ts from 'typescript-api' - -import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' - -const TRANSLATE_IMPORT = "import { t } from '@/i18n/mobile-i18n'\n" -const JSX_ENTITY_VALUES = { - amp: '&', - apos: "'", - gt: '>', - lt: '<', - quot: '"' -} - -function decodeJsxEntities(value) { - return value.replace(/&(amp|apos|gt|lt|quot);/gi, (_match, name) => { - return JSX_ENTITY_VALUES[name.toLowerCase()] - }) -} - -function keyForCandidate(candidate, fallback = candidate.text) { - const source = `${candidate.filePath}:${fallback}` - const hash = createHash('sha1').update(source).digest().subarray(0, 5).toString('base64url') - return `m.${hash}` -} - -function setCatalogValue(catalog, key, value) { - const parts = key.split('.') - let current = catalog - for (const part of parts.slice(0, -1)) { - current[part] ??= {} - current = current[part] - } - current[parts.at(-1)] = value -} - -function translateCall(key, _value, options) { - if (options) { - return `t(${JSON.stringify(key)}, ${options})` - } - return `t(${JSON.stringify(key)})` -} - -function isInsideJsxExpression(node) { - let current = node.parent - while (current) { - if (ts.isJsxExpression(current)) { - return true - } - current = current.parent - } - return false -} - -function editForCandidate(candidate, key, translation, sourceFile) { - const call = translateCall(key, translation.fallback, translation.options) - const node = findNodeByRange(sourceFile, candidate.start, candidate.end) - if (candidate.kind === 'jsx-text') { - return { start: candidate.start, end: candidate.end, text: `{${call}}` } - } - if (candidate.kind === 'jsx-expression') { - return { start: candidate.start, end: candidate.end, text: call } - } - if (candidate.kind.startsWith('jsx-attribute:')) { - if (node?.parent && ts.isJsxAttribute(node.parent) && node.parent.initializer === node) { - return { start: node.getStart(sourceFile), end: node.getEnd(), text: `{${call}}` } - } - if (node && isInsideJsxExpression(node)) { - return { start: candidate.start, end: candidate.end, text: call } - } - return { start: candidate.start, end: candidate.end, text: `{${call}}` } - } - return { start: candidate.start, end: candidate.end, text: call } -} - -function sourceKindForPath(filePath) { - return filePath.endsWith('.tsx') || filePath.endsWith('.jsx') - ? ts.ScriptKind.TSX - : ts.ScriptKind.TS -} - -function findNodeByRange(sourceFile, start, end) { - let match - - function visit(node) { - if (node.getStart(sourceFile) === start && node.getEnd() === end) { - match = node - return - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return match -} - -function translationForCandidate(candidate, sourceFile) { - if (!candidate.dynamic) { - const fallback = - candidate.kind === 'jsx-text' || candidate.kind.startsWith('jsx-attribute:') - ? decodeJsxEntities(candidate.text) - : candidate.text - return { fallback } - } - - const node = findNodeByRange(sourceFile, candidate.start, candidate.end) - if (!node || !ts.isTemplateExpression(node)) { - return null - } - - const options = {} - let fallback = node.head.text - node.templateSpans.forEach((span, index) => { - const optionName = `value${index}` - options[optionName] = span.expression.getText(sourceFile) - fallback += `{{${optionName}}}${span.literal.text}` - }) - - const optionSource = `{ ${Object.entries(options) - .map(([name, expression]) => `${name}: ${expression}`) - .join(', ')} }` - - return { fallback, options: optionSource } -} - -function hasTranslateImport(sourceText) { - return /import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]@\/i18n\/mobile-i18n['"]/.test(sourceText) -} - -function addTranslateImport(sourceText) { - if (hasTranslateImport(sourceText)) { - return sourceText - } - - const importMatches = [...sourceText.matchAll(/^import[\s\S]*?from\s*['"][^'"]+['"]\n/gm)] - if (importMatches.length === 0) { - return `${TRANSLATE_IMPORT}${sourceText}` - } - - const lastImport = importMatches.at(-1) - const insertAt = (lastImport.index ?? 0) + lastImport[0].length - return `${sourceText.slice(0, insertAt)}${TRANSLATE_IMPORT}${sourceText.slice(insertAt)}` -} - -function uniqueCandidates(candidates) { - const seen = new Set() - const unique = [] - - for (const candidate of candidates) { - const signature = `${candidate.start}:${candidate.end}:${candidate.kind}` - if (!seen.has(signature)) { - seen.add(signature) - unique.push(candidate) - } - } - - return unique -} - -function hasJsxDescendant(node) { - let found = false - function visit(child) { - if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) { - found = true - return - } - ts.forEachChild(child, visit) - } - ts.forEachChild(node, visit) - return found -} - -function mixedJsxTranslation(node, filePath, sourceFile) { - if ( - !ts.isJsxElement(node) || - node.children.some( - (child) => - ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child) - ) - ) { - return null - } - - const options = [] - let sourceFallback = '' - let hasText = false - let hasValue = false - for (const child of node.children) { - if (ts.isJsxText(child)) { - sourceFallback += child.text - hasText ||= /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(child.text) - continue - } - if (!ts.isJsxExpression(child) || !child.expression) { - continue - } - const expression = child.expression - if (ts.isStringLiteralLike(expression)) { - sourceFallback += expression.text - hasText ||= /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test( - expression.text - ) - continue - } - if (hasJsxDescendant(expression)) { - return null - } - const optionName = `value${options.length}` - sourceFallback += `{{${optionName}}}` - options.push([optionName, expression.getText(sourceFile)]) - hasValue = true - } - - const fallback = decodeJsxEntities(sourceFallback.replace(/\s+/g, ' ').trim()) - if (!hasText || !hasValue || fallback.length < 2) { - return null - } - const optionsSource = `{ ${options - .map(([name, expression]) => `${name}: ${expression}`) - .join(', ')} }` - const key = keyForCandidate({ filePath, text: fallback }) - return { - key, - fallback, - start: node.openingElement.getEnd(), - end: node.closingElement.getStart(sourceFile), - text: `{${translateCall(key, fallback, optionsSource)}}` - } -} - -function collectMixedJsxTranslations(filePath, sourceFile) { - const translations = [] - function visit(node) { - const translation = mixedJsxTranslation(node, filePath, sourceFile) - if (translation) { - translations.push(translation) - return - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return translations -} - -function applyReplacements(filePath, sourceText, candidates, catalog) { - const sourceFile = ts.createSourceFile( - filePath, - sourceText, - ts.ScriptTarget.Latest, - true, - sourceKindForPath(filePath) - ) - const mixedTranslations = collectMixedJsxTranslations(filePath, sourceFile) - const unique = uniqueCandidates(candidates) - const replacements = unique - .filter( - (candidate) => - !mixedTranslations.some( - (translation) => candidate.start >= translation.start && candidate.end <= translation.end - ) && - !unique.some( - (other) => - other !== candidate && - other.start <= candidate.start && - other.end >= candidate.end && - (other.start < candidate.start || other.end > candidate.end) - ) - ) - .map((candidate) => ({ - candidate, - translation: translationForCandidate(candidate, sourceFile) - })) - .filter((entry) => entry.translation !== null) - .sort((left, right) => right.candidate.start - left.candidate.start) - - const edits = [] - for (const { candidate, translation } of replacements) { - const key = keyForCandidate(candidate, translation.fallback) - setCatalogValue(catalog, key, translation.fallback) - const edit = editForCandidate(candidate, key, translation, sourceFile) - edits.push(edit) - } - for (const translation of mixedTranslations) { - setCatalogValue(catalog, translation.key, translation.fallback) - edits.push(translation) - } - - let nextSource = sourceText - for (const edit of edits.sort((left, right) => right.start - left.start)) { - nextSource = `${nextSource.slice(0, edit.start)}${edit.text}${nextSource.slice(edit.end)}` - } - - return edits.length > 0 ? addTranslateImport(nextSource) : nextSource -} - -async function localizeFile(root, filePath, catalog) { - const sourceText = await fs.readFile(filePath, 'utf8') - const candidates = collectLocalizationCandidates(filePath, sourceText, root) - if (candidates.length === 0) { - return 0 - } - const nextSource = applyReplacements(filePath, sourceText, candidates, catalog) - if (nextSource !== sourceText) { - await fs.writeFile(filePath, nextSource) - } - return uniqueCandidates(candidates).length -} - -async function collectCandidateFiles(root) { - const reports = [] - const stack = [path.join(root, 'mobile', 'app'), path.join(root, 'mobile', 'src')] - while (stack.length > 0) { - const dir = stack.pop() - const entries = await fs.readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!['assets', 'i18n', 'node_modules'].includes(entry.name)) { - stack.push(fullPath) - } - continue - } - if ( - entry.isFile() && - /\.(?:ts|tsx|js|jsx|mts|cts)$/.test(entry.name) && - !entry.name.endsWith('.d.ts') && - !entry.name.includes('.test.') && - !entry.name.includes('.spec.') - ) { - reports.push(fullPath) - } - } - } - return reports -} - -export async function main(root = process.cwd()) { - const catalogPath = path.join(root, 'mobile', 'src', 'i18n', 'locales', 'en.json') - const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8')) - const files = await collectCandidateFiles(root) - let count = 0 - - for (const filePath of files) { - count += await localizeFile(root, filePath, catalog) - } - - await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`) - console.log(`Localized ${count} mobile string candidates.`) - return 0 -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(await main()) -} diff --git a/config/scripts/localize-mobile-strings.test.mjs b/config/scripts/localize-mobile-strings.test.mjs deleted file mode 100644 index 4e2d33ffd2e..00000000000 --- a/config/scripts/localize-mobile-strings.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import { main as localizeMobileStrings } from './localize-mobile-strings.mjs' - -function makeProject() { - const root = mkdtempSync(path.join(tmpdir(), 'orca-localize-mobile-strings-')) - const appDirectory = path.join(root, 'mobile', 'app') - const sourceDirectory = path.join(root, 'mobile', 'src') - const localeDirectory = path.join(sourceDirectory, 'i18n', 'locales') - mkdirSync(appDirectory, { recursive: true }) - mkdirSync(localeDirectory, { recursive: true }) - writeFileSync(path.join(localeDirectory, 'en.json'), '{}\n', 'utf8') - return { root, appDirectory, localeDirectory } -} - -describe('localize-mobile-strings', () => { - it('decodes JSX entities case-insensitively without changing JavaScript string literals', async () => { - const { root, appDirectory, localeDirectory } = makeProject() - writeFileSync(path.join(appDirectory, 'Literal.ts'), "export const label = 'Use & here'\n") - writeFileSync( - path.join(appDirectory, 'Rendered.tsx'), - 'export function Rendered() { return Use & here }\n' - ) - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - - try { - await expect(localizeMobileStrings(root)).resolves.toBe(0) - } finally { - log.mockRestore() - } - - const catalog = JSON.parse(readFileSync(path.join(localeDirectory, 'en.json'), 'utf8')) - expect(Object.values(catalog.m)).toEqual( - expect.arrayContaining(['Use & here', 'Use & here']) - ) - }) -}) diff --git a/config/scripts/mobile-localization-candidate-rules.mjs b/config/scripts/mobile-localization-candidate-rules.mjs index 4004581811f..ffdaf6c5cb3 100644 --- a/config/scripts/mobile-localization-candidate-rules.mjs +++ b/config/scripts/mobile-localization-candidate-rules.mjs @@ -1,6 +1,11 @@ // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' +import { + isAssignedToRenderedVariable, + isRenderedJsxExpression +} from './mobile-localization-rendered-variable.mjs' + const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ 'ariaLabel', 'aria-label', @@ -34,6 +39,7 @@ const USER_VISIBLE_OBJECT_KEYS = new Set([ 'label', 'message', 'placeholder', + 'subject', 'subtitle', 'title', 'toggleDescription', @@ -216,49 +222,6 @@ function ancestorJsxAttributeName(node) { return undefined } -function isRenderedJsxExpression(node) { - let current = node.parent - while (current) { - if (ts.isJsxExpression(current)) { - return ( - ts.isJsxElement(current.parent) || - ts.isJsxFragment(current.parent) || - ts.isJsxSelfClosingElement(current.parent) - ) - } - if ( - ts.isConditionalExpression(current) || - ts.isParenthesizedExpression(current) || - ts.isTemplateExpression(current) || - ts.isNoSubstitutionTemplateLiteral(current) - ) { - if (ts.isConditionalExpression(current) && current.condition === node) { - return false - } - current = current.parent - continue - } - if (ts.isBinaryExpression(current)) { - const operator = current.operatorToken.kind - if ( - ![ - ts.SyntaxKind.PlusToken, - ts.SyntaxKind.AmpersandAmpersandToken, - ts.SyntaxKind.BarBarToken, - ts.SyntaxKind.QuestionQuestionToken - ].includes(operator) || - (operator !== ts.SyntaxKind.PlusToken && current.left === node) - ) { - return false - } - current = current.parent - continue - } - return false - } - return false -} - function nearestObjectPropertyName(node) { let current = node.parent while (current) { @@ -494,10 +457,13 @@ function isComparisonOperand(node) { } export function classifyMobileStringNode(node, userVisibleErrorSource) { - if ( - hasAncestorObjectPropertyName(node, new Set(['className', 'classNames'])) || - isComparisonOperand(node) - ) { + if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) { + return undefined + } + if (isAssignedToRenderedVariable(node)) { + return 'rendered-variable' + } + if (isComparisonOperand(node)) { return undefined } diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index ff513124414..26206555196 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -40,4 +40,45 @@ export const HTML = \` 'Follow up' ]) }) + + it('finds literals assigned to variables that later render in JSX', () => { + const source = ` +export function Example({ alternate }) { + const copy = alternate ? 'Primary explanation' : 'Alternate explanation' + let followUp = 'Initial recovery note' + const fileLocation = \`\${path}:L\${line}\` + if (alternate) { + followUp = 'Updated recovery note' + } + return <>{copy}{followUp}{fileLocation} +} +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/Example.tsx', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual([ + 'Primary explanation', + 'Alternate explanation', + 'Initial recovery note', + 'Updated recovery note' + ]) + }) + + it('finds user-visible subject fallbacks in returned rows', () => { + const source = ` +export function toRow(item) { + return { subject: item.subject || '(no commit message)' } +} +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/source-control/mobile-git-history.ts', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual(['(no commit message)']) + }) }) diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs new file mode 100644 index 00000000000..f2b40edacc2 --- /dev/null +++ b/config/scripts/mobile-localization-rendered-variable.mjs @@ -0,0 +1,119 @@ +// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. +import ts from 'typescript-api' + +function isDirectDisplayExpressionParent(parent, child) { + if ( + ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) + ) { + return parent.expression === child + } + if (ts.isConditionalExpression(parent)) { + return parent.whenTrue === child || parent.whenFalse === child + } + if (ts.isBinaryExpression(parent)) { + return [ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken + ].includes(parent.operatorToken.kind) + } + return ts.isTemplateSpan(parent) || ts.isTemplateExpression(parent) +} + +export function isRenderedJsxExpression(node) { + let current = node.parent + while (current) { + if (ts.isJsxExpression(current)) { + return ( + ts.isJsxElement(current.parent) || + ts.isJsxFragment(current.parent) || + ts.isJsxSelfClosingElement(current.parent) + ) + } + if ( + ts.isConditionalExpression(current) || + ts.isParenthesizedExpression(current) || + ts.isTemplateExpression(current) || + ts.isNoSubstitutionTemplateLiteral(current) + ) { + if (ts.isConditionalExpression(current) && current.condition === node) { + return false + } + current = current.parent + continue + } + if (ts.isBinaryExpression(current)) { + const operator = current.operatorToken.kind + if ( + ![ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.QuestionQuestionToken + ].includes(operator) || + (operator !== ts.SyntaxKind.PlusToken && current.left === node) + ) { + return false + } + current = current.parent + continue + } + return false + } + return false +} + +function assignedVariableName(node) { + let current = node + while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) { + current = current.parent + } + const parent = current.parent + if ( + parent && + ts.isVariableDeclaration(parent) && + parent.initializer === current && + ts.isIdentifier(parent.name) + ) { + return parent.name.text + } + return parent && + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === current && + ts.isIdentifier(parent.left) + ? parent.left.text + : undefined +} + +export function isAssignedToRenderedVariable(node) { + if (ts.isTemplateExpression(node)) { + return false + } + const name = assignedVariableName(node) + if (!name) { + return false + } + let owner = node.parent + while (owner && !ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) { + owner = owner.parent + } + if (!owner) { + return false + } + let rendered = false + function visit(current) { + if (rendered) { + return + } + if (ts.isIdentifier(current) && current.text === name && isRenderedJsxExpression(current)) { + rendered = true + return + } + ts.forEachChild(current, visit) + } + visit(owner) + return rendered +} diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs index 9019733f8b4..c7bc03fff88 100644 --- a/config/scripts/verify-mobile-localization-catalog.mjs +++ b/config/scripts/verify-mobile-localization-catalog.mjs @@ -204,12 +204,42 @@ export function collectMobileTranslationCalls(filePath, sourceText, root = proce sourceKind ) const calls = [] + const fixedTranslatorNames = new Set() + + function isEnglishFixedTranslator(node) { + const expression = unwrapExpression(node) + return ( + ts.isCallExpression(expression) && + ts.isPropertyAccessExpression(expression.expression) && + ts.isIdentifier(expression.expression.expression) && + expression.expression.expression.text === 'mobileI18n' && + expression.expression.name.text === 'getFixedT' && + expression.arguments.length === 1 && + ts.isStringLiteralLike(expression.arguments[0]) && + expression.arguments[0].text === 'en' + ) + } + + function collectFixedTranslators(node) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + isEnglishFixedTranslator(node.initializer) + ) { + fixedTranslatorNames.add(node.name.text) + } + ts.forEachChild(node, collectFixedTranslators) + } + + collectFixedTranslators(sourceFile) function visit(node) { if ( ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 't' + ((ts.isIdentifier(node.expression) && + (node.expression.text === 't' || fixedTranslatorNames.has(node.expression.text))) || + isEnglishFixedTranslator(node.expression)) ) { const keyExpression = node.arguments[0] if (keyExpression) { diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs index 64e59ce9f05..eba1f717015 100644 --- a/config/scripts/verify-mobile-localization-catalog.test.mjs +++ b/config/scripts/verify-mobile-localization-catalog.test.mjs @@ -105,6 +105,16 @@ describe('verify-mobile-localization-catalog', () => { expect(await runFailedVerification(root)).toContain('missing English key: m.missing') }) + it('reports missing keys used by an English fixed translator', async () => { + const root = makeProject({ + sourceText: + "import { mobileI18n } from '@/i18n/mobile-i18n'\nconst canonicalLabel = mobileI18n.getFixedT('en')\nexport const label = canonicalLabel('m.missing')\n", + catalogs: { en: { m: { known: 'Known' } } } + }) + + expect(await runFailedVerification(root)).toContain('missing English key: m.missing') + }) + it('rejects translation keys that cannot be statically inspected', async () => { const root = makeProject({ sourceText: "import { t } from '@/i18n/mobile-i18n'\nexport const label = t(runtimeKey)\n", diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index 3bfd742c914..a576e940f1c 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -16,18 +16,25 @@ export function ProtocolBlockScreen({ verdict }: Props) { // Why: Android APKs ship through GitHub Releases until a Play Store listing exists. const mobileUpdateTarget = Platform.OS === 'ios' - ? { label: t('m.oxg5VTs'), url: IOS_APP_STORE_URL, storeName: 'the App Store' } - : { label: t('m.h3ZwGv0'), url: RELEASES_URL, storeName: 'GitHub Releases' } + ? { + label: t('m.oxg5VTs'), + url: IOS_APP_STORE_URL, + storeName: t('protocol.block.appStoreName') + } + : { + label: t('m.h3ZwGv0'), + url: RELEASES_URL, + storeName: t('protocol.block.githubReleasesName') + } const primaryAction = isMobileTooOld ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } : { label: t('m.h3ZwGv0'), url: RELEASES_URL } const title = isMobileTooOld ? t('m.NkNSfjQ') : t('m.amP19yQ') const body = isMobileTooOld - ? `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${mobileUpdateTarget.storeName}, then try this host again.` - : 'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.' - const recoveryNote = - 'Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again.' + ? t('protocol.block.mobileTooOldBody', { storeName: mobileUpdateTarget.storeName }) + : t('protocol.block.desktopTooOldBody') + const recoveryNote = t('protocol.block.recoveryNote') return ( diff --git a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx index d6ea2a21bf1..17f5bb1cf7a 100644 --- a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx +++ b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx @@ -45,12 +45,11 @@ export function PRConflictingFilesSection({ pr, isRefreshing = false, triage }: if (!conflict) { return null } - let noticeBody = 'Conflict file details are unavailable' + let noticeBody = t('pullRequest.conflicts.fileDetailsUnavailable') if (isRefreshing) { - noticeBody = 'Refreshing conflict details…' + noticeBody = t('pullRequest.conflicts.refreshing') } else if (conflict.localMergeClean) { - noticeBody = - 'GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.' + noticeBody = t('pullRequest.conflicts.localMergeClean') } const copyRefreshCommands = async () => { diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index d0c109251bf..d0349e436f1 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -180,7 +180,7 @@ export function MobileFilePreviewScreen({ route }: Props) { previewParams?.worktreeName, previewParams?.worktreeId ?? '' ) - const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview' + const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : t('filePreview.metaFallback') const isEditableTerminalArtifact = previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview) const canSaveArtifact = diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 3ad86dd3dd4..440cc1e78a9 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -2214,6 +2214,7 @@ } }, "nativeChat": { + "toolResultLabel": "Result", "empty": { "title": "Start a chat with {{agentLabel}}", "subtitle": "Ask {{agentLabel}} to inspect code, explain output, or make a change." @@ -2237,6 +2238,9 @@ "checking": "Checking review status…", "unavailable": "Review status unavailable", "unsupportedProvider": "Review creation unavailable for this provider" + }, + "history": { + "noCommitMessage": "(no commit message)" } }, "accounts": { @@ -2252,8 +2256,25 @@ "unresolvedComments": { "one": ", {{count}} unresolved comment", "other": ", {{count}} unresolved comments" + }, + "conflicts": { + "fileDetailsUnavailable": "Conflict file details are unavailable", + "refreshing": "Refreshing conflict details…", + "localMergeClean": "GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability." } }, + "protocol": { + "block": { + "appStoreName": "the App Store", + "githubReleasesName": "GitHub Releases", + "mobileTooOldBody": "This desktop needs a newer Orca Mobile app. Update Orca Mobile from {{storeName}}, then try this host again.", + "desktopTooOldBody": "This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.", + "recoveryNote": "Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again." + } + }, + "filePreview": { + "metaFallback": "Preview" + }, "voiceModels": { "downloadLabel": "Download {{modelName}}" }, diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 6935fcc46fe..bd4baf802a7 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1124,7 +1124,7 @@ "m6fKanI": "Nuevo preajuste disperso", "6-gKCU8": "Nuevo preset", "m38OFiM": "Utilice todo el repositorio", - "0fqNjoo": "Pago completo", + "0fqNjoo": "Checkout completo", "6xj6dHo": "Pago escaso", "uwfc5JU": "Ninguna rama coincide.", "ajFYYlY": "Utilice la base configurada de este repositorio", @@ -1816,7 +1816,7 @@ "8Tx_eZw": "No se pudo crear la solicitud de extracción", "DIh5pvA": "No se pudo actualizar la reseña vinculada", "D8-XyhU": "No se pudo actualizar la solicitud de extracción vinculada", - "GaTugx8": "Se perderían las commits locales; tirar en su lugar", + "GaTugx8": "Los commits locales se perderían; haz pull en su lugar", "qajmB_0": "Nada que adelantar", "fp_5pjo": "avance rápido", "UWyesQ8": "Avance rápido ({{value0}})", @@ -2210,6 +2210,7 @@ } }, "nativeChat": { + "toolResultLabel": "Resultado", "empty": { "title": "Inicia un chat con {{agentLabel}}", "subtitle": "Pide a {{agentLabel}} que inspeccione código, explique resultados o haga un cambio." @@ -2222,5 +2223,29 @@ "accountUsage": { "resetNow": "Se restablece ahora", "resetIn": "Se restablece en {{duration}}" + }, + "sourceControl": { + "history": { + "noCommitMessage": "(sin mensaje de commit)" + } + }, + "pullRequest": { + "conflicts": { + "fileDetailsUnavailable": "Los detalles de los archivos en conflicto no están disponibles", + "refreshing": "Actualizando los detalles de los conflictos…", + "localMergeClean": "GitHub informa de conflictos, pero Git local no pudo reproducirlos. Actualiza el PR o publica la rama para volver a calcular si se puede fusionar." + } + }, + "protocol": { + "block": { + "appStoreName": "App Store", + "githubReleasesName": "GitHub Releases", + "mobileTooOldBody": "Este equipo necesita una versión más reciente de la app Orca Mobile. Actualiza Orca Mobile desde {{storeName}} y vuelve a probar este host.", + "desktopTooOldBody": "La app de escritorio emparejada es demasiado antigua para tu app Orca Mobile actual. Actualiza Orca en tu equipo y vuelve a probar este host.", + "recoveryNote": "¿Ya la actualizaste? Vuelve a Hosts y actualiza la conexión. Si este mensaje sigue apareciendo, elimina este host y vuelve a emparejarlo." + } + }, + "filePreview": { + "metaFallback": "Vista previa" } } diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 312c35f8832..c3a89c3868b 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1816,7 +1816,7 @@ "8Tx_eZw": "PR の作成に失敗しました", "DIh5pvA": "リンクされたレビューを更新できませんでした", "D8-XyhU": "リンクされた PR の更新に失敗しました", - "GaTugx8": "ローカル commits は失われます。代わりに引っ張る", + "GaTugx8": "ローカルコミットが失われます。代わりにプルしてください", "qajmB_0": "早送りするものはありません", "fp_5pjo": "早送り", "UWyesQ8": "早送り ({{value0}})", @@ -2210,6 +2210,7 @@ } }, "nativeChat": { + "toolResultLabel": "結果", "empty": { "title": "{{agentLabel}} とチャットを開始", "subtitle": "{{agentLabel}} にコードの確認、出力の説明、変更を依頼できます。" @@ -2222,5 +2223,29 @@ "accountUsage": { "resetNow": "今すぐリセット", "resetIn": "{{duration}}後にリセット" + }, + "sourceControl": { + "history": { + "noCommitMessage": "(コミットメッセージなし)" + } + }, + "pullRequest": { + "conflicts": { + "fileDetailsUnavailable": "競合ファイルの詳細を取得できません", + "refreshing": "競合の詳細を更新しています…", + "localMergeClean": "GitHub は競合を報告していますが、ローカル Git では再現できませんでした。PR を更新するかブランチをプッシュして、マージ可能性を再計算してください。" + } + }, + "protocol": { + "block": { + "appStoreName": "App Store", + "githubReleasesName": "GitHub Releases", + "mobileTooOldBody": "このデスクトップには、より新しい Orca Mobile アプリが必要です。{{storeName}} から Orca Mobile を更新してから、このホストをもう一度お試しください。", + "desktopTooOldBody": "ペアリング済みのデスクトップアプリは、現在の Orca Mobile アプリに対して古すぎます。コンピューター上の Orca を更新してから、このホストをもう一度お試しください。", + "recoveryNote": "すでに更新しましたか?ホスト一覧に戻って接続を更新してください。このメッセージが消えない場合は、このホストを削除してもう一度ペアリングしてください。" + } + }, + "filePreview": { + "metaFallback": "プレビュー" } } diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index defc8754d8c..ebac6ce55bb 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1124,7 +1124,7 @@ "m6fKanI": "새로운 희소 사전 설정", "6-gKCU8": "새로운 사전 설정", "m38OFiM": "전체 저장소 사용", - "0fqNjoo": "전체 결제", + "0fqNjoo": "전체 체크아웃", "6xj6dHo": "스파스 체크아웃", "uwfc5JU": "일치하는 브랜치가 없습니다.", "ajFYYlY": "이 저장소의 구성된 기반을 사용하십시오.", @@ -1816,7 +1816,7 @@ "8Tx_eZw": "PR을 생성하지 못했습니다.", "DIh5pvA": "연결된 리뷰를 업데이트하지 못했습니다.", "D8-XyhU": "연결된 PR을 업데이트하지 못했습니다.", - "GaTugx8": "로컬 commits이 손실됩니다. 대신 당겨", + "GaTugx8": "로컬 커밋이 손실됩니다. 대신 풀하세요", "qajmB_0": "빨리 감을 항목이 없습니다.", "fp_5pjo": "빨리감기", "UWyesQ8": "빨리 감기({{value0}})", @@ -2210,6 +2210,7 @@ } }, "nativeChat": { + "toolResultLabel": "결과", "empty": { "title": "{{agentLabel}}와 채팅 시작", "subtitle": "{{agentLabel}}에게 코드 검사, 출력 설명 또는 변경 작업을 요청하세요." @@ -2222,5 +2223,29 @@ "accountUsage": { "resetNow": "지금 재설정", "resetIn": "{{duration}} 후 재설정" + }, + "sourceControl": { + "history": { + "noCommitMessage": "(커밋 메시지 없음)" + } + }, + "pullRequest": { + "conflicts": { + "fileDetailsUnavailable": "충돌 파일 세부 정보를 사용할 수 없습니다", + "refreshing": "충돌 세부 정보 새로 고치는 중…", + "localMergeClean": "GitHub에서는 충돌을 보고하지만 로컬 Git에서는 재현되지 않았습니다. PR을 새로 고치거나 브랜치를 푸시하여 병합 가능 여부를 다시 계산하세요." + } + }, + "protocol": { + "block": { + "appStoreName": "App Store", + "githubReleasesName": "GitHub Releases", + "mobileTooOldBody": "이 데스크톱에는 최신 Orca Mobile 앱이 필요합니다. {{storeName}}에서 Orca Mobile을 업데이트한 후 이 호스트를 다시 시도하세요.", + "desktopTooOldBody": "페어링된 데스크톱 앱이 현재 Orca Mobile 앱에 비해 너무 오래되었습니다. 컴퓨터에서 Orca를 업데이트한 후 이 호스트를 다시 시도하세요.", + "recoveryNote": "이미 업데이트했나요? 호스트 목록으로 돌아가 연결을 새로 고치세요. 이 메시지가 계속 표시되면 이 호스트를 삭제하고 다시 페어링하세요." + } + }, + "filePreview": { + "metaFallback": "미리보기" } } diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 49a9871cefe..a000dc5d92d 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1124,7 +1124,7 @@ "m6fKanI": "新的稀疏预设", "6-gKCU8": "新预设", "m38OFiM": "使用整个存储库", - "0fqNjoo": "全面结帐", + "0fqNjoo": "完整检出", "6xj6dHo": "稀疏结帐", "uwfc5JU": "没有分支匹配。", "ajFYYlY": "使用此存储库的配置基础", @@ -1813,7 +1813,7 @@ "8Tx_eZw": "无法创建拉取请求", "DIh5pvA": "无法更新链接的评论", "D8-XyhU": "无法更新链接的拉取请求", - "GaTugx8": "本地 commits 将会丢失;拉代替", + "GaTugx8": "本地提交将丢失;请改用拉取", "qajmB_0": "没有什么可以快进的", "fp_5pjo": "快进", "UWyesQ8": "快进 ({{value0}})", @@ -2207,6 +2207,7 @@ } }, "nativeChat": { + "toolResultLabel": "结果", "empty": { "title": "与 {{agentLabel}} 开始聊天", "subtitle": "让 {{agentLabel}} 检查代码、解释输出或进行更改。" @@ -2219,5 +2220,29 @@ "accountUsage": { "resetNow": "立即重置", "resetIn": "{{duration}}后重置" + }, + "sourceControl": { + "history": { + "noCommitMessage": "(无提交消息)" + } + }, + "pullRequest": { + "conflicts": { + "fileDetailsUnavailable": "无法获取冲突文件详情", + "refreshing": "正在刷新冲突详情…", + "localMergeClean": "GitHub 报告存在冲突,但本地 Git 未能复现。请刷新 PR 或推送分支,以重新计算可合并状态。" + } + }, + "protocol": { + "block": { + "appStoreName": "App Store", + "githubReleasesName": "GitHub Releases", + "mobileTooOldBody": "此桌面需要更新版本的 Orca Mobile 应用。请从 {{storeName}} 更新 Orca Mobile,然后重试此主机。", + "desktopTooOldBody": "已配对的桌面应用版本太旧,无法与当前 Orca Mobile 应用配合使用。请在电脑上更新 Orca,然后重试此主机。", + "recoveryNote": "已经更新?请返回主机列表并刷新连接。如果仍显示此消息,请删除此主机并重新配对。" + } + }, + "filePreview": { + "metaFallback": "预览" } } diff --git a/mobile/src/i18n/mobile-i18n.test.ts b/mobile/src/i18n/mobile-i18n.test.ts index 21126393988..506c7bbfa0a 100644 --- a/mobile/src/i18n/mobile-i18n.test.ts +++ b/mobile/src/i18n/mobile-i18n.test.ts @@ -141,6 +141,19 @@ describe('mobile i18n', () => { ]) }) + it.each([ + ['es', ['Checkout completo', 'Los commits locales se perderían; haz pull en su lugar']], + ['ja', ['フルチェックアウト', 'ローカルコミットが失われます。代わりにプルしてください']], + ['ko', ['전체 체크아웃', '로컬 커밋이 손실됩니다. 대신 풀하세요']], + ['zh', ['完整检出', '本地提交将丢失;请改用拉取']] + ] satisfies [MobileUiLocale, string[]][])( + '%s uses accurate checkout and pull terminology', + async (locale, expected) => { + await mobileI18n.changeLanguage(locale) + expect([t('m.0fqNjoo'), t('m.GaTugx8')]).toEqual(expected) + } + ) + it.each([ ['es', ['Sin comprobaciones', 'En staging', 'Nota de revisión']], ['ja', ['チェックなし', 'ステージ済み', 'レビューメモ']], diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 257820a60eb..7c0718495f5 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -88,7 +88,7 @@ function ToolLine({ }): React.JSX.Element { const [expanded, setExpanded] = useState(defaultExpanded) const { call, result } = pair - const name = call ? call.name : 'Result' + const name = call ? call.name : t('nativeChat.toolResultLabel') const preview = call ? summarizeToolInput(call.input) : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index 6aa8a9b3b7f..f514a1966ec 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -9,10 +9,7 @@ import { markMobileNativeChatInputStale, resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' -import { - MOBILE_DIFF_REVIEW_SEND_TIMEOUT_MS, - useMobileDiffReviewSendActions -} from './use-mobile-diff-review-send-actions' +import { useMobileDiffReviewSendActions } from './use-mobile-diff-review-send-actions' type SendActions = ReturnType @@ -173,10 +170,7 @@ describe('useMobileDiffReviewSendActions', () => { expect(sendRequest).toHaveBeenCalledTimes(1) expect(sendRequest.mock.calls[0]?.[0]).toBe('terminal.send') expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ terminal: 'terminal-1', enter: true }) - expect(sendRequest.mock.calls[0]?.[2]).toMatchObject({ - timeoutMs: expect.any(Number), - budgetSpansConnect: true - }) + expect(sendRequest.mock.calls[0]?.[2]).toBeUndefined() expect(saveCommentsAndReviewState).toHaveBeenCalledTimes(1) expect(setActionError).toHaveBeenCalledWith('Review notes sent') expect(setSendSheet).toHaveBeenCalledWith(null) @@ -212,38 +206,25 @@ describe('useMobileDiffReviewSendActions', () => { expect(saveCommentsAndReviewState).not.toHaveBeenCalled() }) - it('spends one deadline across terminal creation and note delivery', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) + it('keeps normal RPC timeouts independent for terminal creation and note delivery', async () => { const sendRequest = vi .fn() - .mockImplementationOnce(async () => { - vi.setSystemTime(2_500) - return { - id: 'create', - ok: true, - result: { tab: { id: 'tab-1', type: 'terminal', terminal: 'terminal-1' } } - } + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { tab: { id: 'tab-1', type: 'terminal', terminal: 'terminal-1' } } }) .mockResolvedValueOnce(sendResponse(true)) - try { - await mount({ sendRequest } as unknown as RpcClient) + await mount({ sendRequest } as unknown as RpcClient) - await act(async () => { - await actions?.createTerminalAndSend([COMMENT]) - }) + await act(async () => { + await actions?.createTerminalAndSend([COMMENT]) + }) - expect(sendRequest.mock.calls[0]?.[2]).toEqual({ - timeoutMs: MOBILE_DIFF_REVIEW_SEND_TIMEOUT_MS, - budgetSpansConnect: true - }) - expect(sendRequest.mock.calls[1]?.[2]).toEqual({ - timeoutMs: MOBILE_DIFF_REVIEW_SEND_TIMEOUT_MS - 1_500, - budgetSpansConnect: true - }) - } finally { - vi.useRealTimers() - } + expect(sendRequest.mock.calls[0]?.[0]).toBe('session.tabs.createTerminal') + expect(sendRequest.mock.calls[0]?.[2]).toBeUndefined() + expect(sendRequest.mock.calls[1]?.[0]).toBe('terminal.send') + expect(sendRequest.mock.calls[1]?.[2]).toBeUndefined() }) it('reports a failed terminal.send response', async () => { diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 4cd761ec394..609c2bac53f 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -28,20 +28,6 @@ type SendActionsInput = { ) => Promise } -export const MOBILE_DIFF_REVIEW_SEND_TIMEOUT_MS = 30_000 - -function openMobileDiffReviewSendDeadline(): number { - return Date.now() + MOBILE_DIFF_REVIEW_SEND_TIMEOUT_MS -} - -function remainingRequestBudget(deadline: number, errorMessage: string) { - const timeoutMs = deadline - Date.now() - if (timeoutMs <= 0) { - throw new Error(errorMessage) - } - return { timeoutMs, budgetSpansConnect: true as const } -} - export function useMobileDiffReviewSendActions(input: SendActionsInput) { const { client, @@ -86,30 +72,20 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { ) const sendPromptToTerminal = useCallback( - async ( - terminal: string, - comments: readonly DiffComment[], - deadline = openMobileDiffReviewSendDeadline() - ) => { + async (terminal: string, comments: readonly DiffComment[]) => { if (!client || connState !== 'connected') { throw new Error(t('m.x0Dr_H8')) } // Marked by terminal handle, not by surface, so a paste orphaned here by native // chat would ride along with these notes (#10228). Diff review carries no device token. - if ( - !(await healMobileNativeChatStaleInput({ client, terminal, deviceToken: null, deadline })) - ) { + if (!(await healMobileNativeChatStaleInput({ client, terminal, deviceToken: null }))) { throw new Error(t('m.tpf9SfA')) } - const response = await client.sendRequest( - 'terminal.send', - { - terminal, - text: formatMobileDiffReviewPrompt(comments), - enter: true - }, - remainingRequestBudget(deadline, t('m.tpf9SfA')) - ) + const response = await client.sendRequest('terminal.send', { + terminal, + text: formatMobileDiffReviewPrompt(comments), + enter: true + }) if (!response.ok) { throw new Error(response.error?.message || t('m.tpf9SfA')) } @@ -129,17 +105,12 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { if (!client || connState !== 'connected') { throw new Error(t('m.x0Dr_H8')) } - const deadline = openMobileDiffReviewSendDeadline() - const response = await client.sendRequest( - 'session.tabs.createTerminal', - { - worktree: `id:${worktreeId}`, - activate: false, - select: true, - navigation: 'caller' - }, - remainingRequestBudget(deadline, t('m.NhC30K0')) - ) + const response = await client.sendRequest('session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + activate: false, + select: true, + navigation: 'caller' + }) if (!response.ok) { throw new Error(response.error?.message || t('m.NhC30K0')) } @@ -147,7 +118,7 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { if (!created) { throw new Error(t('m.LYAe0FE')) } - await sendPromptToTerminal(created.terminal, comments, deadline) + await sendPromptToTerminal(created.terminal, comments) }, [client, connState, sendPromptToTerminal, worktreeId] ) @@ -159,12 +130,9 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { } setSendSheet({ kind: 'loading' }) try { - const deadline = openMobileDiffReviewSendDeadline() - const response = await client.sendRequest( - 'session.tabs.list', - { worktree: `id:${worktreeId}` }, - remainingRequestBudget(deadline, t('m.vMHt0l8')) - ) + const response = await client.sendRequest('session.tabs.list', { + worktree: `id:${worktreeId}` + }) if (!response.ok) { throw new Error(response.error?.message || t('m.vMHt0l8')) } diff --git a/mobile/src/source-control/mobile-git-history.ts b/mobile/src/source-control/mobile-git-history.ts index 444701781df..3aa6b9d2d6a 100644 --- a/mobile/src/source-control/mobile-git-history.ts +++ b/mobile/src/source-control/mobile-git-history.ts @@ -45,7 +45,7 @@ export function toMobileCommitRow(item: GitHistoryItem, nowMs: number): MobileCo return { id: item.id, shortId: item.displayId ?? item.id.slice(0, 7), - subject: item.subject || '(no commit message)', + subject: item.subject || t('sourceControl.history.noCommitMessage'), author: item.author ?? '', parentId: item.parentIds[0] ?? null, relativeTime: formatCommitTime(item.timestamp, nowMs)