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)