Step count for --synthetic-spinner-animation steps (default 12)\n`
+ )
+}
+function run(command, args, options = {}) {
+ execFileSync(command, args, { stdio: options.stdio ?? 'pipe', encoding: 'utf8', ...options })
+}
+
+function buildAppIfNeeded(root, skipBuild) {
+ const mainPath = path.join(root, 'out', 'main', 'index.js')
+ if (skipBuild && existsSync(mainPath)) {
+ return mainPath
+ }
+ if (skipBuild) {
+ throw new Error(`--skip-build requested, but ${mainPath} does not exist`)
+ }
+ console.log('[idle-cpu] building Electron app with electron-vite --mode e2e')
+ run('npx', ['electron-vite', 'build', '--mode', 'e2e'], {
+ cwd: root,
+ stdio: 'inherit',
+ env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
+ })
+ return mainPath
+}
+
+function makeCompletedOnboardingProfile() {
+ return {
+ settings: {
+ telemetry: {
+ optedIn: true,
+ installId: '00000000-0000-4000-8000-000000000000',
+ existedBeforeTelemetryRelease: false
+ }
+ },
+ onboarding: {
+ flowVersion: ONBOARDING_FLOW_VERSION,
+ closedAt: 1,
+ outcome: 'completed',
+ lastCompletedStep: ONBOARDING_FINAL_STEP
+ },
+ ui: {
+ contextualToursSeenIds: [
+ 'workspace-board',
+ 'browser',
+ 'tasks',
+ 'automations',
+ 'workspace-creation'
+ ],
+ contextualToursAutoEligible: false,
+ projectOrderManualDefaultNoticeDismissed: true
+ }
+ }
+}
+
+function createIdleRepo(worktreeCount) {
+ const repoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-repo-'))
+ const cleanupDirs = [repoDir]
+ run('git', ['init'], { cwd: repoDir })
+ run('git', ['config', 'user.email', 'idle-cpu@test.local'], { cwd: repoDir })
+ run('git', ['config', 'user.name', 'Idle CPU Benchmark'], { cwd: repoDir })
+ writeFileSync(path.join(repoDir, 'README.md'), '# Orca idle CPU benchmark\n')
+ writeFileSync(
+ path.join(repoDir, 'package.json'),
+ `${JSON.stringify({ private: true }, null, 2)}\n`
+ )
+ mkdirSync(path.join(repoDir, 'src'), { recursive: true })
+ writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const idleBenchmark = true\n')
+ run('git', ['add', '-A'], { cwd: repoDir })
+ run('git', ['commit', '-m', 'Initial idle CPU fixture'], { cwd: repoDir })
+ for (let i = 2; i <= worktreeCount; i += 1) {
+ const worktreeDir = path.join(
+ path.dirname(repoDir),
+ `orca-idle-cpu-worktree-${i}-${Date.now()}`
+ )
+ cleanupDirs.push(worktreeDir)
+ run('git', ['worktree', 'add', worktreeDir, '-b', `idle-cpu-${i}`], { cwd: repoDir })
+ }
+ return { repoDir, cleanupDirs }
+}
+
+function launchArgs(mainPath, headful) {
+ if (headful || process.platform !== 'linux') {
+ return [mainPath]
+ }
+ return [
+ '--disable-gpu',
+ '--disable-gpu-compositing',
+ '--disable-gpu-sandbox',
+ '--disable-dev-shm-usage',
+ '--in-process-gpu',
+ mainPath
+ ]
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function parseCpuTimeSeconds(value) {
+ const trimmed = String(value || '').trim()
+ if (!trimmed) {
+ return null
+ }
+ const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
+ const days = dayOrTime === null ? 0 : Number(dayOrTime)
+ const parts = maybeTime.split(':').map(Number)
+ if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
+ return null
+ }
+ if (parts.length === 3) {
+ return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
+ }
+ if (parts.length === 2) {
+ return days * 86400 + parts[0] * 60 + parts[1]
+ }
+ if (parts.length === 1) {
+ return days * 86400 + parts[0]
+ }
+ return null
+}
+
+function parseUnixProcesses(stdout) {
+ const rows = []
+ for (const raw of stdout.split('\n')) {
+ const line = raw.trim()
+ if (!line) {
+ continue
+ }
+ const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
+ if (!match) {
+ continue
+ }
+ rows.push({
+ pid: Number(match[1]),
+ ppid: Number(match[2]),
+ percentCpu: Number(match[3]),
+ rssBytes: Number(match[4]) * 1024,
+ cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
+ command: match[6]
+ })
+ }
+ return rows
+}
+
+function readUnixProcesses() {
+ const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
+ encoding: 'utf8',
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
+ maxBuffer: 20 * 1024 * 1024
+ })
+ return parseUnixProcesses(stdout)
+}
+
+function readWindowsProcesses() {
+ const script =
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
+ encoding: 'utf8',
+ maxBuffer: 20 * 1024 * 1024
+ })
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'PowerShell process enumeration failed')
+ }
+ const parsed = JSON.parse(result.stdout || '[]')
+ const entries = Array.isArray(parsed) ? parsed : [parsed]
+ return entries.map((entry) => ({
+ pid: Number(entry.ProcessId),
+ ppid: Number(entry.ParentProcessId),
+ percentCpu: 0,
+ cpuTimeSeconds: null,
+ rssBytes: Number(entry.WorkingSetSize) || 0,
+ command: String(entry.CommandLine || '')
+ }))
+}
+
+function readProcessRows() {
+ return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
+}
+
+function descendantsOf(rows, rootPid) {
+ const children = new Map()
+ for (const row of rows) {
+ const list = children.get(row.ppid) ?? []
+ list.push(row)
+ children.set(row.ppid, list)
+ }
+ const result = []
+ const stack = [rootPid]
+ const seen = new Set()
+ while (stack.length > 0) {
+ const pid = stack.pop()
+ if (seen.has(pid)) {
+ continue
+ }
+ seen.add(pid)
+ const row = rows.find((candidate) => candidate.pid === pid)
+ if (row) {
+ result.push(row)
+ }
+ for (const child of children.get(pid) ?? []) {
+ stack.push(child.pid)
+ }
+ }
+ return result
+}
+
+function classify(row, rootPid) {
+ const command = row.command.toLowerCase()
+ if (row.pid === rootPid) {
+ return 'main'
+ }
+ if (command.includes('daemon-entry')) {
+ return 'daemon'
+ }
+ if (command.includes('--type=gpu-process')) {
+ return 'gpu'
+ }
+ if (command.includes('--type=renderer')) {
+ return 'renderer'
+ }
+ if (command.includes('--type=utility')) {
+ return 'utility'
+ }
+ if (command.includes('--type=')) {
+ return 'electron-other'
+ }
+ if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
+ return 'agent-or-node'
+ }
+ return 'other-descendant'
+}
+
+async function collectRendererIdleState(page) {
+ return page.evaluate(() => {
+ const describeElement = (element) => {
+ if (!(element instanceof Element)) {
+ return null
+ }
+ const classes = typeof element.className === 'string' ? element.className : ''
+ const testId = element.getAttribute('data-testid')
+ const label = element.getAttribute('aria-label')
+ return {
+ tag: element.tagName.toLowerCase(),
+ id: element.id || null,
+ testId,
+ label,
+ classes: classes.split(/\s+/).filter(Boolean).slice(0, 12),
+ text: (element.textContent || '').trim().slice(0, 80)
+ }
+ }
+ const animations = document.getAnimations({ subtree: true }).map((animation) => {
+ const effect = animation.effect
+ const target = effect instanceof KeyframeEffect ? effect.target : null
+ return {
+ playState: animation.playState,
+ currentTime: typeof animation.currentTime === 'number' ? animation.currentTime : null,
+ playbackRate: animation.playbackRate,
+ duration:
+ effect instanceof KeyframeEffect && typeof effect.getTiming().duration === 'number'
+ ? effect.getTiming().duration
+ : null,
+ iterations: effect instanceof KeyframeEffect ? effect.getTiming().iterations : null,
+ target: describeElement(target)
+ }
+ })
+ return {
+ visibilityState: document.visibilityState,
+ runningAnimationCount: animations.filter((animation) => animation.playState === 'running')
+ .length,
+ animations: animations.slice(0, 80)
+ }
+ })
+}
+
+function summarizeSamples(samples) {
+ const byKind = new Map()
+ for (const sample of samples) {
+ for (const proc of sample.processes) {
+ const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
+ bucket.cpuValues.push(proc.cpu)
+ bucket.rssValues.push(proc.rssBytes)
+ byKind.set(proc.kind, bucket)
+ }
+ const counts = new Map()
+ for (const proc of sample.processes) {
+ counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
+ }
+ for (const [kind, count] of counts) {
+ byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
+ }
+ }
+ const summary = {}
+ for (const [kind, values] of byKind) {
+ const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
+ const rssSumBySample = samples.map((sample) =>
+ sample.processes
+ .filter((proc) => proc.kind === kind)
+ .reduce((sum, proc) => sum + proc.rssBytes, 0)
+ )
+ summary[kind] = {
+ meanCpuPercent: mean(values.cpuValues),
+ p95CpuPercent: percentile(cpuSorted, 0.95),
+ maxCpuPercent: Math.max(0, ...values.cpuValues),
+ meanRssBytes: mean(rssSumBySample),
+ maxProcessCount: values.maxProcessCount
+ }
+ }
+ summary.total = {
+ meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
+ p95CpuPercent: percentile(
+ samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
+ 0.95
+ ),
+ meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
+ }
+ return summary
+}
+
+function summarizeProcessInventory(samples) {
+ const inventory = {}
+ for (const sample of samples) {
+ const counts = new Map()
+ for (const proc of sample.processes) {
+ counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
+ const entry = inventory[proc.kind] ?? {
+ maxProcessCount: 0,
+ maxCpuPercent: 0,
+ commandSamples: []
+ }
+ entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
+ if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
+ entry.commandSamples.push(proc.command)
+ }
+ inventory[proc.kind] = entry
+ }
+ for (const [kind, count] of counts) {
+ inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
+ }
+ }
+ return inventory
+}
+function mean(values) {
+ return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
+}
+
+function percentile(sorted, fraction) {
+ if (sorted.length === 0) {
+ return 0
+ }
+ const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
+ return sorted[index]
+}
+
+function terminateProcesses(processes) {
+ for (const proc of processes) {
+ try {
+ process.kill(proc.pid)
+ } catch {}
+ }
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2))
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
+ const mainPath = buildAppIfNeeded(root, options.skipBuild)
+ const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-userdata-'))
+ const { repoDir, cleanupDirs } = createIdleRepo(options.worktrees)
+ writeFileSync(
+ path.join(userDataDir, 'orca-data.json'),
+ `${JSON.stringify(makeCompletedOnboardingProfile(), null, 2)}\n`
+ )
+ const { ELECTRON_RUN_AS_NODE, ...cleanEnv } = process.env
+ void ELECTRON_RUN_AS_NODE
+ const app = await electron.launch({
+ args: launchArgs(mainPath, options.headful),
+ env: {
+ ...cleanEnv,
+ NODE_ENV: 'development',
+ ORCA_E2E_USER_DATA_DIR: userDataDir,
+ ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
+ }
+ })
+ const rootPid = app.process().pid
+ try {
+ const page = await app.firstWindow({ timeout: 120_000 })
+ await page.waitForLoadState('domcontentloaded')
+ await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
+ const measurementCss = []
+ if (options.disableRendererAnimations) {
+ measurementCss.push(
+ '*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}'
+ )
+ }
+ if (measurementCss.length > 0) {
+ await page.addStyleTag({ content: measurementCss.join('\n') })
+ }
+ await installSyntheticVisibleSpinners(
+ page,
+ options.syntheticVisibleSpinners,
+ options.syntheticSpinnerAnimation,
+ options.syntheticSpinnerSteps
+ )
+ await page.evaluate(async (repoPath) => {
+ await window.api.repos.add({ path: repoPath })
+ const store = window.__store
+ await store?.getState().fetchRepos()
+ const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
+ if (repo) {
+ await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
+ await store.getState().fetchWorktrees(repo.id)
+ }
+ }, repoDir)
+ await page.waitForFunction(
+ () => window.__store?.getState().workspaceSessionReady === true,
+ null,
+ { timeout: 60_000 }
+ )
+ console.log(
+ `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
+ )
+ await sleep(options.warmupMs)
+ const rendererIdleState = await collectRendererIdleState(page)
+ const deadline = Date.now() + options.sampleMs
+ const samples = []
+ let previousSnapshot = null
+ while (Date.now() <= deadline || samples.length === 0) {
+ const sampledAt = Date.now()
+ const processRows = descendantsOf(readProcessRows(), rootPid)
+ const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
+ if (previousSnapshot) {
+ const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
+ const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
+ const processes = rawProcesses.map((row) => {
+ const previous = previousByPid.get(row.pid)
+ const canComputeDelta =
+ typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
+ const cpu = canComputeDelta
+ ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
+ : row.percentCpu
+ return { ...row, cpu }
+ })
+ samples.push({
+ at: sampledAt,
+ elapsedMs: sampledAt - previousSnapshot.at,
+ totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
+ totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
+ processes
+ })
+ }
+ previousSnapshot = { at: sampledAt, processes: rawProcesses }
+ await sleep(options.intervalMs)
+ }
+ const report = {
+ benchmark: 'orca-idle-cpu',
+ createdAt: new Date().toISOString(),
+ options,
+ rootPid,
+ platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
+ rendererIdleState,
+ sampleCount: samples.length,
+ summary: summarizeSamples(samples),
+ processInventory: summarizeProcessInventory(samples),
+ samples
+ }
+ if (options.output) {
+ mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
+ writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`)
+ console.log(`[idle-cpu] wrote ${options.output}`)
+ }
+ console.log(
+ JSON.stringify(
+ {
+ summary: report.summary,
+ processInventory: report.processInventory,
+ sampleCount: report.sampleCount
+ },
+ null,
+ 2
+ )
+ )
+ } finally {
+ const launchedProcesses = descendantsOf(readProcessRows(), rootPid).filter(
+ (proc) => proc.pid !== rootPid
+ )
+ await app.close().catch(() => undefined)
+ await sleep(250)
+ terminateProcesses(launchedProcesses)
+ rmSync(userDataDir, { recursive: true, force: true })
+ for (const dir of cleanupDirs) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ }
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs
index ed67d13176d..e49c9413da3 100644
--- a/config/scripts/verify-localization-catalog.mjs
+++ b/config/scripts/verify-localization-catalog.mjs
@@ -9,6 +9,8 @@ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
+const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
+const SOURCE_RELATIVE_ROOTS = [path.join('src', 'renderer', 'src'), path.join('src', 'main')]
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
@@ -70,13 +72,14 @@ function expressionNameText(node) {
return undefined
}
-function reportAt(root, filePath, sourceFile, node, key) {
+function reportAt(root, filePath, sourceFile, node, key, fallback) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath: normalizePath(root, filePath),
line: position.line + 1,
column: position.character + 1,
- key
+ key,
+ fallback
}
}
@@ -103,7 +106,17 @@ export function collectLocalizationKeyReferences(filePath, sourceText, root = pr
firstArg &&
ts.isStringLiteralLike(firstArg)
) {
- references.push(reportAt(root, filePath, sourceFile, firstArg, firstArg.text))
+ const secondArg = node.arguments[1]
+ references.push(
+ reportAt(
+ root,
+ filePath,
+ sourceFile,
+ firstArg,
+ firstArg.text,
+ secondArg && ts.isStringLiteralLike(secondArg) ? secondArg.text : undefined
+ )
+ )
}
}
@@ -126,6 +139,52 @@ function formatMissingKeys(label, keys) {
return keys.map((key) => `${label}: ${key}`).join('\n')
}
+function normalizeInterpolationVariables(value) {
+ return collectInterpolationVariables(value)
+ .map((variable) => variable.slice(2, -2))
+ .join('|')
+}
+
+function formatInconsistentFallbackVariables(inconsistentFallbackVariables) {
+ return inconsistentFallbackVariables
+ .map(({ key, references }) => {
+ const locations = references
+ .map(
+ (reference) =>
+ ` ${reference.filePath}:${reference.line}:${reference.column} ${JSON.stringify(reference.fallback)}`
+ )
+ .join('\n')
+ return `${key}\n${locations}`
+ })
+ .join('\n\n')
+}
+
+function collectInconsistentFallbackVariables(references) {
+ const byKey = new Map()
+
+ for (const reference of references) {
+ if (typeof reference.fallback !== 'string') {
+ continue
+ }
+ const existing = byKey.get(reference.key) ?? []
+ existing.push(reference)
+ byKey.set(reference.key, existing)
+ }
+
+ return [...byKey.entries()]
+ .map(([key, keyReferences]) => {
+ const uniqueFallbackVariables = new Set(
+ keyReferences.map((reference) => normalizeInterpolationVariables(reference.fallback))
+ )
+ return {
+ key,
+ references: keyReferences,
+ uniqueFallbackVariableCount: uniqueFallbackVariables.size
+ }
+ })
+ .filter(({ uniqueFallbackVariableCount }) => uniqueFallbackVariableCount > 1)
+}
+
function collectInterpolationVariables(value) {
if (typeof value === 'string') {
const matches = value.match(PLACEHOLDER_RE) ?? []
@@ -151,7 +210,61 @@ function flattenCatalogEntries(value, prefix = '', entries = new Map()) {
return entries
}
-function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
+function getCatalogEntry(catalog, key) {
+ return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
+}
+
+function setCatalogEntry(catalog, key, value) {
+ const parts = key.split('.')
+ let cursor = catalog
+ for (const part of parts.slice(0, -1)) {
+ if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
+ cursor[part] = {}
+ }
+ cursor = cursor[part]
+ }
+ cursor[parts.at(-1)] = value
+}
+
+function deleteCatalogEntry(catalog, key) {
+ const parts = key.split('.')
+ const stack = []
+ let cursor = catalog
+
+ for (const part of parts.slice(0, -1)) {
+ if (
+ typeof cursor?.[part] !== 'object' ||
+ cursor[part] === null ||
+ Array.isArray(cursor[part])
+ ) {
+ return false
+ }
+ stack.push([cursor, part])
+ cursor = cursor[part]
+ }
+
+ const leafKey = parts.at(-1)
+ if (!Object.hasOwn(cursor, leafKey)) {
+ return false
+ }
+
+ delete cursor[leafKey]
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
+ const [parent, part] = stack[index]
+ const child = parent[part]
+ if (
+ typeof child === 'object' &&
+ child !== null &&
+ !Array.isArray(child) &&
+ Object.keys(child).length === 0
+ ) {
+ delete parent[part]
+ }
+ }
+ return true
+}
+
+function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
@@ -169,6 +282,71 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
}
+ return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
+}
+
+function repairLocaleParity(enCatalog, localeCatalog) {
+ const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
+ collectLocaleParityIssues(enCatalog, localeCatalog)
+ let changed = 0
+
+ for (const key of missingInLocale) {
+ setCatalogEntry(localeCatalog, key, enEntries.get(key))
+ changed += 1
+ }
+
+ for (const key of extraInLocale) {
+ if (deleteCatalogEntry(localeCatalog, key)) {
+ changed += 1
+ }
+ }
+
+ for (const key of interpolationMismatches) {
+ setCatalogEntry(localeCatalog, key, enEntries.get(key))
+ changed += 1
+ }
+
+ return changed
+}
+
+function referencesMissingFallbacks(missing) {
+ return missing.filter((reference) => typeof reference.fallback !== 'string')
+}
+
+function collectMissingCatalogEntries(missing) {
+ const entries = new Map()
+
+ for (const reference of missing) {
+ if (typeof reference.fallback !== 'string') {
+ continue
+ }
+ if (!entries.has(reference.key)) {
+ entries.set(reference.key, reference.fallback)
+ }
+ }
+
+ return entries
+}
+
+function applyMissingEnglishEntries(catalog, missing) {
+ const entries = collectMissingCatalogEntries(missing)
+ let changed = 0
+
+ for (const [key, fallback] of entries) {
+ if (getCatalogEntry(catalog, key) !== undefined) {
+ continue
+ }
+ setCatalogEntry(catalog, key, fallback)
+ changed += 1
+ }
+
+ return changed
+}
+
+function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
+ const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
+ collectLocaleParityIssues(enCatalog, localeCatalog)
+
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
@@ -205,12 +383,18 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 0
}
-export async function main(root = process.cwd()) {
- const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
+function parseArgs(argv) {
+ return {
+ fix: argv.includes('--fix')
+ }
+}
+
+export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
+ const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
- const catalogKeys = new Set(flattenCatalogKeys(catalog))
- const sourceRoots = [path.join(root, 'src', 'renderer', 'src'), path.join(root, 'src', 'main')]
+ let catalogKeys = new Set(flattenCatalogKeys(catalog))
+ const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
for (const sourceRoot of sourceRoots) {
@@ -224,9 +408,41 @@ export async function main(root = process.cwd()) {
const missing = references.filter((reference) => !catalogKeys.has(reference.key))
if (missing.length > 0) {
+ const missingFallbacks = referencesMissingFallbacks(missing)
+ if (options.fix && missingFallbacks.length === 0) {
+ const added = applyMissingEnglishEntries(catalog, missing)
+ await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
+ catalogKeys = new Set(flattenCatalogKeys(catalog))
+ console.log(`Added ${added} missing localization key(s) to en.json.`)
+ } else {
+ if (options.fix && missingFallbacks.length > 0) {
+ console.error('Some missing localization keys do not have string fallbacks to bootstrap.')
+ console.error('')
+ console.error(formatMissingReferences(missingFallbacks))
+ return 1
+ }
+ console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
+ console.error('')
+ console.error(formatMissingReferences(missing))
+ console.error('')
+ console.error('Run `pnpm run sync:localization-catalog` to add keys with string fallbacks.')
+ return 1
+ }
+ }
+
+ const remainingMissing = references.filter((reference) => !catalogKeys.has(reference.key))
+ if (remainingMissing.length > 0) {
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
- console.error(formatMissingReferences(missing))
+ console.error(formatMissingReferences(remainingMissing))
+ return 1
+ }
+
+ const inconsistentFallbackVariables = collectInconsistentFallbackVariables(references)
+ if (inconsistentFallbackVariables.length > 0) {
+ console.error('Localization keys are used with inconsistent interpolation placeholders.')
+ console.error('')
+ console.error(formatInconsistentFallbackVariables(inconsistentFallbackVariables))
return 1
}
@@ -246,8 +462,19 @@ export async function main(root = process.cwd()) {
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
+ if (options.fix) {
+ const repaired = repairLocaleParity(catalog, localeCatalog)
+ if (repaired > 0) {
+ await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
+ console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
+ }
+ }
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
+ if (!options.fix) {
+ console.error('')
+ console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
+ }
return exitCode
}
}
diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs
new file mode 100644
index 00000000000..ce148c7890f
--- /dev/null
+++ b/config/scripts/verify-localization-catalog.test.mjs
@@ -0,0 +1,82 @@
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
+
+function writeJson(filePath, value) {
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
+}
+
+function readJson(filePath) {
+ return JSON.parse(readFileSync(filePath, 'utf8'))
+}
+
+function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
+ const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-catalog-'))
+ const rendererDir = path.join(root, 'src', 'renderer', 'src', 'components')
+ const mainDir = path.join(root, 'src', 'main')
+ const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
+
+ mkdirSync(rendererDir, { recursive: true })
+ mkdirSync(mainDir, { recursive: true })
+ mkdirSync(localesDir, { recursive: true })
+
+ writeFileSync(path.join(rendererDir, 'Example.tsx'), sourceText, 'utf8')
+ writeFileSync(path.join(mainDir, 'empty.ts'), 'export {}\n', 'utf8')
+ writeJson(path.join(localesDir, 'en.json'), enCatalog)
+ writeJson(path.join(localesDir, 'es.json'), esCatalog)
+
+ return { root, localesDir }
+}
+
+describe('verify-localization-catalog', () => {
+ it('bootstraps missing catalog entries from string fallbacks', async () => {
+ const { root, localesDir } = makeProject({
+ sourceText:
+ "import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
+ })
+
+ await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
+ await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
+
+ expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
+ auto: { example: { greeting: 'Hello {{name}}' } }
+ })
+ expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
+ auto: { example: { greeting: 'Hello {{name}}' } }
+ })
+ })
+
+ it('repairs stale locale keys and interpolation mismatches', async () => {
+ const { root, localesDir } = makeProject({
+ sourceText:
+ "import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
+ enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } },
+ esCatalog: {
+ auto: {
+ example: { greeting: 'Hola' },
+ stale: { removed: 'Viejo' }
+ }
+ }
+ })
+
+ await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
+
+ expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
+ auto: { example: { greeting: 'Hello {{name}}' } }
+ })
+ })
+
+ it('does not invent values for keys without string fallbacks', async () => {
+ const { root, localesDir } = makeProject({
+ sourceText:
+ "import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.noFallback')\n"
+ })
+
+ await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
+ expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
+ })
+})
diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json
index eca58242c7b..82f1f4ab5fc 100644
--- a/config/tsconfig.tc.web.json
+++ b/config/tsconfig.tc.web.json
@@ -6,7 +6,9 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/shared/**/*",
+ "../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
+ "../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/wsl.ts"
],
"compilerOptions": {
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
new file mode 100644
index 00000000000..d23726970d3
--- /dev/null
+++ b/docs/assets/readme-downloads.svg
@@ -0,0 +1,21 @@
+
+ downloads: 1.0m
+
+
+
+
+
+
+
+
+
+
+
+
+
+ downloads
+ downloads
+ 1.0m
+ 1.0m
+
+
diff --git a/docs/assets/readme-hero.jpg b/docs/assets/readme-hero.jpg
new file mode 100644
index 00000000000..83d2c73cabc
Binary files /dev/null and b/docs/assets/readme-hero.jpg differ
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index fedafc84f5f..2a707c8914a 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · 中文 · 日本語 · 한국어
El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
- Disponible para macOS, Windows y Linux .
+ Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
+
+
- Descargar 🐋
+
-
-
-
-
-## Agentes compatibles
-
-Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## Características
-- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity.
-- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
-- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
-- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
-- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
-- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
-- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
+
+
+
+
+### App companion móvil
+
+Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
+
+[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### Worktrees en paralelo
+
+Lanza un mismo prompt a cinco agentes, cada uno en su propio worktree de git aislado — compara los resultados y haz merge del ganador.
+
+[Docs →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### Terminales divididas
+
+Terminales de nivel Ghostty con renderizado WebGL, divisiones infinitas y un scrollback que sobrevive a los reinicios.
+
+[Docs →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### Modo diseño
+
+Haz clic en cualquier elemento de UI en una ventana real de Chromium para enviar su HTML, su CSS y una captura recortada directo al prompt de tu agente.
+
+[Docs →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub y Linear, nativos
+
+Explora PRs, issues y tableros de proyecto dentro de la app — abre un worktree desde cualquier tarea y revisa sin cambiar de contexto.
+
+[Docs →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### Worktrees por SSH
+
+Ejecuta agentes en una máquina remota potente con edición completa de archivos, git y terminales — con reconexión automática y reenvío de puertos incluidos.
+
+[Docs →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### Anotar diffs de IA
+
+Deja comentarios en cualquier línea de un diff y envíalos de vuelta al agente — revisa, edita y haz commit sin salir de Orca.
+
+[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### Arrastra archivos a los agentes
+
+El editor de VS Code con autoguardado en todas partes — arrastra archivos o imágenes directo al prompt de un agente.
+
+[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+Los agentes también manejan Orca — automatiza cualquier flujo de trabajo con `orca worktree create`, `snapshot`, `click` y `fill`.
+
+[Docs →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**También incluye:**
+
+- **[Apertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Busca entre worktrees, archivos, agentes, comandos y contexto del repo sin salir de tu flujo.
+- **[Cambio de cuenta y seguimiento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Consulta el uso de Claude y Codex y los reinicios de límites de uso, y cambia de cuenta al instante sin volver a iniciar sesión.
+- **[Previews ricos del repo](https://www.onorca.dev/docs/editing/markdown)** — Previsualiza Markdown, imágenes, PDFs y documentos del repo en el workspace.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deja que los agentes manejen apps de escritorio y UI visible cuando un flujo de trabajo necesita interacción real.
+- **[Notificaciones y estado de no leído](https://www.onorca.dev/docs/notifications)** — Entérate cuando un agente termine o necesite tu atención, y marca hilos como no leídos para retomarlos después.
+- **Y muchas, muchas más** — lanzamos a diario, así que esta lista siempre va atrasada. El [changelog](https://github.com/stablyai/orca/releases) es la verdadera lista de funciones.
+
+---
+
+## Agentes compatibles
+
+Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orca.
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## Instalación
-### Mac, Linux, Windows
+### Escritorio — macOS, Windows, Linux
-- **[Descarga desde onOrca.dev](https://onOrca.dev)**
-- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
+- **[Descarga desde onOrca.dev](https://onorca.dev/download)**
+- O descarga un build directamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos los builds](https://github.com/stablyai/orca/releases/latest)
-_También puedes instalar desde un gestor de paquetes:_
-
-### macOS (Homebrew)
+_O mediante un gestor de paquetes:_
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# Binario precompilado
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# Compilar desde el código de GitHub
-yay -S stably-orca-git
```
----
+### App companion móvil — iOS, Android
-## App companion móvil
-
-Controla tus agentes desde el teléfono.
-
-
-
-
+Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar APK desde GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## Showcase de funciones
-
-Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
-
-
- Worktrees en paralelo
- Terminales divididas
- Modo diseño
- GitHub y Linear nativos
- Cualquier agente CLI
- Worktrees por SSH
- Archivos a agentes
- Anotar diffs de IA
- Orca CLI
- Búsqueda nativa
- Cambio de cuenta y seguimiento de uso
- Previews ricos del repo
- Divide cualquier cosa
-
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -142,10 +236,19 @@ Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
- **Discord:** Únete a la comunidad en **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Sigue a **[@orca_build](https://x.com/orca_build)** para novedades y anuncios.
- **Feedback e ideas:** Lanzamos rápido. ¿Te falta algo? [Pide una nueva feature](https://github.com/stablyai/orca/issues).
-- **Muéstranos tu apoyo:** Dale una estrella al repo para seguir nuestros lanzamientos diarios.
+- **Privacidad:** Consulta la [documentación de privacidad y telemetría](https://www.onorca.dev/docs/telemetry) para saber qué datos anónimos de uso recopila Orca y cómo desactivar su envío.
+- **Muéstranos tu apoyo:** Dale una [estrella](https://github.com/stablyai/orca) a este repo para seguir nuestros lanzamientos diarios.
---
## Desarrollo
-¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../.github/CONTRIBUTING.md).
+¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
+
+
+
+
+
+## Licencia
+
+Orca es libre y de código abierto bajo la [Licencia MIT](../../LICENSE).
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 07ad4e312b4..a602c3fed36 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 中文 · 한국어
100x ビルダーのための AI オーケストレーター。
- Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
- macOS、Windows、Linux で利用できます。
+ Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
+
+
- ダウンロード 🐋
+
-
-
-
-
-## 対応するエージェント
-
-Orca は任意の CLI エージェントに対応しています(_このリストに限定されません_)。
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 機能
-- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
-- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
-- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
-- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
-- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
-- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
-- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
+
+
+
+
+### モバイル Companion
+
+スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 並列ワークツリー
+
+1 つのプロンプトを 5 つのエージェントに展開し、それぞれを独立した git ワークツリーで実行 — 結果を比較して、最良のものをマージできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### ターミナル分割
+
+WebGL レンダリング、無制限の分割、再起動後も残るスクロールバックを備えた Ghostty クラスのターミナル。
+
+[ドキュメント →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### デザインモード
+
+実際の Chromium ウィンドウで任意の UI 要素をクリックすると、その HTML、CSS、切り抜いたスクリーンショットがそのままエージェントのプロンプトに送られます。
+
+[ドキュメント →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear をネイティブに
+
+PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意のタスクからワークツリーを開き、コンテキストスイッチなしでレビューできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH ワークツリー
+
+強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。
+
+[ドキュメント →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### AI Diff に注釈
+
+任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。
+
+[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### ファイルをエージェントへドラッグ
+
+オートセーブが全面的に効く VS Code のエディタ — ファイルや画像をそのままエージェントのプロンプトへドラッグできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+エージェント自身も Orca を操作できます — `orca worktree create`、`snapshot`、`click`、`fill` であらゆるワークフローをスクリプト化できます。
+
+[ドキュメント →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**さらに同梱:**
+
+- **[クイックオープン](https://www.onorca.dev/docs/model/quick-open)** — フローを離れずに、ワークツリー、ファイル、エージェント、コマンド、リポジトリコンテキストを横断検索できます。
+- **[アカウント切り替えと使用量トラッキング](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude と Codex の使用量やレート制限のリセットを確認し、再ログインなしでアカウントを切り替えられます。
+- **[リッチなリポジトリプレビュー](https://www.onorca.dev/docs/editing/markdown)** — Markdown、画像、PDF、リポジトリ文書をワークスペース内でプレビューできます。
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 実際の操作が必要なワークフローでは、エージェントにデスクトップアプリや画面上の UI を操作させられます。
+- **[通知と未読ステータス](https://www.onorca.dev/docs/notifications)** — エージェントの完了や要対応をすぐに把握し、スレッドを未読に戻して後で確認できます。
+- **その他、まだまだたくさん** — 毎日リリースしているので、このリストは常に追いついていません。本当の機能一覧は[チェンジログ](https://github.com/stablyai/orca/releases)です。
+
+---
+
+## 対応するエージェント
+
+**あらゆる CLI エージェント**で動作します — ターミナルで動くものなら、Orca でも動きます。
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## インストール
-### Mac, Linux, Windows
+### デスクトップ — macOS, Windows, Linux
-- **[onOrca.dev からダウンロード](https://onOrca.dev)**
-- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
+- **[onOrca.dev からダウンロード](https://onorca.dev/download)**
+- またはビルドを直接入手: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [すべてのビルド](https://github.com/stablyai/orca/releases/latest)
_パッケージマネージャーからもインストールできます:_
-### macOS (Homebrew)
-
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# ビルド済みバイナリ
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# GitHub ソースからビルド
-yay -S stably-orca-git
```
----
+### モバイル Companion — iOS, Android
-## モバイル Companion アプリ
-
-スマートフォンからエージェントを操作できます。
-
-
-
-
+デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [GitHub Releases から APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 機能ショーケース
-
-各タイルをクリックすると、そのワークフローを確認できます。
-
-
- 並列ワークツリー
- ターミナル分割
- デザインモード
- GitHub と Linear をネイティブに
- 任意の CLI エージェント
- SSH ワークツリー
- ファイルをエージェントへ
- AI Diff 注釈
- Orca CLI
- ネイティブ検索
- アカウント切り替えと使用量トラッキング
- リッチなリポジトリプレビュー
- 何でも分割表示
-
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -141,11 +235,20 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加してください。
- **Twitter / X:** アップデートやお知らせは **[@orca_build](https://x.com/orca_build)** をフォローしてください。
-- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
-- **応援する:** 毎日のリリースを追うために、このリポジトリにスターを付けてください。
+- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues)してください。
+- **プライバシー:** Orca が収集する匿名の利用データとオプトアウトの方法については、[プライバシーとテレメトリーのドキュメント](https://www.onorca.dev/docs/telemetry)をご覧ください。
+- **応援する:** 毎日のリリースを追うために、このリポジトリに[スター](https://github.com/stablyai/orca)を付けてください。
---
## 開発について
-貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。
+貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) ガイドをご覧ください。
+
+
+
+
+
+## ライセンス
+
+Orca は [MIT License](../../LICENSE) の下で無料かつオープンソースです。
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6568f629885..3ef6406230a 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 中文 · 日本語
100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
- macOS, Windows, Linux 에서 사용할 수 있습니다.
+ Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+
+
- 다운로드 🐋
+
-
-
-
-
-## 지원 에이전트
-
-Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정되지 않습니다_).
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 기능
-- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
-- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
-- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
-- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
-- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
-- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
-- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
+
+
+
+
+### 모바일 Companion
+
+휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 병렬 Worktree
+
+하나의 프롬프트를 다섯 에이전트에 동시에 보내세요. 각 에이전트는 격리된 자체 git worktree에서 실행됩니다 — 결과를 비교하고 가장 좋은 것을 머지하세요.
+
+[문서 →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### 터미널 분할
+
+WebGL 렌더링, 무한 분할, 재시작 후에도 유지되는 스크롤백을 갖춘 Ghostty급 터미널.
+
+[문서 →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### 디자인 모드
+
+실제 Chromium 창에서 UI 요소를 클릭하면 해당 HTML, CSS, 잘라낸 스크린샷이 에이전트 프롬프트로 바로 전송됩니다.
+
+[문서 →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear 네이티브
+
+PR, issue, 프로젝트 보드를 앱 안에서 탐색하세요 — 어떤 작업에서든 worktree를 열고 컨텍스트 전환 없이 리뷰할 수 있습니다.
+
+[문서 →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH Worktree
+
+강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다.
+
+[문서 →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### AI Diff 주석
+
+diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다.
+
+[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### 에이전트로 파일 드래그
+
+어디서나 자동 저장되는 VS Code 에디터 — 파일이나 이미지를 에이전트 프롬프트로 바로 드래그하세요.
+
+[문서 →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+에이전트도 Orca를 조작할 수 있습니다 — `orca worktree create`, `snapshot`, `click`, `fill`로 모든 워크플로를 스크립팅하세요.
+
+[문서 →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**그 밖에 기본으로 제공되는 기능:**
+
+- **[빠른 열기](https://www.onorca.dev/docs/model/quick-open)** — 작업 흐름을 벗어나지 않고 worktree, 파일, 에이전트, 커맨드, 리포지토리 컨텍스트를 검색하세요.
+- **[계정 전환 및 사용량 추적](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude와 Codex의 사용량과 rate limit 초기화 시점을 확인하고, 다시 로그인하지 않고 계정을 바로 전환하세요.
+- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
+- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
+- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
+
+---
+
+## 지원 에이전트
+
+**모든 CLI 에이전트**와 함께 작동합니다 — 터미널에서 실행되는 에이전트라면 Orca에서도 실행됩니다.
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## 설치
-### Mac, Linux, Windows
+### 데스크톱 — macOS, Windows, Linux
-- **[onOrca.dev에서 다운로드](https://onOrca.dev)**
-- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기
+- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
+- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
-_패키지 매니저로도 설치할 수 있습니다:_
-
-### macOS (Homebrew)
+_또는 패키지 매니저로 설치:_
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# 사전 컴파일된 바이너리
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# GitHub 소스에서 빌드
-yay -S stably-orca-git
```
----
+### 모바일 Companion — iOS, Android
-## 모바일 Companion 앱
-
-휴대폰에서 에이전트를 제어하세요.
-
-
-
-
+데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [GitHub Releases에서 APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 기능 쇼케이스
-
-타일을 클릭해 각 워크플로를 살펴보세요.
-
-
- 병렬 Worktree
- 터미널 분할
- 디자인 모드
- GitHub 및 Linear 네이티브
- 모든 CLI 에이전트
- SSH Worktree
- 에이전트로 파일 드래그
- AI Diff 주석
- Orca CLI
- 네이티브 검색
- 계정 전환 및 사용량 추적
- 풍부한 리포지토리 미리보기
- 무엇이든 분할
-
+- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -142,10 +236,19 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
-- **응원하기:** 이 리포지토리에 star를 눌러 일일 릴리스를 따라와 주세요.
+- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
+- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
---
## 개발
-기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 가이드를 확인하세요.
+기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) 가이드를 확인하세요.
+
+
+
+
+
+## 라이선스
+
+Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 5615bf81cd0..6839197a371 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -3,149 +3,252 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 日本語 · 한국어
面向 100x 构建者的 AI 编排器。
- 跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
- 支持 macOS、Windows 和 Linux 。
+ 并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
+
+
- 下载 🐋
+
-
-
-
-
-## 支持的智能体
-
-Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 特性
-- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
-- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
-- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
-- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
-- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
-- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
-- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
+
+
+
+
+### 移动 Companion 应用
+
+用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 并行 Worktree
+
+把一个提示同时分发给五个智能体,每个都在自己隔离的 git worktree 中运行 — 比较结果,合并最佳方案。
+
+[文档 →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### 终端分屏
+
+Ghostty 级终端,支持 WebGL 渲染、无限分屏,以及重启后依然保留的滚动历史。
+
+[文档 →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### 设计模式
+
+在真实的 Chromium 窗口中点击任意 UI 元素,把它的 HTML、CSS 和裁剪好的截图直接发送到智能体的提示中。
+
+[文档 →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear 原生集成
+
+在应用内浏览 PR、issue 和项目看板 — 从任意任务打开 worktree,无需切换上下文即可完成评审。
+
+[文档 →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH Worktree
+
+在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。
+
+[文档 →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### 标注 AI Diff
+
+在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。
+
+[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### 拖文件给智能体
+
+VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智能体提示。
+
+[文档 →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+智能体也能驱动 Orca — 用 `orca worktree create`、`snapshot`、`click` 和 `fill` 把每个工作流脚本化。
+
+[文档 →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**开箱即用的还有:**
+
+- **[快速打开](https://www.onorca.dev/docs/model/quick-open)** — 在 worktree、文件、智能体、命令和仓库上下文之间搜索,不打断你的心流。
+- **[账号切换与用量追踪](https://www.onorca.dev/docs/agents/usage-tracking)** — 查看 Claude 和 Codex 的用量与限额重置时间,并且无需重新登录即可热切换账号。
+- **[丰富仓库预览](https://www.onorca.dev/docs/editing/markdown)** — 在工作区中预览 Markdown、图片、PDF 和仓库文档。
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 当工作流需要真实交互时,让智能体操作桌面应用和可见 UI。
+- **[通知与未读状态](https://www.onorca.dev/docs/notifications)** — 第一时间知道智能体何时完成或需要关注,并可将会话标记为未读,稍后再回来处理。
+- **还有很多很多** — 我们每天发布新功能,这个列表永远跟不上。[更新日志](https://github.com/stablyai/orca/releases)才是真正的功能列表。
+
+---
+
+## 支持的智能体
+
+适配**任何 CLI 智能体** — 只要能在终端里运行,就能在 Orca 里运行。
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + 任何 CLI 智能体
+
---
## 安装
-### Mac, Linux, Windows
+### 桌面端 — macOS、Windows、Linux
-- **[从 onOrca.dev 下载](https://onOrca.dev)**
-- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
+- **[从 onOrca.dev 下载](https://onorca.dev/download)**
+- 或直接获取安装包:[macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [全部构建](https://github.com/stablyai/orca/releases/latest)
_也可以通过包管理器安装:_
-### macOS (Homebrew)
-
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# 预编译二进制
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# 从 GitHub 源码构建
-yay -S stably-orca-git
```
----
+### 移动 Companion 应用 — iOS、Android
-## 移动 Companion 应用
-
-用手机控制你的智能体。
-
-
-
-
+与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [从 GitHub Releases 下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 功能展示
-
-点击任意卡片了解对应工作流。
-
-
- 并行 Worktree
- 终端分屏
- 设计模式
- GitHub 与 Linear 原生集成
- 任意 CLI 智能体
- SSH Worktree
- 拖文件给智能体
- 标注 AI Diff
- Orca CLI
- 原生搜索
- 账号切换与用量追踪
- 丰富仓库预览
- 任意分屏
-
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
## 社区与支持
-- **Discord:** 加入我们的 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
+- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
-- **支持我们:** 给这个仓库点 Star,关注我们的日常发布。
+- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
+- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
---
## 开发
-想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。
+想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) 指南。
+
+
+
+
+
+## 许可证
+
+Orca 是自由且开源的软件,遵循 [MIT 许可证](../../LICENSE)。
diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md
index e18aae4c2c7..a9e9eb1a46f 100644
--- a/docs/reference/feature-discovery-interaction-tracking.md
+++ b/docs/reference/feature-discovery-interaction-tracking.md
@@ -26,7 +26,7 @@ Do not upload this state as broad analytics. Product analytics should continue t
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
-| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
+| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
@@ -62,6 +62,7 @@ Orca also records surface-level interactions for feature areas where opening the
- `browser`: non-blank browser page viewed
- `tasks`: Tasks page opened
- `automations`: Automations page opened
+- `floating-workspace`: floating workspace opened
- `workspace-creation`: workspace creation flow opened
These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?"
diff --git a/docs/reference/telemetry-availability.md b/docs/reference/telemetry-availability.md
index cf4fc552294..e6d4924d699 100644
--- a/docs/reference/telemetry-availability.md
+++ b/docs/reference/telemetry-availability.md
@@ -88,7 +88,7 @@ Dashboard caveats:
### 2026-05-09 - Onboarding Cohort Injection
-Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster.
+Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_windows_terminal_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster.
The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas.
@@ -300,7 +300,7 @@ This is a product-flow and telemetry-interpretation boundary, not a new event ro
Dashboard caveats:
- Treat `onboarding_step_*` rows for the removed final code/project picker step as historical first-run onboarding signals after this rollout.
-- Segment numeric onboarding step analysis across this boundary. The active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`.
+- Segment numeric onboarding step analysis across this boundary. At this boundary, the active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`; later onboarding step rollouts may supersede that final-step value.
- Do not use absence of new final code/project picker rows as a drop-off signal; that step no longer exists in active onboarding.
### 2026-06-03 - Add Project Default Checkout Handoff
@@ -327,6 +327,59 @@ Dashboard caveats:
- Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow.
- Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`.
+### 2026-06-10 - Repo Added Git-vs-Folder Signal
+
+Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal.
+
+`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`.
+
+| Field | Value |
+| ------------------------ | ------------------------------------------------------------------------------------------- |
+| PR | `#5121` |
+| Merge commit | `TBD` |
+| `code_merged_at_utc` | `TBD` |
+| First release | `TBD` |
+| First release commit | `TBD` |
+| `first_released_at_utc` | `TBD` |
+| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` |
+| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. |
+
+PostHog evidence checked at `2026-06-10T19:00:00Z`:
+
+- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05.
+- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data.
+
+Dashboard caveats:
+
+- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`.
+- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments.
+- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry.
+- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder.
+
+### 2026-06-16 - Windows Terminal Preferences Onboarding Step
+
+Scope: Windows first-run onboarding adds a terminal preferences step before notifications. The step lets users choose the default Windows terminal shell and right-click paste/menu behavior before their first project handoff.
+
+`onboarding_step_*` rows can now emit `value_kind = 'windows_terminal'` at step `4`. Notifications move to step `5`, so `ONBOARDING_FINAL_STEP = 5` for current active onboarding. Non-Windows clients skip the Windows terminal step but still persist through the skipped step so resumed onboarding lands on notifications. `onboarding_windows_terminal_snapshot` records the low-cardinality selected shell bucket, right-click behavior, exit action, duration, and advance method when the visible Windows terminal step exits.
+
+| Field | Value |
+| ------------------------ | -------------------------------------------------------------------------------------- |
+| PR | `#5488` |
+| Merge commit | `68abadba8198c627fb642c41e54937c04ccddfe8` |
+| `code_merged_at_utc` | `2026-06-16T21:07:26Z` |
+| First release | `TBD` |
+| First release commit | `TBD` |
+| `first_released_at_utc` | `TBD` |
+| `first_seen_at_utc` | `TBD` on `onboarding_step_viewed { value_kind: 'windows_terminal' }` and `onboarding_windows_terminal_snapshot` |
+| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and Windows/non-Windows split plus snapshot coverage are verified. |
+
+Dashboard caveats:
+
+- Segment numeric onboarding step analysis across this boundary. Step `4` is Windows terminal preferences in the current flow, but was notifications in the previous active flow.
+- Use `value_kind` rather than numeric `step` when comparing notifications or Windows terminal setup across releases.
+- Non-Windows users can have persisted `lastCompletedStep` values that include the skipped Windows step; do not treat that as evidence they viewed the Windows terminal page.
+- `onboarding_windows_terminal_snapshot.default_shell = 'other'` means Orca could not bucket the persisted setting. It is not a raw shell path and should be monitored as telemetry quality, not a product choice.
+
## Updating This File
When adding or changing telemetry that dashboard authors will depend on:
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index b8a66ca2f5e..d9d226d54cb 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -180,6 +180,8 @@ export default defineConfig({
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
+ 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
+ 'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json
index 325faf0249a..b4b5ef57fe6 100644
--- a/mobile/.oxlintrc.json
+++ b/mobile/.oxlintrc.json
@@ -28,13 +28,19 @@
{
"files": ["src/terminal/TerminalWebView.tsx"],
"rules": {
- "max-lines": ["error", { "max": 2054, "skipBlankLines": true, "skipComments": true }]
+ "max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }]
+ }
+ },
+ {
+ "files": ["src/terminal/terminal-webview-html.ts"],
+ "rules": {
+ "max-lines": ["error", { "max": 1778, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/source-control/*.tsx"],
"rules": {
- "max-lines": ["error", { "max": 2004, "skipBlankLines": true, "skipComments": true }]
+ "max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
@@ -52,7 +58,7 @@
{
"files": ["app/index.tsx"],
"rules": {
- "max-lines": ["error", { "max": 1419, "skipBlankLines": true, "skipComments": true }]
+ "max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }]
}
},
{
@@ -64,7 +70,7 @@
{
"files": ["src/transport/rpc-client.ts"],
"rules": {
- "max-lines": ["error", { "max": 1070, "skipBlankLines": true, "skipComments": true }]
+ "max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
diff --git a/mobile/Gemfile b/mobile/Gemfile
new file mode 100644
index 00000000000..ed3bf159ae0
--- /dev/null
+++ b/mobile/Gemfile
@@ -0,0 +1,6 @@
+# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile and
+# .github/workflows/mobile-build.yml). macOS runners ship a fastlane, but
+# pinning here keeps the release toolchain stable across runner image bumps.
+source "https://rubygems.org"
+
+gem "fastlane"
diff --git a/mobile/app.json b/mobile/app.json
index 829a6c34e03..211bb770364 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
- "version": "0.0.12",
+ "version": "0.0.14",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -20,6 +20,7 @@
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
+ "NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
@@ -78,6 +79,7 @@
},
"plugins": [
"expo-router",
+ "./plugins/android-respect-rotation-lock.js",
[
"expo-splash-screen",
{
@@ -93,6 +95,12 @@
"recordAudioAndroid": false
}
],
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "Allow Orca to attach photos from your library to a terminal session on your paired desktop."
+ }
+ ],
[
"expo-build-properties",
{
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index de1160c6b35..bb404215738 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -145,8 +145,12 @@ export default function RootLayout() {
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
- headerShadowVisible: false,
- orientation: 'all'
+ headerShadowVisible: false
+ // Why: deliberately no `orientation` screenOption. react-native-screens
+ // has no value that respects the device rotation lock — even 'default'
+ // calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
+ // manifest. Leaving it unset lets the manifest's "fullUser" (set by the
+ // android-respect-rotation-lock config plugin) honor the auto-rotate lock.
}}
>
+
diff --git a/mobile/app/h/[hostId]/accounts-screen-styles.ts b/mobile/app/h/[hostId]/accounts-screen-styles.ts
new file mode 100644
index 00000000000..4a987b7a442
--- /dev/null
+++ b/mobile/app/h/[hostId]/accounts-screen-styles.ts
@@ -0,0 +1,137 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
+
+export const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ topRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.sm,
+ gap: spacing.sm
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ iconButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ titleWrap: {
+ flex: 1
+ },
+ heading: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: colors.textPrimary
+ },
+ subheading: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary,
+ marginTop: 1
+ },
+ scroll: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.sm
+ },
+ section: {
+ marginBottom: spacing.xl
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ marginBottom: spacing.sm
+ },
+ sectionHeading: {
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ color: colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5
+ },
+ card: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.card,
+ overflow: 'hidden'
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ rowMain: {
+ flex: 1,
+ gap: 4
+ },
+ // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
+ // same width whether or not the row is currently selected (otherwise the
+ // checkmark on the active account squeezes the bars narrower than the
+ // inactive rows above/below it).
+ rowTrailing: {
+ width: 24,
+ alignItems: 'flex-end',
+ justifyContent: 'center',
+ marginLeft: spacing.sm
+ },
+ rowTitle: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ rowSubtitle: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ usageRow: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ marginTop: 4
+ },
+ errorText: {
+ fontSize: typography.metaSize,
+ color: colors.statusRed
+ },
+ placeholder: {
+ paddingVertical: spacing.xl * 2,
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ placeholderText: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary
+ },
+ footerHint: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.sm,
+ paddingTop: spacing.sm
+ },
+ footerHintText: {
+ flex: 1,
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ lineHeight: 18
+ }
+})
diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx
index e38c6579336..2a5f78abfa8 100644
--- a/mobile/app/h/[hostId]/accounts.tsx
+++ b/mobile/app/h/[hostId]/accounts.tsx
@@ -2,7 +2,6 @@ import { useEffect, useState, useCallback } from 'react'
import {
View,
Text,
- StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
@@ -15,13 +14,16 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
-import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
+import { colors, spacing } from '../../../src/theme/mobile-theme'
+import { styles } from './accounts-screen-styles'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
+ getUsageBarState,
+ hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
@@ -132,6 +134,8 @@ export default function AccountsScreen() {
}
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
+ const activeSessionBar = getUsageBarState(activeUsage, 'session')
+ const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
@@ -149,6 +153,25 @@ export default function AccountsScreen() {
System default
Use the agent's own login
+ {/* Why: when system default is the active selection, activeUsage
+ holds the system-default login's rate limits — surface them
+ here so non-managed users still see their usage. */}
+ {state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
+
+
+
+
+ ) : null}
{state.activeAccountId === null ? (
@@ -164,12 +187,12 @@ export default function AccountsScreen() {
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
- const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
+ const usage = isActive ? activeUsage : (inactiveEntry?.rateLimits ?? null)
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
- const session = usage?.session
- const weekly = usage?.weekly
+ const sessionBar = getUsageBarState(usage, 'session', isFetching)
+ const weeklyBar = getUsageBarState(usage, 'weekly', isFetching)
return (
@@ -185,15 +208,15 @@ export default function AccountsScreen() {
{usage?.error ? (
@@ -285,138 +308,3 @@ export default function AccountsScreen() {
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
- topRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.md,
- paddingTop: spacing.sm,
- paddingBottom: spacing.sm,
- gap: spacing.sm
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center'
- },
- iconButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center'
- },
- titleWrap: {
- flex: 1
- },
- heading: {
- fontSize: 20,
- fontWeight: '700',
- color: colors.textPrimary
- },
- subheading: {
- fontSize: typography.metaSize,
- color: colors.textSecondary,
- marginTop: 1
- },
- scroll: {
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.sm
- },
- section: {
- marginBottom: spacing.xl
- },
- sectionHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm,
- marginBottom: spacing.sm
- },
- sectionHeading: {
- fontSize: typography.metaSize,
- fontWeight: '600',
- color: colors.textSecondary,
- textTransform: 'uppercase',
- letterSpacing: 0.5
- },
- card: {
- backgroundColor: colors.bgPanel,
- borderRadius: radii.card,
- overflow: 'hidden'
- },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- rowMain: {
- flex: 1,
- gap: 4
- },
- // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
- // same width whether or not the row is currently selected (otherwise the
- // checkmark on the active account squeezes the bars narrower than the
- // inactive rows above/below it).
- rowTrailing: {
- width: 24,
- alignItems: 'flex-end',
- justifyContent: 'center',
- marginLeft: spacing.sm
- },
- rowTitle: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary
- },
- rowSubtitle: {
- fontSize: typography.metaSize,
- color: colors.textSecondary
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- usageRow: {
- flexDirection: 'row',
- gap: spacing.md,
- marginTop: 4
- },
- errorText: {
- fontSize: typography.metaSize,
- color: colors.statusRed
- },
- placeholder: {
- paddingVertical: spacing.xl * 2,
- alignItems: 'center',
- gap: spacing.sm
- },
- placeholderText: {
- fontSize: typography.bodySize,
- color: colors.textSecondary
- },
- footerHint: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- gap: spacing.sm,
- paddingHorizontal: spacing.sm,
- paddingTop: spacing.sm
- },
- footerHintText: {
- flex: 1,
- fontSize: typography.metaSize,
- color: colors.textMuted,
- lineHeight: 18
- }
-})
diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx
index 1f3d620d068..9cad1ff33f1 100644
--- a/mobile/app/h/[hostId]/files/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx
@@ -10,111 +10,30 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
-import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native'
-import { useHostClient } from '../../../../src/transport/client-context'
+import {
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ File,
+ FileText,
+ Folder,
+ Image as ImageIcon
+} from 'lucide-react-native'
+import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
+import { getWorktreeLabel } from '../../../../src/session/worktree-label'
+import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
+import {
+ buildTree,
+ flattenTree,
+ isMarkdownPath,
+ type FilesListResult,
+ type MobileFileEntry,
+ type TreeNode
+} from '../../../../src/files/file-tree'
import type { RpcSuccess } from '../../../../src/transport/types'
import { triggerError, triggerSelection } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
-type MobileFileEntry = {
- relativePath: string
- basename: string
- kind: 'text' | 'binary'
-}
-
-type FilesListResult = {
- files: MobileFileEntry[]
- totalCount: number
- truncated: boolean
-}
-
-type TreeNode = {
- id: string
- name: string
- relativePath: string
- depth: number
- kind: 'directory' | 'text' | 'binary'
-}
-
-type DirectoryNode = {
- name: string
- relativePath: string
- directories: Map
- files: MobileFileEntry[]
-}
-
-function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
- return { name, relativePath, directories: new Map(), files: [] }
-}
-
-function buildTree(files: MobileFileEntry[]): DirectoryNode {
- const root = createDirectoryNode('', '')
- for (const file of files) {
- const parts = file.relativePath.split('/').filter(Boolean)
- let current = root
- for (let index = 0; index < parts.length - 1; index += 1) {
- const name = parts[index]!
- const relativePath = parts.slice(0, index + 1).join('/')
- let child = current.directories.get(name)
- if (!child) {
- child = createDirectoryNode(name, relativePath)
- current.directories.set(name, child)
- }
- current = child
- }
- current.files.push(file)
- }
- return root
-}
-
-function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] {
- const rows: TreeNode[] = []
- const visit = (directory: DirectoryNode, depth: number): void => {
- const dirs = Array.from(directory.directories.values()).sort((a, b) =>
- a.name.localeCompare(b.name)
- )
- for (const child of dirs) {
- rows.push({
- id: `dir:${child.relativePath}`,
- name: child.name,
- relativePath: child.relativePath,
- depth,
- kind: 'directory'
- })
- if (expanded.has(child.relativePath)) {
- visit(child, depth + 1)
- }
- }
- const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
- for (const file of files) {
- rows.push({
- id: `file:${file.relativePath}`,
- name: file.basename,
- relativePath: file.relativePath,
- depth,
- kind: file.kind
- })
- }
- }
- visit(root, 0)
- return rows
-}
-
-function isMarkdownPath(relativePath: string): boolean {
- return /\.(md|mdx|markdown)$/i.test(relativePath)
-}
-
-function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
- if (name?.trim()) {
- return name.trim()
- }
- const pathPart = worktreeId.includes('::')
- ? worktreeId.slice(worktreeId.indexOf('::') + 2)
- : worktreeId
- const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
- return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
-}
-
export default function MobileFileExplorerScreen() {
const { hostId, worktreeId, name } = useLocalSearchParams<{
hostId: string
@@ -123,6 +42,7 @@ export default function MobileFileExplorerScreen() {
}>()
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
+ const forceReconnect = useForceReconnect()
const [files, setFiles] = useState([])
const [expanded, setExpanded] = useState>(() => new Set())
const [loading, setLoading] = useState(true)
@@ -174,8 +94,8 @@ export default function MobileFileExplorerScreen() {
}, [])
const openFile = useCallback(
- async (relativePath: string, kind: 'text' | 'binary') => {
- if (!client || kind === 'binary') {
+ async (relativePath: string) => {
+ if (!client) {
return
}
setOpeningPath(relativePath)
@@ -196,13 +116,16 @@ export default function MobileFileExplorerScreen() {
setOpeningPath(null)
}
},
- [client, hostId, name, router, worktreeId]
+ [client, router, worktreeId]
)
const renderItem: ListRenderItem = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
- const disabled = item.kind === 'binary'
+ // Images render in the mobile viewer (via files.readPreview), so a binary
+ // image is openable; only non-previewable binaries are unavailable.
+ const isImage = item.kind === 'binary' && classifyMobileArtifact(item.relativePath) === 'image'
+ const disabled = item.kind === 'binary' && !isImage
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
{
if (isDirectory) {
toggleDirectory(item.relativePath)
- } else if (item.kind === 'text' || item.kind === 'binary') {
- void openFile(item.relativePath, item.kind)
+ } else if (!disabled) {
+ void openFile(item.relativePath)
}
}}
accessibilityLabel={
@@ -241,6 +164,8 @@ export default function MobileFileExplorerScreen() {
) : markdown ? (
+ ) : isImage ? (
+
) : (
)}
@@ -287,7 +212,15 @@ export default function MobileFileExplorerScreen() {
) : error ? (
{error}
- void loadFiles()}>
+ {/* Why: while disconnected, re-sending the request is useless — revive
+ the parked transport instead (issue #5049); loadFiles re-runs via
+ its effect once the new client connects. */}
+
+ connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
+ }
+ >
Retry
@@ -349,9 +282,7 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
- list: {
- flex: 1
- },
+ list: { flex: 1 },
listContent: {
paddingVertical: spacing.sm
},
diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx
new file mode 100644
index 00000000000..8ac0eb8a89b
--- /dev/null
+++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx
@@ -0,0 +1,207 @@
+import { useCallback, useEffect, useState } from 'react'
+import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useLocalSearchParams, useRouter } from 'expo-router'
+import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
+import { useHostClient } from '../../../../src/transport/client-context'
+import type { RpcSuccess } from '../../../../src/transport/types'
+import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
+import {
+ fetchMobileGitHistory,
+ mapMobileCommitRows,
+ type MobileCommitRow
+} from '../../../../src/source-control/mobile-git-history'
+import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
+
+function firstParam(value: string | string[] | undefined): string {
+ return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
+}
+
+export default function HistoryScreen() {
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ worktreeId?: string | string[]
+ }>()
+ const hostId = firstParam(params.hostId)
+ const worktreeId = firstParam(params.worktreeId)
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const { client, state: connState } = useHostClient(hostId)
+
+ const [rows, setRows] = useState(null)
+ const [error, setError] = useState(null)
+ const [expanded, setExpanded] = useState(null)
+ const [filesById, setFilesById] = useState>({})
+
+ useEffect(() => {
+ let active = true
+ if (!client || connState !== 'connected' || !worktreeId) {
+ return
+ }
+ // Reset prior error/rows so a successful retry doesn't stay stuck behind a
+ // stale error (error wins render precedence).
+ setError(null)
+ setRows(null)
+ void (async () => {
+ try {
+ const result = await fetchMobileGitHistory(client, worktreeId)
+ if (active) {
+ setRows(mapMobileCommitRows(result, Date.now()))
+ }
+ } catch (err) {
+ if (active) {
+ setError(err instanceof Error ? err.message : 'Failed to load history')
+ }
+ }
+ })()
+ return () => {
+ active = false
+ }
+ }, [client, connState, worktreeId])
+
+ const toggleCommit = useCallback(
+ (row: MobileCommitRow) => {
+ const next = expanded === row.id ? null : row.id
+ setExpanded(next)
+ if (next && client && !filesById[row.id]) {
+ setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
+ void client
+ .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
+ .then((response) => {
+ const entries = response.ok
+ ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
+ : []
+ setFilesById((prev) => ({ ...prev, [row.id]: entries }))
+ })
+ .catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
+ }
+ },
+ [client, expanded, filesById, worktreeId]
+ )
+
+ const renderCommit = useCallback(
+ ({ item }: { item: MobileCommitRow }) => {
+ const files = filesById[item.id]
+ const isOpen = expanded === item.id
+ return (
+
+ [styles.commitHeader, pressed && styles.commitHeaderPressed]}
+ onPress={() => toggleCommit(item)}
+ >
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {item.subject}
+
+
+ {item.shortId} · {item.author} · {item.relativeTime}
+
+
+
+ {isOpen ? (
+
+ {files === 'loading' || files === undefined ? (
+
+ ) : files.length === 0 ? (
+ No file changes
+ ) : (
+ files.map((file) => (
+
+
+ {file.path}
+
+
+ {file.added ? +{file.added} : null}
+ {file.removed ? -{file.removed} : null}
+
+
+ ))
+ )}
+
+ ) : null}
+
+ )
+ },
+ [expanded, filesById, toggleCommit]
+ )
+
+ return (
+
+
+ router.back()} accessibilityLabel="Back">
+
+
+ Commit History
+
+ {error ? (
+
+ {error}
+
+ ) : rows === null ? (
+
+
+
+ ) : rows.length === 0 ? (
+
+ No commits.
+
+ ) : (
+ row.id}
+ contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
+ />
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.bgBase },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ gap: spacing.sm
+ },
+ back: { padding: spacing.xs },
+ title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
+ stateText: { color: colors.textMuted, fontSize: typography.bodySize },
+ commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
+ commitHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm + 2
+ },
+ commitHeaderPressed: { backgroundColor: colors.bgRaised },
+ commitMain: { flex: 1, minWidth: 0 },
+ commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
+ commitMeta: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily,
+ marginTop: 2
+ },
+ files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
+ fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ filePath: {
+ flex: 1,
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily
+ },
+ fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
+ add: { color: colors.gitDecorationAdded },
+ del: { color: colors.gitDecorationDeleted },
+ empty: { color: colors.textMuted, fontSize: typography.metaSize }
+})
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index d64f94269d6..3ee2c1cbb04 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -14,9 +14,7 @@ import {
Search,
X,
Pin,
- Bell,
GitBranch,
- GitPullRequest,
List,
SlidersHorizontal,
Layers,
@@ -43,30 +41,44 @@ import {
type ConnectionVerdict
} from '../../../src/transport/connection-health'
import type { RpcSuccess } from '../../../src/transport/types'
-import { triggerMediumImpact } from '../../../src/platform/haptics'
import { StatusDot } from '../../../src/components/StatusDot'
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
-import { AgentSpinner } from '../../../src/components/AgentSpinner'
+import { MobileRepoIcon } from '../../../src/components/MobileRepoIcon'
+import { WorktreeListRow } from '../../../src/components/WorktreeListRow'
+import { useNow } from '../../../src/hooks/use-now'
+import { useActiveWorktreeScroll } from '../../../src/hooks/use-active-worktree-scroll'
+import type { RepoIcon } from '../../../../src/shared/repo-icon'
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
+import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
-import {
- loadPinnedIds,
- savePinnedIds,
- loadPreferences,
- savePreferences
-} from '../../../src/storage/preferences'
+import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from '../../../src/host-route-action-state'
+import {
+ applyDesktopViewSettings,
+ groupModeToDesktop,
+ type MobileGroupMode,
+ type MobileSortMode,
+ type MobileViewState,
+ type WorkspaceViewSettings
+} from '../../../src/worktree/workspace-view-settings'
+import {
+ buildSections,
+ getWorktreeStatus,
+ isWorktreePinned,
+ type FilterState,
+ type Worktree
+} from '../../../src/worktree/workspace-list-sections'
// Why: locally-typed subset of the desktop's RuntimeStatus we read from
// `status.get`. Only the version fields matter to mobile today; everything
@@ -77,268 +89,34 @@ type DesktopStatus = {
minCompatibleMobileVersion?: number
}
-type Worktree = {
- worktreeId: string
- repo: string
- branch: string
- displayName: string
- // Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
- // marine-creature fallback dedupes against the actual filesystem basenames
- // (matching the desktop's collision check), not against displayName which
- // the user may have renamed.
- path: string
- liveTerminalCount: number
- hasAttachedPty: boolean
- preview: string
- unread: boolean
- lastOutputAt?: number
- isPinned: boolean
- linkedPR: { number: number; state: string } | null
- status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
-}
-
+// repo.list response item — captures id (desktop filter key) plus the visual
+// metadata keyed by displayName the section headers/rows already use.
type RepoSummary = {
+ id: string
displayName: string
badgeColor?: string
-}
-
-type SortMode = 'smart' | 'name' | 'recent' | 'repo'
-type _FilterMode = 'all' | 'active'
-type GroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
-
-type FilterState = {
- activeOnly: boolean
- selectedRepos: Set
+ repoIcon?: RepoIcon | null
}
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
}
-const SORT_OPTIONS: PickerOption[] = [
+const SORT_OPTIONS: PickerOption[] = [
{ value: 'smart', label: 'Smart', subtitle: 'Unread and active first' },
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
- { value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' }
+ { value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' },
+ { value: 'manual', label: 'Manual', subtitle: 'Server order' }
]
-const GROUP_OPTIONS: PickerOption[] = [
+const GROUP_OPTIONS: PickerOption[] = [
{ value: 'none', label: 'No Grouping' },
{ value: 'workspaceStatus', label: 'Status' },
{ value: 'repo', label: 'Repository' },
{ value: 'prStatus', label: 'PR Status' }
]
-function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
- if (w.status) {
- return w.status
- }
- if (w.liveTerminalCount > 0) {
- return 'active'
- }
- return 'inactive'
-}
-
-// Why: the previous 10-minute lastOutputAt window was too strict — most
-// worktrees with idle terminal prompts had no recent output and were excluded.
-// Any worktree with live terminals or unread output counts as "active".
-function isWorktreeActive(w: Worktree): boolean {
- if (w.unread) {
- return true
- }
- if (w.status) {
- return w.status !== 'inactive'
- }
- if (w.liveTerminalCount > 0) {
- return true
- }
- return false
-}
-
-const WORKSPACE_STATUS_LABELS: Record, string> = {
- permission: 'Needs Permission',
- working: 'Working',
- done: 'Done',
- active: 'Active',
- inactive: 'Inactive'
-}
-
-const WORKSPACE_STATUS_ORDER: ReturnType[] = [
- 'permission',
- 'working',
- 'done',
- 'active',
- 'inactive'
-]
-
-function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] {
- return [...worktrees].sort((a, b) => {
- if (mode === 'name') {
- return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
- }
- if (mode === 'recent') {
- return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
- }
- if (mode === 'repo') {
- const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
- return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
- }
- // 'smart' — attention-first
- if (a.unread !== b.unread) {
- return a.unread ? -1 : 1
- }
- const aStatus = getWorktreeStatus(a)
- const bStatus = getWorktreeStatus(b)
- const statusOrder = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 }
- if (statusOrder[aStatus] !== statusOrder[bStatus]) {
- return statusOrder[aStatus] - statusOrder[bStatus]
- }
- if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0)) {
- return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
- }
- return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
- })
-}
-
-function filterWorktrees(worktrees: Worktree[], filters: FilterState, search: string): Worktree[] {
- let result = worktrees
- if (filters.activeOnly) {
- result = result.filter(isWorktreeActive)
- }
- if (filters.selectedRepos.size > 0) {
- result = result.filter((w) => filters.selectedRepos.has(w.repo))
- }
- if (search.trim()) {
- const q = search.toLowerCase()
- result = result.filter(
- (w) =>
- (w.displayName || w.repo).toLowerCase().includes(q) ||
- w.branch.toLowerCase().includes(q) ||
- w.repo.toLowerCase().includes(q)
- )
- }
- return result
-}
-
-type Section = { title: string; icon?: 'pin'; data: Worktree[] }
-
-// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
-// no PR/draft/unknown → "In Progress", open → "In Review", merged → "Done", closed → "Closed"
-type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
-
-const PR_GROUP_LABELS: Record = {
- done: 'Done',
- 'in-review': 'In Review',
- 'in-progress': 'In Progress',
- closed: 'Closed'
-}
-
-const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
-
-function getPRGroupKey(w: Worktree): PRGroupKey {
- if (!w.linkedPR) {
- return 'in-progress'
- }
- const s = w.linkedPR.state.toLowerCase()
- if (s === 'merged') {
- return 'done'
- }
- if (s === 'closed') {
- return 'closed'
- }
- if (s === 'draft') {
- return 'in-progress'
- }
- return 'in-review'
-}
-
-function isWorktreePinned(w: Worktree, localPins: Set): boolean {
- return w.isPinned || localPins.has(w.worktreeId)
-}
-
-function buildSections(
- worktrees: Worktree[],
- sortMode: SortMode,
- filters: FilterState,
- search: string,
- groupMode: GroupMode,
- pinnedIds: Set
-): Section[] {
- const filtered = filterWorktrees(worktrees, filters, search)
- const sorted = sortWorktrees(filtered, sortMode)
-
- const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds))
- const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds))
- const active = unpinned.filter(isWorktreeActive)
- const inactive = unpinned.filter((w) => !isWorktreeActive(w))
-
- const sections: Section[] = []
- if (pinned.length > 0) {
- sections.push({ title: 'Pinned', icon: 'pin', data: pinned })
- }
-
- if (groupMode === 'none') {
- if (active.length > 0) {
- // Why: without explicit grouping, mobile's primary workflow is jumping
- // back into running sessions before browsing the full worktree archive.
- sections.push({ title: 'Active', data: active })
- }
- if (inactive.length > 0) {
- sections.push({ title: pinned.length > 0 || active.length > 0 ? 'All' : '', data: inactive })
- }
- } else if (groupMode === 'repo') {
- const byRepo = new Map()
- for (const w of unpinned) {
- const key = w.repo || 'Unknown'
- const list = byRepo.get(key)
- if (list) {
- list.push(w)
- } else {
- byRepo.set(key, [w])
- }
- }
- for (const [repo, items] of byRepo) {
- sections.push({ title: repo, data: items })
- }
- } else if (groupMode === 'workspaceStatus') {
- const byStatus = new Map, Worktree[]>()
- for (const w of unpinned) {
- const key = getWorktreeStatus(w)
- const list = byStatus.get(key)
- if (list) {
- list.push(w)
- } else {
- byStatus.set(key, [w])
- }
- }
- for (const status of WORKSPACE_STATUS_ORDER) {
- const items = byStatus.get(status)
- if (items && items.length > 0) {
- sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items })
- }
- }
- } else if (groupMode === 'prStatus') {
- const byGroup = new Map()
- for (const w of unpinned) {
- const key = getPRGroupKey(w)
- const list = byGroup.get(key)
- if (list) {
- list.push(w)
- } else {
- byGroup.set(key, [w])
- }
- }
- for (const groupKey of PR_GROUP_ORDER) {
- const items = byGroup.get(groupKey)
- if (items && items.length > 0) {
- sections.push({ title: PR_GROUP_LABELS[groupKey], data: items })
- }
- }
- }
-
- return sections
-}
-
export default function HostScreen() {
const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>()
const router = useRouter()
@@ -359,19 +137,31 @@ export default function HostScreen() {
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
+ // Why: opening a worktree activates it on the host, but the active-row
+ // highlight otherwise waits for the next worktree.ps poll to reflect it.
+ // Track the locally-opened worktree so the highlight moves instantly.
+ const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState(null)
+ // One tick drives every visible agent row's relative timestamp.
+ const now = useNow(30_000)
const [repoColorsByName, setRepoColorsByName] = useState>(new Map())
+ const [repoIconsByName, setRepoIconsByName] = useState>(new Map())
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' })
const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
- const [sortMode, setSortMode] = useState('recent')
+ const [sortMode, setSortMode] = useState('recent')
const [filters, setFilters] = useState({
- activeOnly: false,
- selectedRepos: new Set()
+ filterRepoIds: new Set(),
+ hideSleeping: false,
+ hideDefaultBranch: false
})
- const [groupMode, setGroupMode] = useState('repo')
+ const [groupMode, setGroupMode] = useState('repo')
+ // displayName → repo id, populated from repo.list. The filter model keys on
+ // repo ids (desktop's PersistedUIState), but the section headers/rows key on
+ // displayName, so we bridge the two here.
+ const [repoIdsByName, setRepoIdsByName] = useState>(new Map())
// Modals
const [showSortPicker, setShowSortPicker] = useState(false)
@@ -387,9 +177,70 @@ export default function HostScreen() {
// Persisted pin state
const [pinnedIds, setPinnedIds] = useState>(new Set())
- const [_prefsLoaded, setPrefsLoaded] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState>(new Set())
+ // Why: snapshot of the synced view settings so the focus-effect ui.get merge
+ // and the optimistic ui.set writes read the latest values without forcing the
+ // callbacks to re-create on every state change.
+ const viewStateRef = useRef({
+ groupMode: 'repo',
+ sortMode: 'recent',
+ hideSleeping: false,
+ hideDefaultBranch: false,
+ filterRepoIds: [],
+ collapsedGroups: []
+ })
+
+ // Keep the snapshot ref aligned with the individual view-setting states.
+ useEffect(() => {
+ viewStateRef.current = {
+ groupMode,
+ sortMode,
+ hideSleeping: filters.hideSleeping,
+ hideDefaultBranch: filters.hideDefaultBranch,
+ filterRepoIds: [...filters.filterRepoIds],
+ collapsedGroups: [...collapsedGroups]
+ }
+ }, [groupMode, sortMode, filters, collapsedGroups])
+
+ // Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
+ // states and the snapshot ref in one shot.
+ const applyViewState = useCallback((next: MobileViewState) => {
+ viewStateRef.current = next
+ setGroupMode(next.groupMode)
+ setSortMode(next.sortMode)
+ setCollapsedGroups(new Set(next.collapsedGroups))
+ setFilters({
+ filterRepoIds: new Set(next.filterRepoIds),
+ hideSleeping: next.hideSleeping,
+ hideDefaultBranch: next.hideDefaultBranch
+ })
+ }, [])
+
+ // Optimistically apply a partial change locally, then push the full mapped
+ // settings to the desktop's shared store via ui.set so both apps stay in sync.
+ const persistViewSettings = useCallback(
+ (patch: Partial) => {
+ const next: MobileViewState = { ...viewStateRef.current, ...patch }
+ applyViewState(next)
+ if (!client) {
+ return
+ }
+ const payload: WorkspaceViewSettings = {
+ groupBy: groupModeToDesktop(next.groupMode),
+ sortBy: next.sortMode,
+ hideSleepingWorkspaces: next.hideSleeping,
+ hideDefaultBranchWorkspace: next.hideDefaultBranch,
+ filterRepoIds: next.filterRepoIds,
+ collapsedGroups: next.collapsedGroups
+ }
+ void client.sendRequest('ui.set', payload).catch(() => {
+ // Best-effort: view settings are a convenience preference.
+ })
+ },
+ [client, applyViewState]
+ )
+
const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action)
// Why: `action=newWorktree` is a route-derived open edge. Resolve it before
// commit, but don't reopen after the user closes while the same URL remains.
@@ -401,32 +252,49 @@ export default function HostScreen() {
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
- // Load persisted pins and preferences
+ // Load persisted pins from the local cache. View settings are no longer
+ // stored locally — they sync from the desktop's shared store via ui.get.
useEffect(() => {
if (!hostId) {
return
}
let stale = false
void (async () => {
- const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)])
+ const pins = await loadPinnedIds(hostId)
if (stale) {
return
}
setPinnedIds(pins)
- setSortMode(prefs.sortMode as SortMode)
- setFilters({
- activeOnly: prefs.filterMode === 'active',
- selectedRepos: new Set(prefs.selectedRepos ?? [])
- })
- setGroupMode(prefs.groupMode as GroupMode)
- setCollapsedGroups(new Set(prefs.collapsedGroups))
- setPrefsLoaded(true)
})()
return () => {
stale = true
}
}, [hostId])
+ // Read the desktop's shared view settings (PersistedUIState) and merge them
+ // onto local state. Runs on connect and on screen focus so changes made on
+ // desktop appear on the phone.
+ const syncViewSettingsFromDesktop = useCallback(async () => {
+ if (!client || connState !== 'connected') {
+ return
+ }
+ const requestClient = client
+ const requestHostId = hostId
+ try {
+ const response = await requestClient.sendRequest('ui.get')
+ if (clientRef.current !== requestClient || hostId !== requestHostId || !response.ok) {
+ return
+ }
+ const ui = ((response as RpcSuccess).result as { ui?: WorkspaceViewSettings }).ui
+ if (!ui) {
+ return
+ }
+ applyViewState(applyDesktopViewSettings(viewStateRef.current, ui))
+ } catch {
+ // Transient transport failure; retry on the next focus/connect.
+ }
+ }, [client, connState, hostId, applyViewState])
+
// Why: keep clientRef in sync so existing imperative call sites work
// unchanged. Also re-seed the cached worktree list on hostId change
// since the useState initializer only runs on first mount.
@@ -439,6 +307,7 @@ export default function HostScreen() {
setError('')
setCompatVerdict({ kind: 'ok' })
setRepoColorsByName(new Map())
+ setRepoIconsByName(new Map())
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
@@ -481,7 +350,9 @@ export default function HostScreen() {
const requestHostId = hostId
try {
- const response = await requestClient.sendRequest('worktree.ps')
+ // Why: worktree.ps defaults to 200 and silently truncates; match the
+ // desktop's high cap so large hosts don't drop workspaces on mobile.
+ const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 })
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
@@ -490,6 +361,14 @@ export default function HostScreen() {
setWorktrees(result.worktrees)
setLastKnownWorktrees(result.worktrees)
setWorktreesLoaded(true)
+ // Drop the optimistic active override once the host confirms it (the
+ // activate RPC has landed and worktree.ps now reports it active), so we
+ // stop overriding and respect any later desktop-driven change.
+ setOptimisticActiveWorktreeId((pending) =>
+ pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive)
+ ? null
+ : pending
+ )
void requestClient
.sendRequest('repo.list')
@@ -509,6 +388,14 @@ export default function HostScreen() {
])
)
)
+ setRepoIconsByName(
+ new Map(
+ repoResult.repos.flatMap((repo) =>
+ repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
+ )
+ )
+ )
+ setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
})
.catch(() => null)
@@ -601,13 +488,16 @@ export default function HostScreen() {
return
}
void fetchWorktrees()
+ // Pull desktop's shared view settings on focus so desktop-side changes
+ // show up here without a manual refresh.
+ void syncViewSettingsFromDesktop()
// Why: React Navigation keeps previous stack screens mounted; only
// poll the host list while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
}, 3000)
return () => clearInterval(interval)
- }, [connState, fetchWorktrees])
+ }, [connState, fetchWorktrees, syncViewSettingsFromDesktop])
)
const updateLocalPins = useCallback(
@@ -701,6 +591,8 @@ export default function HostScreen() {
const openWorktreeSession = useCallback(
(item: Worktree) => {
+ // Highlight the row immediately; the next worktree.ps poll confirms it.
+ setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
void client
.sendRequest('worktree.activate', {
@@ -716,70 +608,54 @@ export default function HostScreen() {
)
const handleSortChange = useCallback(
- (value: SortMode) => {
- setSortMode(value)
- if (hostId) {
- void savePreferences(hostId, { sortMode: value })
- }
+ (value: MobileSortMode) => {
+ persistViewSettings({ sortMode: value })
},
- [hostId]
+ [persistViewSettings]
)
- const toggleActiveFilter = useCallback(() => {
- setFilters((prev) => {
- const next = { ...prev, activeOnly: !prev.activeOnly }
- if (hostId) {
- void savePreferences(hostId, {
- filterMode: next.activeOnly ? 'active' : 'all'
- })
- }
- return next
- })
- }, [hostId])
+ const toggleHideSleeping = useCallback(() => {
+ persistViewSettings({ hideSleeping: !viewStateRef.current.hideSleeping })
+ }, [persistViewSettings])
+
+ const toggleHideDefaultBranch = useCallback(() => {
+ persistViewSettings({ hideDefaultBranch: !viewStateRef.current.hideDefaultBranch })
+ }, [persistViewSettings])
const toggleRepoFilter = useCallback(
- (repo: string) => {
- setFilters((prev) => {
- const next = new Set(prev.selectedRepos)
- if (next.has(repo)) {
- next.delete(repo)
- } else {
- next.add(repo)
- }
- const updated = { ...prev, selectedRepos: next }
- if (hostId) {
- void savePreferences(hostId, { selectedRepos: [...next] })
- }
- return updated
- })
+ (repoId: string) => {
+ const next = new Set(viewStateRef.current.filterRepoIds)
+ if (next.has(repoId)) {
+ next.delete(repoId)
+ } else {
+ next.add(repoId)
+ }
+ persistViewSettings({ filterRepoIds: [...next] })
},
- [hostId]
+ [persistViewSettings]
)
const clearFilters = useCallback(() => {
- setFilters({ activeOnly: false, selectedRepos: new Set() })
- if (hostId) {
- void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] })
- }
- }, [hostId])
+ persistViewSettings({ hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] })
+ }, [persistViewSettings])
const activeFilterCount = useMemo(() => {
let count = 0
- if (filters.activeOnly) {
+ if (filters.hideSleeping) {
count++
}
- count += filters.selectedRepos.size
+ if (filters.hideDefaultBranch) {
+ count++
+ }
+ count += filters.filterRepoIds.size
return count
}, [filters])
const handleGroupChange = useCallback(
- (value: GroupMode) => {
- setGroupMode(value)
- if (hostId) {
- void savePreferences(hostId, { groupMode: value })
- }
+ (value: MobileGroupMode) => {
+ persistViewSettings({ groupMode: value })
},
- [hostId]
+ [persistViewSettings]
)
const displayWorktrees = useMemo(() => {
@@ -787,25 +663,35 @@ export default function HostScreen() {
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
? lastKnownWorktrees
: worktrees
- if (sleptIds.size === 0) {
+ if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
return base
}
- return base.map((w) =>
- sleptIds.has(w.worktreeId)
- ? { ...w, liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
- : w
- )
- }, [connState, worktrees, lastKnownWorktrees, sleptIds])
+ return base.map((w) => {
+ const slept = sleptIds.has(w.worktreeId)
+ ? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
+ : null
+ // Force the just-opened worktree active (and the rest inactive) until the
+ // next poll confirms it, so the highlight doesn't lag the navigation.
+ const active =
+ optimisticActiveWorktreeId !== null
+ ? { isActive: w.worktreeId === optimisticActiveWorktreeId }
+ : null
+ return slept || active ? { ...w, ...slept, ...active } : w
+ })
+ }, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
const uniqueRepos = useMemo(() => {
- const repos = new Map()
+ const repos = new Map()
for (const w of displayWorktrees) {
if (!repos.has(w.repo)) {
- repos.set(w.repo, repoColorsByName.get(w.repo) ?? repoColor(w.repo))
+ repos.set(w.repo, {
+ id: repoIdsByName.get(w.repo) ?? w.repoId,
+ color: repoColorsByName.get(w.repo) ?? repoColor(w.repo)
+ })
}
}
- return [...repos.entries()].map(([name, color]) => ({ name, color }))
- }, [displayWorktrees, repoColorsByName])
+ return [...repos.entries()].map(([name, { id, color }]) => ({ name, id, color }))
+ }, [displayWorktrees, repoColorsByName, repoIdsByName])
const uniqueRepoColors = useMemo(
() => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])),
@@ -814,20 +700,15 @@ export default function HostScreen() {
const toggleCollapsed = useCallback(
(title: string) => {
- setCollapsedGroups((prev) => {
- const next = new Set(prev)
- if (next.has(title)) {
- next.delete(title)
- } else {
- next.add(title)
- }
- if (hostId) {
- void savePreferences(hostId, { collapsedGroups: [...next] })
- }
- return next
- })
+ const next = new Set(viewStateRef.current.collapsedGroups)
+ if (next.has(title)) {
+ next.delete(title)
+ } else {
+ next.add(title)
+ }
+ persistViewSettings({ collapsedGroups: [...next] })
},
- [hostId]
+ [persistViewSettings]
)
const rawSections = useMemo(
@@ -844,6 +725,8 @@ export default function HostScreen() {
[rawSections, collapsedGroups]
)
+ const { sectionListRef, onScrollToIndexFailed } = useActiveWorktreeScroll(sections)
+
const isReadOnly = connState === 'auth-failed'
if (error) {
@@ -928,13 +811,7 @@ export default function HostScreen() {
setShowSortPicker(true)}>
- {sortMode === 'smart'
- ? 'Smart'
- : sortMode === 'name'
- ? 'Name'
- : sortMode === 'repo'
- ? 'Repo'
- : 'Recent'}
+ {SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? 'Recent'}
@@ -998,19 +875,12 @@ export default function HostScreen() {
{/* Auth failed banner */}
{connState === 'auth-failed' && (
-
-
- Pairing rejected — re-pair from desktop or remove this host.
-
-
- router.push('/pair-scan')}>
- Re-pair
-
- setConfirmRemoveHost(true)}>
- Remove
-
-
-
+ hostId && void forceReconnectHost(hostId)}
+ onRepair={() => router.push('/pair-scan')}
+ onRemove={() => setConfirmRemoveHost(true)}
+ />
)}
{/* Search bar */}
@@ -1060,9 +930,11 @@ export default function HostScreen() {
{/* Worktree list */}
{sections.length > 0 && (
w.worktreeId}
stickySectionHeadersEnabled={false}
+ onScrollToIndexFailed={onScrollToIndexFailed}
// Why: edge-to-edge — the list scrolls under the system nav bar
// while reserving insets.bottom keeps the last worktree row reachable
// above the Samsung 3-button nav / iOS home indicator.
@@ -1080,6 +952,7 @@ export default function HostScreen() {
const count = rawSection?.data.length ?? 0
const repoSectionColor =
groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
+ const repoSectionIcon = groupMode === 'repo' ? repoIconsByName.get(section.title) : null
return (
)}
- {repoSectionColor ? (
-
+ {groupMode === 'repo' ? (
+
+
+
) : null}
{section.title}
{count}
@@ -1103,71 +982,17 @@ export default function HostScreen() {
}}
ItemSeparatorComponent={ListSeparator}
renderItem={({ item }) => (
- [styles.worktreeRow, pressed && styles.worktreeRowPressed]}
- disabled={isReadOnly}
- onPress={() => openWorktreeSession(item)}
- onLongPress={() => {
- triggerMediumImpact()
- setActionTarget(item)
- }}
- delayLongPress={400}
- >
- {/* Left indicator */}
-
-
- {item.unread && (
-
- )}
-
-
- {/* Main content */}
-
-
-
- {item.displayName || item.repo}
-
- {item.linkedPR && (
-
-
- #{item.linkedPR.number}
-
- )}
-
-
-
-
- {item.repo}
-
-
- {item.branch}
-
-
- {item.preview ? (
-
- {item.preview}
-
- ) : null}
-
-
- {/* Terminal count */}
- {item.liveTerminalCount > 0 && (
- {item.liveTerminalCount}
- )}
-
+
)}
/>
)}
@@ -1203,11 +1028,16 @@ export default function HostScreen() {
)}
- Status
+ Workspaces
-
- Active only
- {filters.activeOnly && }
+
+ Hide sleeping
+ {filters.hideSleeping && }
+
+
+
+ Hide default branch
+ {filters.hideDefaultBranch && }
@@ -1216,14 +1046,14 @@ export default function HostScreen() {
Repositories
{uniqueRepos.map((repo, i) => (
-
+
{i > 0 && }
- toggleRepoFilter(repo.name)}>
+ toggleRepoFilter(repo.id)}>
{repo.name}
- {filters.selectedRepos.has(repo.name) && (
+ {filters.filterRepoIds.has(repo.id) && (
)}
@@ -1424,30 +1254,6 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '600'
},
- authBanner: {
- backgroundColor: colors.bgPanel,
- paddingVertical: spacing.sm,
- paddingHorizontal: spacing.lg,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- authBannerText: {
- color: colors.statusRed,
- fontSize: 13,
- marginBottom: spacing.sm
- },
- authActions: {
- flexDirection: 'row',
- gap: spacing.lg
- },
- authAction: {
- paddingVertical: spacing.xs
- },
- authActionText: {
- color: colors.accentBlue,
- fontSize: 13,
- fontWeight: '600'
- },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
@@ -1547,10 +1353,7 @@ const styles = StyleSheet.create({
sectionIcon: {
marginRight: spacing.xs
},
- sectionRepoDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
+ sectionRepoIcon: {
marginRight: spacing.xs
},
sectionTitle: {
@@ -1571,91 +1374,6 @@ const styles = StyleSheet.create({
marginLeft: spacing.lg + 24,
marginRight: spacing.lg
},
- worktreeRow: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- paddingVertical: spacing.sm + 2,
- paddingHorizontal: spacing.lg
- },
- worktreeRowPressed: {
- backgroundColor: colors.bgRaised
- },
- indicatorCol: {
- width: 20,
- alignItems: 'center',
- paddingTop: 6,
- marginRight: spacing.sm,
- gap: 4
- },
- unreadBell: {
- marginTop: 2
- },
- worktreeMain: {
- flex: 1,
- marginRight: spacing.sm
- },
- worktreeNameRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- worktreeName: {
- fontSize: 14,
- fontWeight: '600',
- color: colors.textPrimary,
- flexShrink: 1
- },
- textReadOnly: {
- opacity: 0.5
- },
- prBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 3,
- backgroundColor: colors.bgRaised,
- paddingHorizontal: 5,
- paddingVertical: 1,
- borderRadius: 4
- },
- prNumber: {
- fontSize: 10,
- color: colors.textSecondary
- },
- worktreeMetaRow: {
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: 2,
- gap: spacing.xs
- },
- repoDot: {
- width: 6,
- height: 6,
- borderRadius: 3
- },
- repoName: {
- fontSize: 11,
- color: colors.textSecondary,
- maxWidth: 100
- },
- branchName: {
- fontSize: 11,
- color: colors.textMuted,
- fontFamily: typography.monoFamily,
- flexShrink: 1
- },
- worktreePreview: {
- fontSize: 11,
- color: colors.textMuted,
- fontFamily: typography.monoFamily,
- marginTop: 2
- },
- terminalCount: {
- fontSize: typography.metaSize,
- color: colors.textMuted,
- minWidth: 16,
- textAlign: 'right',
- paddingTop: 3
- },
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/mobile/app/h/[hostId]/review/[worktreeId].tsx b/mobile/app/h/[hostId]/review/[worktreeId].tsx
new file mode 100644
index 00000000000..d155240783a
--- /dev/null
+++ b/mobile/app/h/[hostId]/review/[worktreeId].tsx
@@ -0,0 +1,45 @@
+import { useCallback } from 'react'
+import { useLocalSearchParams, useRouter } from 'expo-router'
+import { MobileDiffReviewScreenView } from '../../../../src/components/MobileDiffReviewScreenView'
+import {
+ firstReviewParam,
+ normalizeReviewFilterParam
+} from '../../../../src/session/mobile-diff-review-screen-model'
+import { useMobileDiffReviewController } from '../../../../src/session/use-mobile-diff-review-controller'
+import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
+
+export default function MobileDiffReviewScreen() {
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ worktreeId?: string | string[]
+ name?: string | string[]
+ scope?: string | string[]
+ }>()
+ const hostId = firstReviewParam(params.hostId)
+ const worktreeId = firstReviewParam(params.worktreeId)
+ const name = firstReviewParam(params.name)
+ const initialFilter = normalizeReviewFilterParam(firstReviewParam(params.scope))
+ const router = useRouter()
+ const { client, state: connState } = useHostClient(hostId)
+ const forceReconnect = useForceReconnect()
+
+ const openSession = useCallback(() => {
+ const query = name ? `?${new URLSearchParams({ name }).toString()}` : ''
+ router.replace(
+ `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${query}`
+ )
+ }, [hostId, name, router, worktreeId])
+
+ const controller = useMobileDiffReviewController({
+ client,
+ connState,
+ hostId,
+ worktreeId,
+ name,
+ initialFilter,
+ onOpenSession: openSession,
+ onReconnect: forceReconnect
+ })
+
+ return router.back()} />
+}
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index a9cc8eb7075..e98e73e259d 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -4,9 +4,9 @@ import * as Clipboard from 'expo-clipboard'
import {
BackHandler,
FlatList,
+ Image,
View,
Text,
- StyleSheet,
ScrollView,
TextInput,
Pressable,
@@ -33,6 +33,7 @@ import {
FileText,
GitBranch,
Globe,
+ ImagePlus,
Keyboard as KeyboardIcon,
MessageSquare,
Mic,
@@ -45,8 +46,20 @@ import {
X
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
+import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types'
import { loadHosts } from '../../../../src/transport/host-store'
-import { useHostClient } from '../../../../src/transport/client-context'
+import {
+ loadTerminalAutocompleteEnabled,
+ loadTerminalTextScale,
+ saveTerminalTextScale
+} from '../../../../src/storage/preferences'
+import {
+ useHostClient,
+ useForceReconnect,
+ useReconnectAttempt,
+ useLastConnectedAt
+} from '../../../../src/transport/client-context'
+import { classifyConnection } from '../../../../src/transport/connection-health'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
@@ -57,9 +70,7 @@ import {
triggerEdgeBump
} from '../../../../src/platform/haptics'
import {
- TerminalWebView,
type TerminalKeyboardAvoidanceMetrics,
- type MobileTerminalTheme,
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
@@ -75,8 +86,9 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
+import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
-import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
+import { MobileBrowserPane } from '../../../../src/browser/MobileBrowserPane'
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
@@ -91,10 +103,7 @@ import {
saveCustomKeys,
type CustomKey
} from '../../../../src/components/CustomKeyModal'
-import {
- buildMobileDiffLines,
- type MobileDiffLine
-} from '../../../../src/session/mobile-diff-lines'
+import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines'
import {
addMobileDiffComment,
formatDiffComments,
@@ -106,144 +115,79 @@ import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
- resolveMobileSyntaxLanguage,
- type MobileHighlightedDiffLine,
- type MobileSyntaxSegment
+ resolveMobileSyntaxLanguage
} from '../../../../src/session/mobile-file-syntax'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
- terminalRecordsEqual,
- type TerminalRecord
+ terminalRecordsEqual
} from '../../../../src/session/mobile-terminal-records'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
+import {
+ buildMobileImagePastePayload,
+ saveMobileClipboardImageAsTempFile
+} from '../../../../src/session/mobile-clipboard-image'
+import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
+import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
+import {
+ buildMarkdownDiskFallbackDoc,
+ shouldReadMarkdownFromDiskAfterReadTabFailure
+} from '../../../../src/session/mobile-markdown-disk-fallback'
+import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
+import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
+import {
+ fetchDictationSetup,
+ isDictationSetupRequiredError
+} from '../../../../src/dictation/mobile-dictation-setup'
+import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
+import {
+ getRepoIdFromMobileWorktreeId,
+ isFileExistsErrorMessage,
+ isGestureMouseTrackingMode,
+ MOBILE_SESSION_STATUS_LABELS,
+ TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
+ TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
+ TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
+ TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
+ TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
+} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
+import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import {
createMobileSessionCreateWarningState,
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
-import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
+import { colors, spacing } from '../../../../src/theme/mobile-theme'
+import { styles } from './mobile-session-styles'
import type { DiffComment } from '../../../../../src/shared/types'
-import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
-
-type Terminal = TerminalRecord
-
-type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
-
-type MobileSessionTab =
- | {
- type: 'terminal'
- id: string
- title: string
- parentTabId?: string
- leafId?: string
- status?: 'pending-handle' | 'ready'
- terminal: string | null
- agentStatus?: AgentStatusEntry | null
- terminalTheme?: MobileTerminalTheme
- isActive: boolean
- }
- | {
- type: 'markdown'
- id: string
- title: string
- filePath: string
- relativePath: string
- isDirty: boolean
- isActive: boolean
- documentVersion: string
- }
- | {
- type: 'file'
- id: string
- title: string
- filePath: string
- relativePath: string
- language?: string
- mode?: 'edit' | 'diff'
- diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
- isDirty: boolean
- isActive: boolean
- }
- | MobileBrowserTab
-
-type SessionTabsResult = {
- worktree: string
- publicationEpoch?: string
- snapshotVersion: number
- tabs: MobileSessionTab[]
- activeTabId: string | null
- activeTabType: MobileSessionTabType | null
-}
-
-type RuntimeStatusResult = {
- capabilities?: string[]
-}
-
-type MarkdownDocState =
- | { status: 'loading' }
- | {
- status: 'ready'
- content: string
- localContent: string
- baseVersion: string
- isDirty: boolean
- editable: boolean
- stale?: boolean
- saving?: boolean
- saveError?: string
- readOnlyReason?: string
- }
- | { status: 'error'; message: string }
-
-type FileDocState =
- | { status: 'loading' }
- | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
- | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
- | { status: 'error'; message: string }
-
-type RenderableDiffLine = MobileHighlightedDiffLine
-
-type DiffCommentActions = {
- comments: DiffComment[]
- busy: boolean
- onAdd: (filePath: string, lineNumber: number, body: string) => Promise
- onDelete: (commentId: string) => Promise
- onCopyAll: () => Promise
- onSendAll: () => void
-}
-
-type DiffNotesDelivery = {
- prompt: string
- comments: DiffComment[]
-}
-
-type ReadyFileDocState = Extract
-
-type FileSyntaxState = {
- doc: ReadyFileDocState
- language: string
- segments: MobileSyntaxSegment[]
-}
-
-type DiffSyntaxState = {
- doc: ReadyFileDocState
- language: string
- lines: RenderableDiffLine[]
-}
-
-type DirtyMarkdownDraft = {
- tabId: string
- title: string
- content: string
-}
+import type {
+ DiffCommentActions,
+ DiffNotesDelivery,
+ DiffSyntaxState,
+ DirtyMarkdownDraft,
+ FileDocState,
+ FileSyntaxState,
+ MarkdownDocState,
+ MobileDisplayMode,
+ MobileNewTabAgentLoadState,
+ MobileSessionTab,
+ MobileSessionTabType,
+ RenderableDiffLine,
+ RuntimeRepoSummary,
+ RuntimeStatusResult,
+ SessionTabsResult,
+ Terminal,
+ TerminalCreateResult,
+ TerminalGestureInputBucket,
+ TerminalGestureInputQueue
+} from './mobile-session-route-types'
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
@@ -280,131 +224,6 @@ function getMobileSessionTabTitle(tab: MobileSessionTab): string {
return tab.title || 'Terminal'
}
-function isFileExistsErrorMessage(message: string): boolean {
- const normalized = message.toLowerCase()
- return normalized.includes('eexist') || normalized.includes('already exists')
-}
-
-type TerminalCreateResult = {
- tab: Extract
-}
-
-type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error'
-
-type RuntimeRepoSummary = {
- id: string
- connectionId?: string | null
-}
-
-function getRepoIdFromMobileWorktreeId(id: string): string {
- // Why: mobile cannot import desktop shared modules in its standalone tsc run,
- // but the runtime worktree id wire format is still `${repoId}::${path}`.
- const separatorIdx = id.indexOf('::')
- return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
-}
-
-type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
-
-const STATUS_LABELS: Record = {
- connecting: 'Connecting',
- handshaking: 'Securing',
- connected: 'Connected',
- disconnected: 'Disconnected',
- reconnecting: 'Reconnecting',
- 'auth-failed': 'Auth failed'
-}
-
-const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
-const TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND = 120
-const TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS = 16
-const TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES = 32
-const TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS = 250
-
-type TerminalGestureInputBucket = {
- tokens: number
- lastRefillMs: number
-}
-
-type TerminalGestureInputQueue = {
- bytes: string
- sequenceCount: number
- timer: ReturnType | null
- lastUpdatedMs: number
-}
-
-function isWheelMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean {
- return mode === 'vt200' || mode === 'drag' || mode === 'any'
-}
-
-function isGestureMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean {
- return mode === 'x10' || isWheelMouseTrackingMode(mode)
-}
-
-function TerminalPaneView({
- handle,
- active,
- keyboardLift,
- terminalTheme,
- onRef,
- onWebReady,
- onSelectionMode,
- onSelectionCopy,
- onSelectionEvicted,
- onModesChanged,
- onKeyboardAvoidanceMetrics,
- onHaptic,
- onTerminalInput,
- onTerminalTap
-}: {
- handle: string
- active: boolean
- keyboardLift: number
- terminalTheme?: MobileTerminalTheme
- onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
- onWebReady: (handle: string) => void
- onSelectionMode: (handle: string, active: boolean) => void
- onSelectionCopy: (handle: string, text: string) => void
- onSelectionEvicted: (handle: string) => void
- onModesChanged: (handle: string, modes: TerminalModes) => void
- onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
- onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
- onTerminalInput: (handle: string, bytes: string) => void
- onTerminalTap: (handle: string) => void
-}) {
- const setRef = useCallback(
- (ref: TerminalWebViewHandle | null) => {
- onRef(handle, ref)
- },
- [handle, onRef]
- )
-
- return (
- 0 && { transform: [{ translateY: -keyboardLift }] },
- !active && styles.terminalPaneHidden
- ]}
- >
- onWebReady(handle)}
- onSelectionMode={(a) => onSelectionMode(handle, a)}
- onSelectionCopy={(t) => onSelectionCopy(handle, t)}
- onSelectionEvicted={() => onSelectionEvicted(handle)}
- onModesChanged={(m) => onModesChanged(handle, m)}
- onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
- onHaptic={onHaptic}
- onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
- onTerminalTap={() => onTerminalTap(handle)}
- />
-
- )
-}
-
function MarkdownReader({
documentId,
doc,
@@ -424,6 +243,10 @@ function MarkdownReader({
onDiscard: () => void
keyboardLift: number
}) {
+ // The editor lives in a WebView; native Keyboard events under-report its
+ // covered area, so prefer the inset measured inside the WebView when larger.
+ const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
+ const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
@@ -462,6 +285,7 @@ function MarkdownReader({
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
+ onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
{
- if (doc.kind === 'file') {
+ // file + html share the syntax-segment source view (html's "Source" toggle).
+ if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
@@ -793,11 +618,14 @@ function FileReader({
})
return
}
- setDiffSyntax({
- doc,
- language: syntaxLanguage,
- lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
- })
+ if (doc.kind === 'diff') {
+ setDiffSyntax({
+ doc,
+ language: syntaxLanguage,
+ lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
+ })
+ }
+ // image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
@@ -885,7 +713,28 @@ function FileReader({
)
}
- return (
+ if (doc.kind === 'image') {
+ return (
+
+
+
+
+
+ )
+ }
+
+ const renderSourceText = (content: string) => (
)
+
+ if (doc.kind === 'html') {
+ return (
+
+ renderSourceText(doc.content)} />
+
+ )
+ }
+
+ return renderSourceText(doc.content)
}
export default function SessionScreen() {
@@ -924,6 +783,9 @@ export default function SessionScreen() {
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
+ const reconnectAttempts = useReconnectAttempt(hostId)
+ const lastConnectedAt = useLastConnectedAt(hostId)
+ const forceReconnectHost = useForceReconnect()
const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : ''
const [terminals, setTerminals] = useState([])
const terminalsRef = useRef([])
@@ -931,6 +793,12 @@ export default function SessionScreen() {
const sessionTabsRef = useRef([])
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
+ // Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change
+ // applies in place (the terminal panes stay mounted).
+ const [terminalTextScale, setTerminalTextScale] = useState(1)
+ // Why: local opt-in for keyboard autocomplete/autocorrect on the terminal
+ // command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return.
+ const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
const [liveInputCapture, setLiveInputCapture] = useState('')
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>(
() => new Set()
@@ -938,6 +806,13 @@ export default function SessionScreen() {
const [activeHandle, setActiveHandle] = useState(null)
const [activeSessionTabId, setActiveSessionTabId] = useState(null)
const activeSessionTabIdRef = useRef(null)
+ // Auto-scroll the tab strip so the active tab (synced from desktop on
+ // worktree entry) is revealed without a manual scroll.
+ const tabStripRef = useRef(null)
+ const tabStripOffsetRef = useRef(0)
+ const tabStripViewportWidthRef = useRef(0)
+ const tabStripContentWidthRef = useRef(0)
+ const tabLayoutsRef = useRef>(new Map())
const [markdownDocs, setMarkdownDocs] = useState>(new Map())
const markdownDocsRef = useRef>(new Map())
const [fileDocs, setFileDocs] = useState>(new Map())
@@ -1001,6 +876,10 @@ export default function SessionScreen() {
>(new Map())
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
+ const [showDictationSetup, setShowDictationSetup] = useState(false)
+ // 'hold' makes the mic press-and-hold; 'toggle' makes it tap-to-start/stop.
+ // Mirrors Settings ▸ Voice ▸ Dictation Mode so the button matches the setting.
+ const [dictationMode, setDictationMode] = useState<'toggle' | 'hold'>('toggle')
const [toastMessage, setToastMessage] = useState(null)
const toastOpacityRef = useRef(new Animated.Value(0))
const toastHideTimerRef = useRef | null>(null)
@@ -1034,6 +913,13 @@ export default function SessionScreen() {
const activeSessionTabTypeRef = useRef(null)
const pendingActiveSessionTabIdRef = useRef(null)
const pendingActiveTerminalHandleRef = useRef(null)
+ // Why: a browser tab opened from a terminal-tapped HTML must be focused as an
+ // Orca session tab (bridge auto-activate only flags the live webContents, not
+ // the app-level active tab). We remember the page id and, once its session tab
+ // syncs, activate it through the normal switchSessionTab path (which also makes
+ // switching back to the terminal work). A ref breaks the callback dep cycle.
+ const pendingBrowserFocusPageIdRef = useRef(null)
+ const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
const initialEmptySessionAutoCreateRef = useRef(null)
const markdownSaveSeqRef = useRef>(new Map())
const markdownSaveInFlightRef = useRef>(new Set())
@@ -1063,6 +949,10 @@ export default function SessionScreen() {
activeSessionTab?.type !== 'browser'
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null)
+ // Why: stable callbacks (handleFileTap) read the live value via this ref, since
+ // the capability probe resolves after the callbacks are created.
+ const browserScreencastSupportedRef = useRef(browserScreencastSupported)
+ browserScreencastSupportedRef.current = browserScreencastSupported
// Why: terminal gesture/input callbacks are intentionally stable and
// imperative; keep their refs current before commit instead of one effect later.
clientRef.current = client
@@ -1149,11 +1039,73 @@ export default function SessionScreen() {
showToast('Dictation inserted')
},
onError: (err) => {
+ // Dictation isn't set up on the desktop yet → open the setup sheet so the
+ // user can download a model + enable it from here, instead of a dead-end toast.
+ if (isDictationSetupRequiredError(err.message)) {
+ setShowDictationSetup(true)
+ return
+ }
triggerError()
showToast(err.message)
}
})
+ const startDictation = useCallback(() => {
+ void dictation.start().catch((err) => {
+ triggerError()
+ showToast(err instanceof Error ? err.message : String(err))
+ })
+ }, [dictation, triggerError, showToast])
+
+ // Toggle mode: one tap starts, the next stops; long-press cancels mid-record.
+ const handleDictationToggle = useCallback(() => {
+ if (dictation.isProcessing) {
+ void dictation.cancel()
+ } else if (dictation.isStarting) {
+ return
+ } else if (dictation.isRecording) {
+ void dictation.stop()
+ } else {
+ startDictation()
+ }
+ }, [dictation, startDictation])
+
+ // Hold mode: press starts, release stops — like a walkie-talkie.
+ const handleDictationPressIn = useCallback(() => {
+ if (!dictation.isStarting && !dictation.isRecording && !dictation.isProcessing) {
+ startDictation()
+ }
+ }, [dictation, startDictation])
+
+ const handleDictationPressOut = useCallback(() => {
+ if (dictation.isRecording) {
+ void dictation.stop()
+ } else if (dictation.isStarting) {
+ // Released before recording began: cancel so we don't leave a live mic.
+ void dictation.cancel()
+ }
+ }, [dictation])
+
+ const refreshDictationMode = useCallback(async () => {
+ if (!client) {
+ return
+ }
+ try {
+ const setup = await fetchDictationSetup(client)
+ setDictationMode(setup.dictationMode)
+ } catch {
+ // Non-fatal: fall back to the default toggle behavior.
+ }
+ }, [client])
+
+ // Re-read on focus so a Dictation Mode change made in Settings ▸ Voice is
+ // reflected when the user returns to the session.
+ useFocusEffect(
+ useCallback(() => {
+ void refreshDictationMode()
+ }, [refreshDictationMode])
+ )
+
useEffect(() => {
diffCommentsRef.current = diffComments
}, [diffComments])
@@ -1695,27 +1647,56 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: tab.id
})
- if (!response.ok) {
+ if (response.ok) {
+ const result = (response as RpcSuccess).result as {
+ content: string
+ version: string
+ isDirty: boolean
+ editable?: boolean
+ readOnlyReason?: string
+ }
+ setMarkdownDocs((prev) =>
+ new Map(prev).set(tab.id, {
+ status: 'ready',
+ content: result.content,
+ localContent: result.content,
+ baseVersion: result.version,
+ isDirty: false,
+ editable: result.editable === true,
+ stale: result.isDirty,
+ readOnlyReason: result.readOnlyReason
+ })
+ )
+ return
+ }
+ if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
+ throw new Error((response as RpcFailure).error.message)
+ }
+ // Why: a headless host (no desktop renderer) can't serve the live editor
+ // document and fails markdown.readTab with renderer_unavailable. Fall back
+ // to the on-disk file so markdown still renders read-only, matching how
+ // other file types load via files.read.
+ const fallback = await client.sendRequest('files.read', {
+ worktree: `id:${worktreeId}`,
+ relativePath: tab.relativePath
+ })
+ if (!fallback.ok) {
throw new Error('Unable to read markdown')
}
- const result = (response as RpcSuccess).result as {
+ const fileResult = (fallback as RpcSuccess).result as {
content: string
- version: string
- isDirty: boolean
- editable?: boolean
- readOnlyReason?: string
+ truncated: boolean
+ byteLength: number
}
setMarkdownDocs((prev) =>
- new Map(prev).set(tab.id, {
- status: 'ready',
- content: result.content,
- localContent: result.content,
- baseVersion: result.version,
- isDirty: false,
- editable: result.editable === true,
- stale: result.isDirty,
- readOnlyReason: result.readOnlyReason
- })
+ new Map(prev).set(
+ tab.id,
+ buildMarkdownDiskFallbackDoc({
+ content: fileResult.content,
+ truncated: fileResult.truncated,
+ tabIsDirty: tab.isDirty
+ })
+ )
)
} catch {
setMarkdownDocs((prev) =>
@@ -1766,6 +1747,32 @@ export default function SessionScreen() {
)
return
}
+ const artifactKind = classifyMobileArtifact(tab.relativePath)
+ if (artifactKind === 'image') {
+ const preview = await client.sendRequest('files.readPreview', {
+ worktree: `id:${worktreeId}`,
+ relativePath: tab.relativePath
+ })
+ if (!preview.ok) {
+ throw new Error((preview as RpcFailure).error.message)
+ }
+ const result = (preview as RpcSuccess).result as {
+ content: string
+ isImage?: boolean
+ mimeType?: string
+ }
+ if (!result.isImage || !result.mimeType || result.content.length === 0) {
+ throw new Error('binary_file')
+ }
+ setFileDocs((prev) =>
+ new Map(prev).set(tab.id, {
+ status: 'ready',
+ kind: 'image',
+ dataUri: `data:${result.mimeType};base64,${result.content}`
+ })
+ )
+ return
+ }
const response = await client.sendRequest('files.read', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
@@ -1778,6 +1785,16 @@ export default function SessionScreen() {
truncated: boolean
byteLength: number
}
+ if (artifactKind === 'html') {
+ setFileDocs((prev) =>
+ new Map(prev).set(tab.id, {
+ status: 'ready',
+ kind: 'html',
+ content: result.content
+ })
+ )
+ return
+ }
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
@@ -2143,6 +2160,18 @@ export default function SessionScreen() {
}
const result = (response as RpcSuccess).result as SessionTabsResult
applySessionTabs(result)
+ // Focus a just-opened browser tab once it appears in the snapshot, via the
+ // normal activate path so it sticks and the user can still switch away.
+ const pendingPageId = pendingBrowserFocusPageIdRef.current
+ if (pendingPageId) {
+ const browserTab = result.tabs.find(
+ (tab) => tab.type === 'browser' && tab.browserPageId === pendingPageId
+ )
+ if (browserTab) {
+ pendingBrowserFocusPageIdRef.current = null
+ switchSessionTabRef.current?.(browserTab)
+ }
+ }
} catch {
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
@@ -2264,6 +2293,7 @@ export default function SessionScreen() {
deviceTokenRef,
initializedHandlesRef,
tabStripVisible: terminals.length > 1,
+ textScale: terminalTextScale,
unsubscribeTerminal,
subscribeToTerminal
})
@@ -2285,6 +2315,34 @@ export default function SessionScreen() {
}
}, [])
+ const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
+ if (!tabId) {
+ return
+ }
+ const layout = tabLayoutsRef.current.get(tabId)
+ if (!layout) {
+ return
+ }
+ const nextOffset = resolveTabStripScrollOffset({
+ tabX: layout.x,
+ tabWidth: layout.width,
+ viewportWidth: tabStripViewportWidthRef.current,
+ contentWidth: tabStripContentWidthRef.current,
+ currentOffset: tabStripOffsetRef.current
+ })
+ if (nextOffset !== tabStripOffsetRef.current) {
+ tabStripOffsetRef.current = nextOffset
+ tabStripRef.current?.scrollTo({ x: nextOffset, animated })
+ }
+ }, [])
+
+ // Reveal the active tab whenever it changes (e.g. desktop's open tab synced on
+ // worktree entry). Defer one frame so freshly mounted tab layouts are recorded.
+ useEffect(() => {
+ const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true))
+ return () => cancelAnimationFrame(id)
+ }, [activeSessionTabId, scrollActiveTabIntoView])
+
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
@@ -2314,6 +2372,7 @@ export default function SessionScreen() {
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
+ pendingBrowserFocusPageIdRef.current = null
initialEmptySessionAutoCreateRef.current = null
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
@@ -2460,6 +2519,37 @@ export default function SessionScreen() {
}, [connState, fetchSessionTabs, fetchTerminals])
)
+ // Why: pick up the Settings → Terminal text size when returning here — the
+ // terminal panes stay mounted, so they update in place.
+ useFocusEffect(
+ useCallback(() => {
+ let active = true
+ void loadTerminalTextScale().then((scale) => {
+ if (active) {
+ setTerminalTextScale(scale)
+ }
+ })
+ return () => {
+ active = false
+ }
+ }, [])
+ )
+
+ // Why: pick up the Settings → Terminal autocomplete toggle when returning here.
+ useFocusEffect(
+ useCallback(() => {
+ let active = true
+ void loadTerminalAutocompleteEnabled().then((enabled) => {
+ if (active) {
+ setAutocompleteEnabled(enabled)
+ }
+ })
+ return () => {
+ active = false
+ }
+ }, [])
+ )
+
// Why: unsubscribe the old terminal so the server restores its desktop dims
// (clearing the phone-fit banner), then subscribe the new terminal with the
// measured viewport so the server phone-fits it. Also call terminal.focus
@@ -2570,6 +2660,9 @@ export default function SessionScreen() {
},
[client, markdownDocs, readFileTab, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId]
)
+ // Keep the ref pointing at the latest switchSessionTab so fetchSessionTabs can
+ // activate a freshly-synced browser tab without a callback dependency cycle.
+ switchSessionTabRef.current = switchSessionTab
// Why: just store the ref. Subscription is deferred to handleTerminalWebReady
// which fires after the WebView has loaded xterm.js and is ready to process
@@ -2654,7 +2747,7 @@ export default function SessionScreen() {
}
sendingRef.current = true
- const text = input
+ const text = normalizeTerminalTextInput(input)
setInput('')
try {
@@ -2697,10 +2790,11 @@ export default function SessionScreen() {
const sendLiveTerminalInput = useCallback(
(handle: string, bytes: string) => {
- if (bytes.length === 0) {
+ const text = normalizeTerminalTextInput(bytes)
+ if (text.length === 0) {
return
}
- if (!isTerminalLiveInputWithinByteLimit(bytes)) {
+ if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return
@@ -2717,7 +2811,7 @@ export default function SessionScreen() {
void rpc
.sendRequest('terminal.send', {
terminal: handle,
- text: bytes,
+ text,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
@@ -2747,6 +2841,59 @@ export default function SessionScreen() {
[focusLiveInput]
)
+ // Tap on a file path in terminal output → resolve it on the host and open it
+ // as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
+ // WebView only emits this when the tap landed on a detected path.
+ const handleFileTap = useCallback(
+ (handle: string, pathText: string) => {
+ if (handle !== activeHandleRef.current || !client) {
+ return
+ }
+ void (async () => {
+ try {
+ const worktree = `id:${worktreeId}`
+ const response = await client.sendRequest(
+ 'files.resolveTerminalPath',
+ { worktree, pathText },
+ { timeoutMs: 10_000 }
+ )
+ if (!response.ok) {
+ return
+ }
+ const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
+ if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) {
+ return
+ }
+ // Confirm the tap landed on something openable before giving feedback.
+ triggerSelection()
+ // Why: HTML opens in a browser pane (streamed from the desktop),
+ // matching desktop's terminal-click behavior, instead of a file view.
+ if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) {
+ void handleCreateBrowser('file://' + resolved.absolutePath)
+ return
+ }
+ const openResponse = await client.sendRequest(
+ 'files.open',
+ { worktree, relativePath: resolved.relativePath },
+ { timeoutMs: 15_000 }
+ )
+ if (!openResponse.ok) {
+ return
+ }
+ // Why: the desktop creates the file tab asynchronously; a single poll
+ // can race it, so refresh a few times to reliably pick it up and
+ // switch to it (the file browser gets this for free via router.back).
+ scheduleDelayedAction(() => void fetchSessionTabs(), 300)
+ scheduleDelayedAction(() => void fetchSessionTabs(), 900)
+ scheduleDelayedAction(() => void fetchSessionTabs(), 1800)
+ } catch {
+ // Resolution/open is best-effort; a failed tap silently no-ops.
+ }
+ })()
+ },
+ [client, worktreeId, scheduleDelayedAction, fetchSessionTabs]
+ )
+
const toggleLiveInput = useCallback(() => {
if (!activeHandle) {
return
@@ -2782,8 +2929,9 @@ export default function SessionScreen() {
liveInputRef.current?.setNativeProps({ text: '' })
return
}
- if (text.length > 0) {
- sendLiveTerminalInput(activeHandle, text)
+ const normalizedText = normalizeTerminalTextInput(text)
+ if (normalizedText.length > 0) {
+ sendLiveTerminalInput(activeHandle, normalizedText)
}
setLiveInputCapture('')
// Why: the field is only a keyboard capture surface. Clearing the
@@ -3143,30 +3291,65 @@ export default function SessionScreen() {
}
}, [])
+ const getActiveWorktreeConnectionId = useCallback(async (): Promise => {
+ if (!client) {
+ return null
+ }
+ const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
+ const repoResponse = await client.sendRequest('repo.list')
+ if (!repoResponse.ok) {
+ throw new Error((repoResponse as RpcFailure).error.message)
+ }
+ const repos =
+ ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
+ return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null
+ }, [client, worktreeId])
+
+ const refreshCanPaste = useCallback(() => {
+ void Promise.all([
+ Clipboard.hasStringAsync().catch(() => false),
+ Clipboard.hasImageAsync().catch(() => false)
+ ]).then(([hasString, hasImage]) => {
+ setCanPaste(hasString || hasImage)
+ })
+ }, [])
+
const handlePaste = useCallback(async () => {
if (!client || !activeHandle || !canSend) {
return
}
try {
const text = await Clipboard.getStringAsync()
- if (text.length === 0) {
- return
+ let payload: string | null = null
+ if (text.length > 0) {
+ const modes = ptyModesRef.current.get(activeHandle) || {
+ bracketedPasteMode: false,
+ altScreen: false,
+ mouseTrackingMode: 'none',
+ sgrMouseMode: false,
+ sgrMousePixelsMode: false
+ }
+ const wrap = modes.bracketedPasteMode && !modes.altScreen
+ // Why: strip embedded bracketed-paste markers from clipboard text so a
+ // malicious copy containing `\x1b[201~` can't terminate paste mode early
+ // and have the trailing bytes interpreted as shell commands. Matches
+ // xterm.js / iTerm2 behavior.
+ // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
+ const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
+ payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
+ } else {
+ const image = await Clipboard.getImageAsync({ format: 'png' })
+ if (!image) {
+ refreshCanPaste()
+ return
+ }
+ const connectionId = await getActiveWorktreeConnectionId()
+ const imagePath = await saveMobileClipboardImageAsTempFile(client, image.data, {
+ connectionId
+ })
+ payload = buildMobileImagePastePayload(imagePath)
}
- const modes = ptyModesRef.current.get(activeHandle) || {
- bracketedPasteMode: false,
- altScreen: false,
- mouseTrackingMode: 'none',
- sgrMouseMode: false,
- sgrMousePixelsMode: false
- }
- const wrap = modes.bracketedPasteMode && !modes.altScreen
- // Why: strip embedded bracketed-paste markers from clipboard text so a
- // malicious copy containing `\x1b[201~` can't terminate paste mode early
- // and have the trailing bytes interpreted as shell commands. Matches
- // xterm.js / iTerm2 behavior.
- // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
- const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
- const payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
+
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
triggerError()
@@ -3184,7 +3367,7 @@ export default function SessionScreen() {
: {})
})
triggerSelection()
- void Clipboard.hasStringAsync().then(setCanPaste)
+ refreshCanPaste()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
@@ -3193,17 +3376,44 @@ export default function SessionScreen() {
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast('Paste failed (disconnected)', 1500)
+ } else if (err.message === 'Clipboard image is too large') {
+ showToast('Image too large to paste', 1500)
+ } else {
+ showToast('Paste failed', 1500)
}
}
- }, [client, activeHandle, canSend, connState, showToast])
+ }, [
+ client,
+ activeHandle,
+ canSend,
+ connState,
+ getActiveWorktreeConnectionId,
+ refreshCanPaste,
+ showToast
+ ])
+
+ const { attachImage, isAttaching } = useMobileImageAttachment({
+ client,
+ activeHandle,
+ canSend,
+ connState,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ showToast,
+ onSuccess: triggerSelection,
+ onError: triggerError
+ })
// Why: refresh canPaste on mount, AppState active, after paste.
useEffect(() => {
let mounted = true
const refresh = () => {
- void Clipboard.hasStringAsync().then((has) => {
+ void Promise.all([
+ Clipboard.hasStringAsync().catch(() => false),
+ Clipboard.hasImageAsync().catch(() => false)
+ ]).then(([hasString, hasImage]) => {
if (mounted) {
- setCanPaste(has)
+ setCanPaste(hasString || hasImage)
}
})
}
@@ -3446,7 +3656,9 @@ export default function SessionScreen() {
if (!client || creatingBrowser) {
return false
}
- if (browserScreencastSupported !== true) {
+ // Why: read via ref so a tap that fires before the capability probe resolves
+ // (or from a stale callback) still sees the live support value.
+ if (browserScreencastSupportedRef.current !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return false
}
@@ -3465,14 +3677,25 @@ export default function SessionScreen() {
'browser.tabCreate',
{
worktree: `id:${worktreeId}`,
- url
+ url,
+ // The user opened this tab (tapped HTML / address bar) → focus it.
+ activate: true
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
- scheduleDelayedAction(() => void fetchSessionTabs(), 300)
+ // Focus the new browser tab once it syncs (fetchSessionTabs activates it
+ // via the normal path). Refresh a few times since the desktop registers
+ // the tab asynchronously.
+ const created = (response as RpcSuccess).result as { browserPageId?: string }
+ if (created.browserPageId) {
+ pendingBrowserFocusPageIdRef.current = created.browserPageId
+ }
+ void fetchSessionTabs()
+ scheduleDelayedAction(() => void fetchSessionTabs(), 400)
+ scheduleDelayedAction(() => void fetchSessionTabs(), 1200)
return true
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create browser'
@@ -3640,6 +3863,17 @@ export default function SessionScreen() {
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
+ // Why: the reconnect loop parks at its give-up cap; without an in-session
+ // affordance the only recovery is leaving the screen or restarting the
+ // app (issue #5049). Surface tap-to-retry once the verdict escalates.
+ const connectionVerdict = classifyConnection({
+ state: connState,
+ reconnectAttempts,
+ lastConnectedAt
+ })
+ const showConnectionRetry =
+ connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
+
const terminalSummary =
connState === 'connected'
? showLoadingState
@@ -3647,7 +3881,9 @@ export default function SessionScreen() {
: visibleTabs.length === 1
? '1 tab'
: `${visibleTabs.length} tabs`
- : STATUS_LABELS[connState]
+ : showConnectionRetry
+ ? `${connectionVerdict.label} — tap to retry`
+ : MOBILE_SESSION_STATUS_LABELS[connState]
// Why: keep safe-area padding in layout at all times, then visually translate
// the controls over the terminal when the keyboard appears. iOS keyboard
@@ -3790,12 +4026,22 @@ export default function SessionScreen() {
{worktreeName || 'Terminal'}
-
+ {
+ if (hostId) {
+ void forceReconnectHost(hostId)
+ }
+ }}
+ accessibilityRole={showConnectionRetry ? 'button' : undefined}
+ accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
+ >
{terminalSummary}
-
+
[styles.filesButton, pressed && styles.filesButtonPressed]}
@@ -3827,16 +4073,41 @@ export default function SessionScreen() {
{visibleTabs.length > 0 && (
+ {/* Why: tab taps must register on the first press while the live
+ keyboard is open instead of being eaten by keyboard dismissal
+ (#5106); leaving a non-live tab still closes the keyboard
+ because the live input unmounts. */}
{
+ tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
+ }}
+ onLayout={(e) => {
+ tabStripViewportWidthRef.current = e.nativeEvent.layout.width
+ scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
+ }}
+ onContentSizeChange={(width) => {
+ tabStripContentWidthRef.current = width
+ scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
+ }}
>
{visibleTabs.map((t) => (
{
+ const { x, width } = e.nativeEvent.layout
+ tabLayoutsRef.current.set(t.id, { x, width })
+ if (t.id === activeSessionTabIdRef.current) {
+ scrollActiveTabIntoView(t.id, false)
+ }
+ }}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
triggerMediumImpact()
@@ -4036,6 +4307,13 @@ export default function SessionScreen() {
active={terminal.handle === activeHandle}
keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0}
terminalTheme={terminal.terminalTheme}
+ textScale={terminalTextScale}
+ onTextScaleChange={(scale) => {
+ // Why: pinch-to-zoom in the WebView reports a new preset; persist
+ // it so the size sticks across panes and app launches.
+ setTerminalTextScale(scale)
+ void saveTerminalTextScale(scale)
+ }}
onRef={setTerminalWebViewRef}
onWebReady={handleTerminalWebReady}
onSelectionMode={handleSelectionMode}
@@ -4046,6 +4324,7 @@ export default function SessionScreen() {
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
onTerminalTap={handleTerminalTap}
+ onFileTap={handleFileTap}
/>
))}
{toastMessage && (
@@ -4067,10 +4346,14 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
+ {/* Why: with default tap handling the first tap on any accessory
+ key dismisses the open keyboard and is swallowed, so live
+ input lost its keyboard on every Esc/Tab press (#5106). */}
[
@@ -4238,6 +4521,7 @@ export default function SessionScreen() {
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
+ smartInsertDelete={false}
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
returnKeyType="default"
blurOnSubmit={false}
@@ -4249,17 +4533,59 @@ export default function SessionScreen() {
) : (
+ setInput((previousText) => normalizeTerminalTextInput(text, previousText))
+ }
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
- autoCorrect={false}
+ autoCorrect={autocompleteEnabled}
+ spellCheck={autocompleteEnabled}
+ smartInsertDelete={false}
+ // Why: the default keyboard exposes autocomplete/autocorrect;
+ // ascii-capable (iOS) / visible-password (Android) suppress it.
+ keyboardType={
+ autocompleteEnabled
+ ? 'default'
+ : Platform.OS === 'ios'
+ ? 'ascii-capable'
+ : 'visible-password'
+ }
returnKeyType="send"
editable={canSend}
onSubmitEditing={() => void handleSend()}
/>
+ void attachImage('library')}
+ onLongPress={() => void attachImage('files')}
+ delayLongPress={350}
+ accessibilityLabel={isAttaching ? 'Sending image' : 'Attach a photo'}
+ accessibilityHint="Long press to attach a file instead"
+ >
+ {isAttaching ? (
+
+ ) : (
+
+ )}
+
{
- if (dictation.isProcessing) {
- void dictation.cancel()
- } else if (dictation.isStarting) {
- return
- } else if (dictation.isRecording) {
- void dictation.stop()
- } else {
- void dictation.start().catch((err) => {
- triggerError()
- showToast(err instanceof Error ? err.message : String(err))
- })
- }
- }}
- onLongPress={() => {
- if (dictation.isRecording || dictation.isProcessing) {
- void dictation.cancel()
- }
- }}
+ onPress={dictationMode === 'toggle' ? handleDictationToggle : undefined}
+ onPressIn={dictationMode === 'hold' ? handleDictationPressIn : undefined}
+ onPressOut={dictationMode === 'hold' ? handleDictationPressOut : undefined}
+ onLongPress={
+ dictationMode === 'toggle'
+ ? () => {
+ if (dictation.isRecording || dictation.isProcessing) {
+ void dictation.cancel()
+ }
+ }
+ : undefined
+ }
accessibilityLabel={
dictation.isRecording
? 'Stop voice dictation'
@@ -4322,6 +4641,7 @@ export default function SessionScreen() {
visible={showCreateTabDrawer}
title="New Tab"
actions={[
+ ...createTabAgentActions,
{
label: 'Terminal',
icon: SquareTerminal,
@@ -4349,8 +4669,7 @@ export default function SessionScreen() {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
- },
- ...createTabAgentActions
+ }
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
@@ -4645,6 +4964,12 @@ export default function SessionScreen() {
onKeysChanged={setCustomKeys}
onManageShortcuts={handleManageShortcuts}
/>
+ setShowDictationSetup(false)}
+ onReady={() => setShowDictationSetup(false)}
+ />
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase
- },
- kavInner: {
- flex: 1
- },
- sessionChrome: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: 1,
- borderBottomColor: colors.borderSubtle
- },
- sessionTopBar: {
- minHeight: 44,
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.xs
- },
- backButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- filesButton: {
- width: 36,
- height: 36,
- borderRadius: radii.button,
- alignItems: 'center',
- justifyContent: 'center',
- marginLeft: spacing.xs
- },
- filesButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- sessionTitleBlock: {
- flex: 1,
- minWidth: 0
- },
- sessionTitle: {
- color: colors.textPrimary,
- fontSize: 14,
- fontWeight: '600'
- },
- sessionMetaRow: {
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: 2
- },
- sessionMetaText: {
- flexShrink: 1,
- color: colors.textSecondary,
- fontSize: typography.metaSize
- },
- tabBar: {
- flexDirection: 'row',
- alignItems: 'center',
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle
- },
- tabScroll: {
- flex: 1,
- maxHeight: 36
- },
- tabContent: {
- paddingLeft: spacing.sm,
- paddingRight: spacing.sm
- },
- tab: {
- width: 128,
- maxWidth: 128,
- minHeight: 36,
- alignItems: 'center',
- justifyContent: 'center',
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.sm,
- borderBottomWidth: 2,
- borderBottomColor: 'transparent'
- },
- tabActive: {
- borderBottomColor: colors.accentBlue
- },
- tabLabelRow: {
- maxWidth: '100%',
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs
- },
- tabText: {
- flexShrink: 1,
- color: colors.textSecondary,
- fontSize: 13
- },
- tabTextActive: {
- color: colors.textPrimary
- },
- newTerminalButton: {
- width: 40,
- height: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderBottomWidth: 2,
- borderBottomColor: 'transparent'
- },
- newTerminalButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- newTerminalButtonDisabled: {
- opacity: 0.45
- },
- terminalFrame: {
- flex: 1,
- minHeight: 0,
- position: 'relative',
- overflow: 'hidden'
- },
- terminalPane: {
- ...StyleSheet.absoluteFillObject
- },
- terminalPaneHidden: {
- opacity: 0
- },
- terminalWebView: {
- flex: 1
- },
- markdownFrame: {
- flex: 1,
- minHeight: 0,
- backgroundColor: colors.bgBase
- },
- browserFrame: {
- flex: 1,
- minHeight: 0,
- backgroundColor: colors.bgBase
- },
- markdownEditor: {
- flex: 1,
- position: 'relative'
- },
- markdownState: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl,
- gap: spacing.md
- },
- markdownError: {
- color: colors.statusRed,
- fontSize: typography.bodySize
- },
- markdownTextInput: {
- flex: 1,
- minHeight: 0,
- color: colors.textPrimary,
- backgroundColor: colors.bgBase,
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.lg,
- paddingBottom: spacing.xl * 3,
- fontSize: typography.bodySize,
- lineHeight: 22,
- fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
- },
- filePreviewScroll: {
- flex: 1,
- minHeight: 0,
- backgroundColor: colors.editorSurface
- },
- filePreviewContent: {
- paddingHorizontal: spacing.lg,
- paddingTop: spacing.lg,
- paddingBottom: spacing.xl
- },
- filePreviewText: {
- color: colors.textPrimary,
- fontSize: typography.bodySize,
- lineHeight: 22,
- fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
- },
- diffNotesToolbar: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: spacing.sm,
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- diffNotesTitleRow: {
- minWidth: 0,
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs
- },
- diffNotesTitle: {
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- diffNotesActions: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs
- },
- diffNotesActionButton: {
- minHeight: 30,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.sm,
- backgroundColor: colors.bgRaised
- },
- diffNotesActionText: {
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- diffLineBlock: {
- marginBottom: spacing.xs
- },
- diffLine: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- borderLeftWidth: 2,
- borderLeftColor: colors.editorSurface,
- paddingRight: spacing.sm
- },
- diffLineAdded: {
- backgroundColor: colors.diffAddedBg,
- borderLeftColor: colors.gitDecorationAdded
- },
- diffLineDeleted: {
- backgroundColor: colors.diffDeletedBg,
- borderLeftColor: colors.gitDecorationDeleted
- },
- diffGutter: {
- width: 42,
- paddingRight: spacing.sm,
- textAlign: 'right',
- color: colors.textMuted,
- fontSize: typography.metaSize,
- lineHeight: 22,
- fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
- },
- diffText: {
- flex: 1,
- color: colors.textPrimary,
- fontSize: typography.bodySize,
- lineHeight: 22,
- fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
- },
- diffPrefix: {
- color: colors.textMuted
- },
- diffPrefixAdded: {
- color: colors.gitDecorationAdded
- },
- diffPrefixDeleted: {
- color: colors.gitDecorationDeleted
- },
- diffCommentAddButton: {
- width: 26,
- height: 22,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- diffCommentAddButtonPressed: {
- backgroundColor: colors.bgPanel
- },
- diffCommentButtonDisabled: {
- opacity: 0.45
- },
- diffCommentList: {
- gap: spacing.xs,
- marginLeft: 44,
- marginRight: spacing.sm,
- marginTop: spacing.xs
- },
- diffCommentCard: {
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- diffCommentHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- marginBottom: 2
- },
- diffCommentMeta: {
- flex: 1,
- color: colors.textMuted,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- diffCommentDeleteButton: {
- width: 22,
- height: 22,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 11
- },
- diffCommentBody: {
- color: colors.textPrimary,
- fontSize: typography.metaSize,
- lineHeight: 17
- },
- diffCommentComposer: {
- gap: spacing.xs,
- marginLeft: 44,
- marginRight: spacing.sm,
- marginTop: spacing.xs,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- padding: spacing.sm
- },
- diffCommentInput: {
- minHeight: 70,
- height: 70,
- marginRight: 0,
- paddingTop: spacing.sm,
- paddingBottom: spacing.sm
- },
- diffCommentComposerActions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: spacing.xs
- },
- diffCommentSecondaryAction: {
- minHeight: 30,
- justifyContent: 'center',
- borderRadius: radii.button,
- paddingHorizontal: spacing.md
- },
- diffCommentSecondaryText: {
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontWeight: '600'
- },
- diffCommentPrimaryAction: {
- minHeight: 30,
- justifyContent: 'center',
- borderRadius: radii.button,
- backgroundColor: colors.bgRaised,
- paddingHorizontal: spacing.md
- },
- diffCommentPrimaryText: {
- color: colors.textPrimary,
- fontSize: typography.metaSize,
- fontWeight: '700'
- },
- markdownRefreshButton: {
- alignSelf: 'flex-start',
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- backgroundColor: colors.bgRaised,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs
- },
- markdownButtonDisabled: {
- opacity: 0.45
- },
- markdownRefreshText: {
- color: colors.textPrimary,
- fontSize: 13,
- fontWeight: '600'
- },
- markdownFloatingBar: {
- position: 'absolute',
- left: spacing.md,
- right: spacing.md,
- bottom: spacing.lg,
- alignItems: 'flex-end',
- gap: spacing.xs
- },
- markdownFloatingStatus: {
- maxWidth: '100%',
- alignSelf: 'flex-end',
- overflow: 'hidden',
- color: colors.textSecondary,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs,
- fontSize: typography.metaSize
- },
- markdownFloatingActions: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'flex-end',
- gap: spacing.xs
- },
- markdownFloatingButton: {
- minHeight: 34,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs
- },
- markdownSaveButton: {
- backgroundColor: colors.bgRaised
- },
- markdownFloatingButtonText: {
- color: colors.textPrimary,
- fontSize: 13,
- fontWeight: '600'
- },
- toast: {
- position: 'absolute',
- bottom: spacing.lg,
- alignSelf: 'center',
- left: 0,
- right: 0,
- alignItems: 'center'
- },
- toastText: {
- backgroundColor: colors.bgRaised,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.borderSubtle,
- color: colors.textPrimary,
- fontSize: 13,
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- borderRadius: radii.button,
- overflow: 'hidden'
- },
- createWarningBanner: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- gap: spacing.sm,
- backgroundColor: colors.bgPanel,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm
- },
- createWarningText: {
- flex: 1,
- color: colors.textPrimary,
- fontSize: 12,
- lineHeight: 16
- },
- createWarningDismiss: {
- width: 24,
- height: 24,
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: -4
- },
- emptyState: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- padding: spacing.xl
- },
- emptyText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- marginBottom: spacing.lg
- },
- createError: {
- color: colors.statusRed,
- fontSize: 13,
- marginBottom: spacing.sm
- },
- emptyActions: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'center',
- gap: spacing.sm
- },
- createButton: {
- backgroundColor: colors.bgRaised,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm + 2,
- borderRadius: radii.button
- },
- createButtonDisabled: {
- opacity: 0.5
- },
- createButtonText: {
- color: colors.textPrimary,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- commandDock: {
- zIndex: 20
- },
- accessoryBar: {
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- accessoryContent: {
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs,
- gap: spacing.xs
- },
- accessoryKey: {
- backgroundColor: colors.bgRaised,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.xs,
- borderRadius: radii.button,
- minWidth: 36,
- alignItems: 'center'
- },
- accessoryKeyPressed: {
- backgroundColor: colors.borderSubtle
- },
- accessoryKeyActive: {
- backgroundColor: colors.textPrimary
- },
- customAccessoryKey: {
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- accessoryKeyDisabled: {
- opacity: 0.35
- },
- accessoryKeyText: {
- color: colors.textSecondary,
- fontSize: 12,
- fontFamily: typography.monoFamily
- },
- accessoryKeyTextActive: {
- color: colors.bgBase,
- fontWeight: '700'
- },
- accessoryKeyTextDisabled: {
- color: colors.textMuted
- },
- inputBar: {
- flexDirection: 'row',
- alignItems: 'center',
- minHeight: 46,
- paddingVertical: spacing.xs + 2,
- paddingHorizontal: spacing.md,
- borderTopWidth: 1,
- borderTopColor: colors.borderSubtle,
- backgroundColor: colors.bgPanel
- },
- textInput: {
- flex: 1,
- height: 34,
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: 0,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- marginRight: spacing.sm
- },
- liveInputBar: {
- gap: spacing.sm
- },
-
- liveInputHint: {
- flex: 1,
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontFamily: typography.monoFamily
- },
- liveInputCapture: {
- position: 'absolute',
- opacity: 0,
- width: 1,
- height: 1,
- color: colors.textPrimary
- },
- sendButton: {
- backgroundColor: colors.bgRaised,
- width: 34,
- height: 34,
- borderRadius: 17,
- alignItems: 'center',
- justifyContent: 'center'
- },
- dictationButton: {
- backgroundColor: colors.bgRaised,
- width: 34,
- height: 34,
- borderRadius: 17,
- borderWidth: 1,
- borderColor: 'transparent',
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.sm
- },
- dictationButtonActive: {
- backgroundColor: colors.bgPanel,
- borderColor: colors.textSecondary
- },
- sendButtonDisabled: {
- opacity: 0.35
- }
-})
diff --git a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts
new file mode 100644
index 00000000000..829078e3a9f
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts
@@ -0,0 +1,178 @@
+import { StyleSheet } from 'react-native'
+
+import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
+
+export const mobileSessionCommandInputStyles = StyleSheet.create({
+ createWarningBanner: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.sm,
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm
+ },
+ createWarningText: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontSize: 12,
+ lineHeight: 16
+ },
+ createWarningDismiss: {
+ width: 24,
+ height: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: -4
+ },
+ emptyState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl
+ },
+ emptyText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ marginBottom: spacing.lg
+ },
+ createError: {
+ color: colors.statusRed,
+ fontSize: 13,
+ marginBottom: spacing.sm
+ },
+ emptyActions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'center',
+ gap: spacing.sm
+ },
+ createButton: {
+ backgroundColor: colors.bgRaised,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ paddingHorizontal: spacing.xl,
+ paddingVertical: spacing.sm + 2,
+ borderRadius: radii.button
+ },
+ createButtonDisabled: {
+ opacity: 0.5
+ },
+ createButtonText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ commandDock: {
+ zIndex: 20
+ },
+ accessoryBar: {
+ borderTopWidth: 1,
+ borderTopColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ accessoryContent: {
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ gap: spacing.xs
+ },
+ accessoryKey: {
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.sm + 2,
+ paddingVertical: spacing.xs,
+ borderRadius: radii.button,
+ minWidth: 36,
+ alignItems: 'center'
+ },
+ accessoryKeyPressed: {
+ backgroundColor: colors.borderSubtle
+ },
+ accessoryKeyActive: {
+ backgroundColor: colors.textPrimary
+ },
+ customAccessoryKey: {
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ accessoryKeyDisabled: {
+ opacity: 0.35
+ },
+ accessoryKeyText: {
+ color: colors.textSecondary,
+ fontSize: 12,
+ fontFamily: typography.monoFamily
+ },
+ accessoryKeyTextActive: {
+ color: colors.bgBase,
+ fontWeight: '700'
+ },
+ accessoryKeyTextDisabled: {
+ color: colors.textMuted
+ },
+ inputBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ minHeight: 46,
+ paddingVertical: spacing.xs + 2,
+ paddingHorizontal: spacing.md,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ textInput: {
+ flex: 1,
+ height: 34,
+ backgroundColor: colors.bgRaised,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 0,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ marginRight: spacing.sm
+ },
+ liveInputBar: {
+ gap: spacing.sm
+ },
+
+ liveInputHint: {
+ flex: 1,
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily
+ },
+ liveInputCapture: {
+ position: 'absolute',
+ opacity: 0,
+ width: 1,
+ height: 1,
+ color: colors.textPrimary
+ },
+ sendButton: {
+ backgroundColor: colors.bgRaised,
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ dictationButton: {
+ backgroundColor: colors.bgRaised,
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ borderWidth: 1,
+ borderColor: 'transparent',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.sm
+ },
+ dictationButtonActive: {
+ backgroundColor: colors.bgPanel,
+ borderColor: colors.textSecondary
+ },
+ sendButtonDisabled: {
+ opacity: 0.35
+ }
+})
diff --git a/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts
new file mode 100644
index 00000000000..9994757116e
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts
@@ -0,0 +1,164 @@
+import { StyleSheet } from 'react-native'
+
+import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
+
+export const mobileSessionFrameStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ kavInner: {
+ flex: 1
+ },
+ sessionChrome: {
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ sessionTopBar: {
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.xs
+ },
+ backButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ filesButton: {
+ width: 36,
+ height: 36,
+ borderRadius: radii.button,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: spacing.xs
+ },
+ filesButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ sessionTitleBlock: {
+ flex: 1,
+ minWidth: 0
+ },
+ sessionTitle: {
+ color: colors.textPrimary,
+ fontSize: 14,
+ fontWeight: '600'
+ },
+ sessionMetaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 2
+ },
+ sessionMetaText: {
+ flexShrink: 1,
+ color: colors.textSecondary,
+ fontSize: typography.metaSize
+ },
+ tabBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderTopWidth: 1,
+ borderTopColor: colors.borderSubtle
+ },
+ tabScroll: {
+ flex: 1,
+ maxHeight: 36
+ },
+ tabContent: {
+ paddingLeft: spacing.sm,
+ paddingRight: spacing.sm
+ },
+ tab: {
+ width: 128,
+ maxWidth: 128,
+ minHeight: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 2,
+ borderBottomColor: 'transparent'
+ },
+ tabActive: {
+ // Neutral grey underline, matching the desktop terminal tab's active
+ // indicator (a muted foreground/card mix), not a blue accent.
+ borderBottomColor: colors.textSecondary
+ },
+ tabLabelRow: {
+ maxWidth: '100%',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs
+ },
+ tabText: {
+ flexShrink: 1,
+ color: colors.textSecondary,
+ fontSize: 13
+ },
+ tabTextActive: {
+ color: colors.textPrimary
+ },
+ newTerminalButton: {
+ width: 40,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderBottomWidth: 2,
+ borderBottomColor: 'transparent'
+ },
+ newTerminalButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ newTerminalButtonDisabled: {
+ opacity: 0.45
+ },
+ terminalFrame: {
+ flex: 1,
+ minHeight: 0,
+ position: 'relative',
+ overflow: 'hidden'
+ },
+ terminalPane: {
+ ...StyleSheet.absoluteFillObject
+ },
+ terminalPaneHidden: {
+ opacity: 0
+ },
+ terminalWebView: {
+ flex: 1
+ },
+ markdownFrame: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.bgBase
+ },
+ browserFrame: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.bgBase
+ },
+ markdownEditor: {
+ flex: 1,
+ position: 'relative'
+ },
+ markdownState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ gap: spacing.md
+ },
+ markdownError: {
+ color: colors.statusRed,
+ fontSize: typography.bodySize
+ }
+})
diff --git a/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts
new file mode 100644
index 00000000000..aba5340ec7a
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts
@@ -0,0 +1,140 @@
+import { Platform, StyleSheet } from 'react-native'
+
+import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
+
+export const mobileSessionReaderStyles = StyleSheet.create({
+ markdownTextInput: {
+ flex: 1,
+ minHeight: 0,
+ color: colors.textPrimary,
+ backgroundColor: colors.bgBase,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.lg,
+ paddingBottom: spacing.xl * 3,
+ fontSize: typography.bodySize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ filePreviewScroll: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.editorSurface
+ },
+ filePreviewContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.lg,
+ paddingBottom: spacing.xl
+ },
+ filePreviewText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ imagePreviewContainer: {
+ flex: 1,
+ minHeight: 0,
+ backgroundColor: colors.editorSurface
+ },
+ imagePreviewScroll: {
+ flex: 1
+ },
+ imagePreviewContent: {
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.lg
+ },
+ imagePreview: {
+ width: '100%',
+ height: '100%',
+ minHeight: 200
+ },
+ diffNotesToolbar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ diffNotesTitleRow: {
+ minWidth: 0,
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs
+ },
+ diffNotesTitle: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ diffNotesActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs
+ },
+ diffNotesActionButton: {
+ minHeight: 30,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.sm,
+ backgroundColor: colors.bgRaised
+ },
+ diffNotesActionText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ diffLineBlock: {
+ marginBottom: spacing.xs
+ },
+ diffLine: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ borderLeftWidth: 2,
+ borderLeftColor: colors.editorSurface,
+ paddingRight: spacing.sm
+ },
+ diffLineAdded: {
+ backgroundColor: colors.diffAddedBg,
+ borderLeftColor: colors.gitDecorationAdded
+ },
+ diffLineDeleted: {
+ backgroundColor: colors.diffDeletedBg,
+ borderLeftColor: colors.gitDecorationDeleted
+ },
+ diffGutter: {
+ width: 42,
+ paddingRight: spacing.sm,
+ textAlign: 'right',
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ diffText: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ diffPrefix: {
+ color: colors.textMuted
+ },
+ diffPrefixAdded: {
+ color: colors.gitDecorationAdded
+ },
+ diffPrefixDeleted: {
+ color: colors.gitDecorationDeleted
+ }
+})
diff --git a/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts
new file mode 100644
index 00000000000..b9d19578b83
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts
@@ -0,0 +1,189 @@
+import { StyleSheet } from 'react-native'
+
+import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
+
+export const mobileSessionReviewCommentStyles = StyleSheet.create({
+ diffCommentAddButton: {
+ width: 26,
+ height: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button
+ },
+ diffCommentAddButtonPressed: {
+ backgroundColor: colors.bgPanel
+ },
+ diffCommentButtonDisabled: {
+ opacity: 0.45
+ },
+ diffCommentList: {
+ gap: spacing.xs,
+ marginLeft: 44,
+ marginRight: spacing.sm,
+ marginTop: spacing.xs
+ },
+ diffCommentCard: {
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ diffCommentHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ marginBottom: 2
+ },
+ diffCommentMeta: {
+ flex: 1,
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ diffCommentDeleteButton: {
+ width: 22,
+ height: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 11
+ },
+ diffCommentBody: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ lineHeight: 17
+ },
+ diffCommentComposer: {
+ gap: spacing.xs,
+ marginLeft: 44,
+ marginRight: spacing.sm,
+ marginTop: spacing.xs,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ padding: spacing.sm
+ },
+ diffCommentInput: {
+ minHeight: 70,
+ height: 70,
+ marginRight: 0,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.sm
+ },
+ diffCommentComposerActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: spacing.xs
+ },
+ diffCommentSecondaryAction: {
+ minHeight: 30,
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md
+ },
+ diffCommentSecondaryText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ diffCommentPrimaryAction: {
+ minHeight: 30,
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.md
+ },
+ diffCommentPrimaryText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ markdownRefreshButton: {
+ alignSelf: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ backgroundColor: colors.bgRaised,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs
+ },
+ markdownButtonDisabled: {
+ opacity: 0.45
+ },
+ markdownRefreshText: {
+ color: colors.textPrimary,
+ fontSize: 13,
+ fontWeight: '600'
+ },
+ markdownFloatingBar: {
+ position: 'absolute',
+ left: spacing.md,
+ right: spacing.md,
+ bottom: spacing.lg,
+ alignItems: 'flex-end',
+ gap: spacing.xs
+ },
+ markdownFloatingStatus: {
+ maxWidth: '100%',
+ alignSelf: 'flex-end',
+ overflow: 'hidden',
+ color: colors.textSecondary,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ fontSize: typography.metaSize
+ },
+ markdownFloatingActions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'flex-end',
+ gap: spacing.xs
+ },
+ markdownFloatingButton: {
+ minHeight: 34,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs
+ },
+ markdownSaveButton: {
+ backgroundColor: colors.bgRaised
+ },
+ markdownFloatingButtonText: {
+ color: colors.textPrimary,
+ fontSize: 13,
+ fontWeight: '600'
+ },
+ toast: {
+ position: 'absolute',
+ bottom: spacing.lg,
+ alignSelf: 'center',
+ left: 0,
+ right: 0,
+ alignItems: 'center'
+ },
+ toastText: {
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ color: colors.textPrimary,
+ fontSize: 13,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ borderRadius: radii.button,
+ overflow: 'hidden'
+ }
+})
diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts
new file mode 100644
index 00000000000..68d545eb387
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts
@@ -0,0 +1,149 @@
+import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
+import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView'
+import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
+import type {
+ MobileHighlightedDiffLine,
+ MobileSyntaxSegment
+} from '../../../../src/session/mobile-file-syntax'
+import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
+import type { DiffComment } from '../../../../../src/shared/types'
+import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
+
+export type Terminal = TerminalRecord
+
+export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
+
+export type MobileSessionTab =
+ | {
+ type: 'terminal'
+ id: string
+ title: string
+ parentTabId?: string
+ leafId?: string
+ status?: 'pending-handle' | 'ready'
+ terminal: string | null
+ agentStatus?: AgentStatusEntry | null
+ terminalTheme?: MobileTerminalTheme
+ isActive: boolean
+ }
+ | {
+ type: 'markdown'
+ id: string
+ title: string
+ filePath: string
+ relativePath: string
+ isDirty: boolean
+ isActive: boolean
+ documentVersion: string
+ }
+ | {
+ type: 'file'
+ id: string
+ title: string
+ filePath: string
+ relativePath: string
+ language?: string
+ mode?: 'edit' | 'diff'
+ diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
+ isDirty: boolean
+ isActive: boolean
+ }
+ | MobileBrowserTab
+
+export type SessionTabsResult = {
+ worktree: string
+ publicationEpoch?: string
+ snapshotVersion: number
+ tabs: MobileSessionTab[]
+ activeTabId: string | null
+ activeTabType: MobileSessionTabType | null
+}
+
+export type RuntimeStatusResult = {
+ capabilities?: string[]
+}
+
+export type MarkdownDocState =
+ | { status: 'loading' }
+ | {
+ status: 'ready'
+ content: string
+ localContent: string
+ baseVersion: string
+ isDirty: boolean
+ editable: boolean
+ stale?: boolean
+ saving?: boolean
+ saveError?: string
+ readOnlyReason?: string
+ }
+ | { status: 'error'; message: string }
+
+export type FileDocState =
+ | { status: 'loading' }
+ | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
+ | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
+ | { status: 'ready'; kind: 'image'; dataUri: string }
+ | { status: 'ready'; kind: 'html'; content: string }
+ | { status: 'error'; message: string }
+
+export type RenderableDiffLine = MobileHighlightedDiffLine
+
+export type DiffCommentActions = {
+ comments: DiffComment[]
+ busy: boolean
+ onAdd: (filePath: string, lineNumber: number, body: string) => Promise
+ onDelete: (commentId: string) => Promise
+ onCopyAll: () => Promise
+ onSendAll: () => void
+}
+
+export type DiffNotesDelivery = {
+ prompt: string
+ comments: DiffComment[]
+}
+
+export type ReadyFileDocState = Extract
+
+export type FileSyntaxState = {
+ doc: ReadyFileDocState
+ language: string
+ segments: MobileSyntaxSegment[]
+}
+
+export type DiffSyntaxState = {
+ doc: ReadyFileDocState
+ language: string
+ lines: RenderableDiffLine[]
+}
+
+export type DirtyMarkdownDraft = {
+ tabId: string
+ title: string
+ content: string
+}
+
+export type TerminalCreateResult = {
+ tab: Extract
+}
+
+export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error'
+
+export type RuntimeRepoSummary = {
+ id: string
+ connectionId?: string | null
+}
+
+export type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
+
+export type TerminalGestureInputBucket = {
+ tokens: number
+ lastRefillMs: number
+}
+
+export type TerminalGestureInputQueue = {
+ bytes: string
+ sequenceCount: number
+ timer: ReturnType | null
+ lastUpdatedMs: number
+}
diff --git a/mobile/app/h/[hostId]/session/mobile-session-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-styles.ts
new file mode 100644
index 00000000000..0f1baae476e
--- /dev/null
+++ b/mobile/app/h/[hostId]/session/mobile-session-styles.ts
@@ -0,0 +1,11 @@
+import { mobileSessionCommandInputStyles } from './mobile-session-command-input-styles'
+import { mobileSessionFrameStyles } from './mobile-session-frame-styles'
+import { mobileSessionReaderStyles } from './mobile-session-reader-styles'
+import { mobileSessionReviewCommentStyles } from './mobile-session-review-comment-styles'
+
+export const styles = {
+ ...mobileSessionFrameStyles,
+ ...mobileSessionReaderStyles,
+ ...mobileSessionReviewCommentStyles,
+ ...mobileSessionCommandInputStyles
+}
diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
index dad4d4cfe94..e289bb995ef 100644
--- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
@@ -14,25 +14,48 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import {
- ChevronLeft,
ArrowDown,
ArrowDownUp,
ArrowUp,
Check,
+ ChevronLeft,
CloudUpload,
FileText,
GitBranch,
GitPullRequest,
+ History,
Minus,
MoreHorizontal,
Plus,
RefreshCw,
+ Sparkles,
Trash2,
- X
+ X,
+ type LucideIcon
} from 'lucide-react-native'
-import { useHostClient } from '../../../../src/transport/client-context'
-import type { RpcClient } from '../../../../src/transport/rpc-client'
+import type { MobileSourceControlActionIcon } from '../../../../src/source-control/mobile-source-control-actions'
+import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
+import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import type { RpcSuccess } from '../../../../src/transport/types'
+import { MobileSourceControlReviewEntry } from '../../../../src/source-control/mobile-source-control-review-entry'
+import { resolveMobileBranchCompareBaseRef } from '../../../../src/source-control/mobile-branch-base-ref'
+import {
+ cancelMobileCommitMessage,
+ requestMobileCommitMessage
+} from '../../../../src/source-control/mobile-commit-message-ai'
+import { buildMobileSourceControlActions } from '../../../../src/source-control/mobile-source-control-actions'
+import {
+ MobilePrComposeSheet,
+ openMobilePrUrl
+} from '../../../../src/components/MobilePrComposeSheet'
+import {
+ resolveMobilePrPrefill,
+ type MobilePrPrefill
+} from '../../../../src/source-control/mobile-pr-create'
+import { PickerModal } from '../../../../src/components/PickerModal'
+import type { RuntimeGitLocalBranches } from '../../../../../src/shared/runtime-types'
+
+type MobileGitLocalBranches = RuntimeGitLocalBranches
import {
ActionSheetModal,
type ActionSheetAction
@@ -133,16 +156,6 @@ type MobileBranchDiffPreviewState =
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
-type RuntimeRepoSummary = {
- id: string
- worktreeBaseRef?: string | null
-}
-
-type RepoBaseRefDefaultResult = {
- defaultBaseRef: string | null
- remoteCount: number
-}
-
type GitDiffTextResult = {
kind: 'text'
originalContent: string
@@ -150,6 +163,19 @@ type GitDiffTextResult = {
}
const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
+
+const SOURCE_CONTROL_ACTION_ICONS: Record = {
+ commit: Check,
+ push: ArrowUp,
+ pull: ArrowDown,
+ sync: ArrowDownUp,
+ fetch: RefreshCw,
+ publish: CloudUpload,
+ rebase: GitBranch,
+ pr: GitPullRequest,
+ branch: GitBranch,
+ history: History
+}
const SELECTOR_RETRY_COUNT = 3
const SELECTOR_RETRY_DELAY_MS = 250
@@ -161,56 +187,6 @@ function wait(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms))
}
-function getRepoIdFromMobileWorktreeId(id: string): string {
- // Why: mobile cannot import desktop shared modules in its standalone tsc run,
- // but the runtime worktree id wire format is still `${repoId}::${path}`.
- const separatorIdx = id.indexOf('::')
- return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
-}
-
-async function resolveMobileBranchCompareBaseRef(
- client: RpcClient,
- worktreeId: string
-): Promise {
- const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
- if (!repoId) {
- return null
- }
-
- let repoBaseRef: string | null = null
- const repoResponse = await client.sendRequest('repo.list')
- if (repoResponse.ok) {
- const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos
- const repo = repos?.find((candidate) => candidate.id === repoId)
- repoBaseRef = repo?.worktreeBaseRef?.trim() || null
- }
-
- if (repoBaseRef) {
- return repoBaseRef
- }
-
- const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` })
- if (!defaultResponse.ok) {
- if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) {
- return null
- }
- throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base')
- }
- const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult
- return result.defaultBaseRef?.trim() || null
-}
-
-function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
- if (name?.trim()) {
- return name.trim()
- }
- const pathPart = worktreeId.includes('::')
- ? worktreeId.slice(worktreeId.indexOf('::') + 2)
- : worktreeId
- const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
- return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
-}
-
function formatBranchLabel(branch: string | undefined, head: string | undefined): string {
if (branch?.startsWith('refs/heads/')) {
return branch.slice('refs/heads/'.length)
@@ -249,6 +225,7 @@ export default function MobileSourceControlScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
+ const forceReconnect = useForceReconnect()
const [screenState, setScreenState] = useState({ kind: 'loading' })
const [branchCompareState, setBranchCompareState] = useState({
kind: 'idle'
@@ -258,6 +235,12 @@ export default function MobileSourceControlScreen() {
)
const [busyAction, setBusyAction] = useState(null)
const [commitMessage, setCommitMessage] = useState('')
+ const [generatingMessage, setGeneratingMessage] = useState(false)
+ const [showPrSheet, setShowPrSheet] = useState(false)
+ const [showBranchPicker, setShowBranchPicker] = useState(false)
+ const [localBranches, setLocalBranches] = useState(null)
+ const [createdPrUrl, setCreatedPrUrl] = useState(null)
+ const [prPrefill, setPrPrefill] = useState(null)
const [discardTarget, setDiscardTarget] = useState(null)
const [showActionSheet, setShowActionSheet] = useState(false)
const [actionError, setActionError] = useState(null)
@@ -516,6 +499,7 @@ export default function MobileSourceControlScreen() {
branchCompareState.kind === 'error' ||
(branchCompareResult !== null && branchCompareResult.summary.status !== 'ready')
const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection
+ const reviewableCount = entries.length + (branchCompareCanOpen ? branchEntries.length : 0)
const stageablePaths = useMemo(() => getStageablePaths(entries), [entries])
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
@@ -693,6 +677,109 @@ export default function MobileSourceControlScreen() {
)
}, [commitMessage, runGitWorkflow, sendCommitRequest])
+ // AI-generate a commit message from the staged diff. Matches desktop: the
+ // button is always available; a missing model surfaces as a toast.
+ const generateCommitMessage = useCallback(async () => {
+ if (!client || generatingMessage || busyActionRef.current) {
+ return
+ }
+ setGeneratingMessage(true)
+ setActionError(null)
+ try {
+ const result = await requestMobileCommitMessage(client, worktreeId)
+ if (!mountedRef.current) {
+ return
+ }
+ if (result.success) {
+ setCommitMessage(result.message)
+ triggerSuccess()
+ } else if (!result.canceled) {
+ triggerError()
+ setActionError(result.error)
+ }
+ } finally {
+ if (mountedRef.current) {
+ setGeneratingMessage(false)
+ }
+ }
+ }, [client, generatingMessage, worktreeId])
+
+ const cancelGenerateCommitMessage = useCallback(() => {
+ if (client) {
+ void cancelMobileCommitMessage(client, worktreeId)
+ }
+ }, [client, worktreeId])
+
+ const openPrSheet = useCallback(
+ async (pushFirst: boolean) => {
+ setShowActionSheet(false)
+ if (pushFirst) {
+ const pushed = await runGitWorkflow('push-create-pr', async () => {
+ await sendGitRequest('git.push')
+ })
+ if (!pushed || !mountedRef.current) {
+ return
+ }
+ }
+ const up = status?.upstreamStatus
+ const prefill: MobilePrPrefill = client
+ ? await resolveMobilePrPrefill(client, worktreeId, {
+ branch: status?.branch,
+ title: branchLabel,
+ hasUncommittedChanges: (status?.entries?.length ?? 0) > 0,
+ hasUpstream: up?.hasUpstream === true,
+ ahead: up?.ahead ?? 0,
+ behind: up?.behind ?? 0
+ })
+ : { provider: 'github', base: 'main', title: branchLabel, body: '' }
+ if (!mountedRef.current) {
+ return
+ }
+ setPrPrefill(prefill)
+ setShowPrSheet(true)
+ },
+ [branchLabel, client, runGitWorkflow, sendGitRequest, status, worktreeId]
+ )
+
+ const openBranchPicker = useCallback(() => {
+ setShowActionSheet(false)
+ setLocalBranches(null)
+ setShowBranchPicker(true)
+ if (client) {
+ void sendGitRequest('git.localBranches')
+ .then((result) => {
+ if (mountedRef.current) {
+ setLocalBranches(result)
+ }
+ })
+ .catch(() => {
+ if (mountedRef.current) {
+ setLocalBranches({ current: null, branches: [] })
+ }
+ })
+ }
+ }, [client, sendGitRequest])
+
+ const openHistory = useCallback(() => {
+ setShowActionSheet(false)
+ if (hostId && worktreeId) {
+ router.push(
+ `/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
+ typeof router.push
+ >[0]
+ )
+ }
+ }, [hostId, router, worktreeId])
+
+ // Switch to a local branch, then reload status.
+ const checkoutBranch = useCallback(
+ async (branch: string) => {
+ setShowBranchPicker(false)
+ await runGitAction('checkout', 'git.checkout', { branch })
+ },
+ [runGitAction]
+ )
+
const runCommitFollowUps = useCallback(
async (actionId: string, afterCommit: () => Promise) => {
const message = commitMessage.trim()
@@ -800,6 +887,33 @@ export default function MobileSourceControlScreen() {
setShowActionSheet(false)
}, [runGitSync])
+ const runActionSheetRebase = useCallback(async () => {
+ await runGitWorkflow('rebase', async () => {
+ if (!client) {
+ throw new Error('Waiting for desktop...')
+ }
+ const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
+ if (!baseRef) {
+ throw new Error('No base branch to rebase onto')
+ }
+ await sendGitRequest('git.rebaseFromBase', { baseRef })
+ })
+ setShowActionSheet(false)
+ }, [client, runGitWorkflow, sendGitRequest, worktreeId])
+
+ // Abort an in-progress merge/rebase from the conflict banner.
+ const abortConflictOperation = useCallback(
+ async (operation: string) => {
+ const method =
+ operation === 'merge' ? 'git.abortMerge' : operation === 'rebase' ? 'git.abortRebase' : null
+ if (!method) {
+ return
+ }
+ await runGitAction(`abort-${operation}`, method, {})
+ },
+ [runGitAction]
+ )
+
const openFile = useCallback(
async (entry: MobileGitStatusEntry) => {
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') {
@@ -944,144 +1058,58 @@ export default function MobileSourceControlScreen() {
[branchCompareState, client, connState, worktreeId]
)
- const actionSheetActions = useMemo(() => {
- const hasMessage = commitMessage.trim().length > 0
- const hasStaged = stagedCount > 0
- const hasUpstream = upstream?.hasUpstream === true
- const ahead = upstream?.ahead ?? 0
- const behind = upstream?.behind ?? 0
- const busy = busyAction !== null || openingPath !== null || openingBranchPath !== null
- const commitHint = !hasStaged
- ? 'Stage at least one file'
- : !hasMessage
- ? 'Enter a commit message'
- : undefined
- const remoteHint = !upstreamKnown
- ? 'Checking branch status...'
- : hasUpstream
- ? undefined
- : 'Publish Branch first'
- const createPrHint = 'Pull requests are not available on mobile yet'
-
- return [
- {
- label: 'Commit',
- icon: Check,
- disabled: busy || !!commitHint,
- hint: commitHint,
- loading: busyAction === 'commit',
- skipAutoClose: true,
- onPress: () => void runActionSheetCommit()
- },
- {
- label: 'Commit & Push',
- icon: ArrowUp,
- disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream,
- hint: commitHint ?? remoteHint,
- loading: busyAction === 'commit-push',
- skipAutoClose: true,
- onPress: () => void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }])
- },
- {
- label: 'Commit & Sync',
- icon: ArrowDownUp,
- disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream || behind === 0,
- hint:
- commitHint ??
- (!upstreamKnown || !hasUpstream
- ? remoteHint
- : behind === 0
- ? 'Nothing to pull'
- : undefined),
- loading: busyAction === 'commit-sync',
- skipAutoClose: true,
- onPress: () => void runActionSheetCommitSync()
- },
- {
- label: ahead > 0 ? `Push (${ahead})` : 'Push',
- icon: ArrowUp,
- disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0,
- hint: !hasUpstream ? remoteHint : ahead === 0 ? 'Nothing to push' : undefined,
- loading: busyAction === 'push',
- skipAutoClose: true,
- onPress: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }])
- },
- {
- label: 'Create PR',
- icon: GitPullRequest,
- disabled: true,
- hint: createPrHint,
- onPress: () => {}
- },
- {
- label: 'Push & Create PR',
- icon: GitPullRequest,
- disabled: true,
- hint: createPrHint,
- onPress: () => {}
- },
- {
- label: behind > 0 ? `Pull (${behind})` : 'Pull',
- icon: ArrowDown,
- disabled: busy || !upstreamKnown || !hasUpstream || behind === 0,
- hint: !hasUpstream ? remoteHint : behind === 0 ? 'Nothing to pull' : undefined,
- loading: busyAction === 'pull',
- skipAutoClose: true,
- onPress: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }])
- },
- {
- label: ahead > 0 || behind > 0 ? `Sync (↓${behind} ↑${ahead})` : 'Sync',
- icon: ArrowDownUp,
- disabled: busy || !upstreamKnown || !hasUpstream || (ahead === 0 && behind === 0),
- hint:
- !upstreamKnown || !hasUpstream
- ? remoteHint
- : ahead === 0 && behind === 0
- ? 'Branch is up to date'
- : undefined,
- loading: busyAction === 'sync',
- skipAutoClose: true,
- onPress: () => void runActionSheetGitSync()
- },
- {
- label: 'Fetch',
- icon: RefreshCw,
- disabled: busy,
- loading: busyAction === 'fetch',
- skipAutoClose: true,
- onPress: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }])
- },
- {
- label: 'Publish Branch',
- icon: CloudUpload,
- disabled: busy || !upstreamKnown || hasUpstream,
- hint: !upstreamKnown
- ? 'Checking branch status...'
- : hasUpstream
- ? 'Branch is already published'
- : undefined,
- loading: busyAction === 'publish',
- skipAutoClose: true,
- onPress: () =>
- void runActionSheetGitSequence('publish', [
- { method: 'git.push', params: { publish: true } }
- ])
- }
+ const actionSheetActions = useMemo(
+ () =>
+ buildMobileSourceControlActions({
+ commitMessage,
+ stagedCount,
+ upstream: upstream ?? null,
+ upstreamKnown,
+ busyAction,
+ openingPath,
+ openingBranchPath,
+ prAvailable: upstreamKnown && upstream?.hasUpstream === true,
+ handlers: {
+ commit: () => void runActionSheetCommit(),
+ commitPush: () =>
+ void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }]),
+ commitSync: () => void runActionSheetCommitSync(),
+ push: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }]),
+ pull: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }]),
+ sync: () => void runActionSheetGitSync(),
+ fetch: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }]),
+ publish: () =>
+ void runActionSheetGitSequence('publish', [
+ { method: 'git.push', params: { publish: true } }
+ ]),
+ fastForward: () =>
+ void runActionSheetGitSequence('fast-forward', [{ method: 'git.fastForward' }]),
+ rebase: () => void runActionSheetRebase(),
+ createPr: () => void openPrSheet(false),
+ pushAndCreatePr: () => void openPrSheet(true),
+ checkout: () => void openBranchPicker(),
+ history: () => void openHistory()
+ }
+ }).map((action) => ({ ...action, icon: SOURCE_CONTROL_ACTION_ICONS[action.iconKey] })),
+ [
+ busyAction,
+ commitMessage,
+ openBranchPicker,
+ openHistory,
+ openingBranchPath,
+ openingPath,
+ openPrSheet,
+ runActionSheetCommit,
+ runActionSheetCommitSequence,
+ runActionSheetCommitSync,
+ runActionSheetGitSequence,
+ runActionSheetGitSync,
+ runActionSheetRebase,
+ stagedCount,
+ upstream,
+ upstreamKnown
]
- }, [
- busyAction,
- commitMessage,
- openingBranchPath,
- openingPath,
- runActionSheetCommit,
- runActionSheetCommitSequence,
- runActionSheetCommitSync,
- runActionSheetGitSequence,
- runActionSheetGitSync,
- stagedCount,
- upstream,
- upstreamKnown
- ])
+ )
const renderItem = useCallback<
SectionListRenderItem<
@@ -1431,7 +1459,20 @@ export default function MobileSourceControlScreen() {
{screenState.message}
{screenState.kind === 'error' ? (
- void loadStatus()}>
+ {
+ // Why: retrying the request is useless while the transport's
+ // reconnect loop is parked at its give-up cap — revive the
+ // connection instead (issue #5049). loadStatus re-runs via
+ // its connState effect once the new client connects.
+ if (connState !== 'connected' && hostId) {
+ void forceReconnect(hostId)
+ return
+ }
+ void loadStatus()
+ }}
+ >
Retry
) : null}
@@ -1455,7 +1496,23 @@ export default function MobileSourceControlScreen() {
{branchEntries.length} on branch
) : null}
{status && status.conflictOperation !== 'unknown' ? (
- {status.conflictOperation}
+
+ {status.conflictOperation}
+ {(status.conflictOperation === 'merge' ||
+ status.conflictOperation === 'rebase') && (
+ [styles.abortButton, pressed && styles.abortPressed]}
+ disabled={busyAction !== null}
+ onPress={() => void abortConflictOperation(status.conflictOperation)}
+ >
+
+ {busyAction === `abort-${status.conflictOperation}`
+ ? 'Aborting…'
+ : `Abort ${status.conflictOperation}`}
+
+
+ )}
+
) : null}
{actionError ? (
@@ -1465,6 +1522,19 @@ export default function MobileSourceControlScreen() {
) : null}
+
[
@@ -1584,6 +1654,30 @@ export default function MobileSourceControlScreen() {
onSubmitEditing={() => void commit()}
/>
)}
+ [
+ styles.generateButton,
+ (stagedCount === 0 || busyAction !== null) && styles.commitButtonDisabled,
+ pressed && styles.commitButtonPressed
+ ]}
+ // Why: stay tappable while generating so the press can cancel
+ // (disabling it here made the cancel branch below unreachable).
+ disabled={stagedCount === 0 || busyAction !== null}
+ onPress={() =>
+ generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
+ }
+ accessibilityLabel={
+ generatingMessage
+ ? 'Cancel commit message generation'
+ : 'Generate commit message with AI'
+ }
+ >
+ {generatingMessage ? (
+
+ ) : (
+
+ )}
+
[
styles.commitButton,
@@ -1643,6 +1737,52 @@ export default function MobileSourceControlScreen() {
}}
onCancel={() => setDiscardTarget(null)}
/>
+
+ setShowPrSheet(false)}
+ onCreated={(url) => {
+ setShowPrSheet(false)
+ setCreatedPrUrl(url)
+ void loadStatus({ preserveReadyOnFailure: true, force: true })
+ }}
+ />
+
+ ({
+ value: b,
+ label: b,
+ subtitle: b === localBranches?.current ? 'current' : undefined
+ }))}
+ selected={localBranches?.current ?? ''}
+ onSelect={(branch) => {
+ if (branch !== localBranches?.current) {
+ void checkoutBranch(branch)
+ } else {
+ setShowBranchPicker(false)
+ }
+ }}
+ onClose={() => setShowBranchPicker(false)}
+ />
+
+ {
+ if (createdPrUrl) {
+ openMobilePrUrl(createdPrUrl)
+ }
+ setCreatedPrUrl(null)
+ }}
+ onCancel={() => setCreatedPrUrl(null)}
+ />
)
}
@@ -1743,11 +1883,32 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
+ conflictRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
conflictText: {
color: colors.statusAmber,
fontSize: typography.metaSize,
textTransform: 'capitalize'
},
+ abortButton: {
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ borderRadius: radii.button,
+ borderWidth: 1,
+ borderColor: colors.statusAmber
+ },
+ abortPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ abortText: {
+ color: colors.statusAmber,
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ textTransform: 'capitalize'
+ },
actionError: {
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
@@ -1953,6 +2114,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingHorizontal: spacing.md
},
+ generateButton: {
+ width: 42,
+ minHeight: 42,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
commitButtonDisabled: {
opacity: 0.45
},
diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx
index 171508bb636..878f958968f 100644
--- a/mobile/app/h/_layout.tsx
+++ b/mobile/app/h/_layout.tsx
@@ -17,6 +17,7 @@ export default function HostGroupLayout() {
name="[hostId]/source-control/[worktreeId]"
options={{ title: 'Source Control' }}
/>
+
)
}
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index b0e011c0cdc..acaf97b7646 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -19,10 +19,14 @@ import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
+ getUsageBarState,
+ hasActiveProviderUsage,
+ hasRenderableUsage,
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
+import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
import {
useAllHostClients,
@@ -73,6 +77,10 @@ type WorktreeSummary = {
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
+ // The worktree the desktop currently has focused (exactly one is true).
+ isActive?: boolean
+ // Last terminal-output time (ms); breaks ties when nothing is focused.
+ lastOutputAt?: number
}
type HostWorktreeInfo = {
@@ -179,7 +187,9 @@ function fetchWorktreeInfo(
}
client
- .sendRequest('worktree.ps')
+ // Why: worktree.ps defaults to 200 and silently truncates; request the full
+ // set so the host worktree count and active count are accurate.
+ .sendRequest('worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
return
@@ -190,7 +200,8 @@ function fetchWorktreeInfo(
setCachedWorktrees(hostId, worktrees)
const activeStatuses = new Set(['working', 'active', 'permission'])
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
- const lastActive = active.length > 0 ? active[0] : (worktrees[0] ?? null)
+ // Mirror the desktop's focused workspace (see pickResumeWorktree).
+ const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
@@ -601,9 +612,10 @@ export default function HomeScreen() {
if (!snap) {
continue
}
- const hasClaude = snap.claude.accounts.length > 0
- const hasCodex = snap.codex.accounts.length > 0
- if (hasClaude || hasCodex) {
+ // Why: also show hosts whose only usage is the system-default login
+ // (no Orca-managed accounts but live rate-limit data for the active
+ // target), otherwise system-default users see no usage section at all.
+ if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
items.push({ host, snapshot: snap })
}
}
@@ -971,16 +983,16 @@ export default function HomeScreen() {
provider === 'claude'
? snapshot.claude.accounts
: snapshot.codex.accounts
- if (accounts.length === 0) {
+ const limits = getActiveProviderRateLimits(snapshot, provider)
+ // Why: with no managed accounts, still render a
+ // "System default" row when the active target has
+ // live usage data; the row label already falls back
+ // to "System default" below.
+ if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
- const limits = getActiveProviderRateLimits(snapshot, provider)
- const isFetching =
- limits?.status === 'fetching' || limits?.status === 'idle'
- const unavailable =
- limits == null ||
- limits.status === 'unavailable' ||
- limits.status === 'error'
+ const sessionBar = getUsageBarState(limits, 'session')
+ const weeklyBar = getUsageBarState(limits, 'weekly')
return (
@@ -997,15 +1009,15 @@ export default function HomeScreen() {
diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx
index a189a8cd07b..e9a38685e3c 100644
--- a/mobile/app/settings.tsx
+++ b/mobile/app/settings.tsx
@@ -9,6 +9,7 @@ import {
Wrench,
Shield,
LifeBuoy,
+ Mic,
Terminal as TerminalIcon
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
@@ -36,6 +37,15 @@ export default function SettingsScreen() {
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={() => router.push('/voice-settings')}
+ >
+
+ Voice
+
+
+
[styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx
index 2ceefc65516..81367c7f784 100644
--- a/mobile/app/terminal-settings.tsx
+++ b/mobile/app/terminal-settings.tsx
@@ -1,44 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import {
- AppState,
- View,
- Text,
- StyleSheet,
- Pressable,
- ScrollView,
- Switch,
- type AppStateStatus
-} from 'react-native'
+import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { useFocusEffect, useRouter } from 'expo-router'
-import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
-import {
- CustomKeyModal,
- loadCustomKeys,
- saveCustomKeys,
- type CustomKey
-} from '../src/components/CustomKeyModal'
+import { GestureHandlerRootView } from 'react-native-gesture-handler'
+import Animated, {
+ useAnimatedRef,
+ useAnimatedScrollHandler,
+ useSharedValue
+} from 'react-native-reanimated'
+import { useRouter } from 'expo-router'
+import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
-import {
- TERMINAL_ACCESSORY_KEYS,
- type TerminalAccessoryKey
-} from '../src/terminal/terminal-accessory-keys'
-import {
- getDefaultTerminalAccessoryBuiltInIds,
- loadTerminalAccessoryLayout,
- resetTerminalAccessoryBuiltInIds,
- saveTerminalAccessoryLayout,
- setTerminalAccessoryBuiltInVisible
-} from '../src/terminal/terminal-accessory-layout'
+import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
+import {
+ loadTerminalAutocompleteEnabled,
+ loadTerminalTextScale,
+ saveTerminalAutocompleteEnabled,
+ saveTerminalTextScale
+} from '../src/storage/preferences'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
+type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
+
+// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
+// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
+const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [
+ { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
+ { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
+ { value: 'default', label: 'Default (100%)', scale: 1 },
+ { value: 'large', label: 'Large (125%)', scale: 1.25 },
+ { value: 'larger', label: 'Larger (150%)', scale: 1.5 },
+ { value: 'largest', label: 'Largest (200%)', scale: 2 }
+]
+
+function textSizeValueFromScale(scale: number): TextSizeValue {
+ return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
+}
+
+function textSizeSummary(scale: number): string {
+ return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
+}
+
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
@@ -111,33 +119,6 @@ function HostFitRow({
)
}
-function ShortcutBarRow({
- shortcutKey,
- visible,
- onToggle
-}: {
- shortcutKey: TerminalAccessoryKey
- visible: boolean
- onToggle: (visible: boolean) => void
-}): React.JSX.Element {
- return (
-
-
- {shortcutKey.label}
-
-
- {shortcutKey.accessibilityLabel ?? shortcutKey.label}
-
-
-
- )
-}
-
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -152,9 +133,6 @@ export default function TerminalSettingsScreen() {
[hostClients]
)
- const [customKeys, setCustomKeys] = useState([])
- const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
-
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
@@ -162,81 +140,41 @@ export default function TerminalSettingsScreen() {
// drawer appear cut-off.
const [hostMs, setHostMs] = useState>({})
const [pickerHostId, setPickerHostId] = useState(null)
- const [visibleBuiltInIds, setVisibleBuiltInIds] = useState(
- getDefaultTerminalAccessoryBuiltInIds
- )
- const layoutWriteChainRef = useRef>(Promise.resolve())
- const layoutWriteSeqRef = useRef(0)
- const pendingLayoutWritesRef = useRef(0)
-
- const persistLayout = useCallback((nextIds: string[]) => {
- layoutWriteSeqRef.current += 1
- pendingLayoutWritesRef.current += 1
- layoutWriteChainRef.current = layoutWriteChainRef.current
- .catch(() => {})
- .then(() => saveTerminalAccessoryLayout(nextIds))
- .catch(() => {})
- .finally(() => {
- pendingLayoutWritesRef.current -= 1
- })
- }, [])
-
- const refreshShortcutLayout = useCallback(() => {
- const refreshSeq = layoutWriteSeqRef.current
- void loadTerminalAccessoryLayout().then((layout) => {
- if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
- return
- }
- setVisibleBuiltInIds(layout.visibleBuiltInIds)
- })
- }, [])
-
- const refreshCustomKeys = useCallback(() => {
- void loadCustomKeys().then(setCustomKeys)
- }, [])
-
- const handleDeleteCustomKey = useCallback(
- async (key: CustomKey) => {
- const updated = customKeys.filter((k) => k.id !== key.id)
- setCustomKeys(updated)
- await saveCustomKeys(updated)
- },
- [customKeys]
- )
-
- useFocusEffect(
- useCallback(() => {
- refreshShortcutLayout()
- refreshCustomKeys()
- }, [refreshShortcutLayout, refreshCustomKeys])
- )
+ const [textScale, setTextScale] = useState(1)
+ const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
useEffect(() => {
- const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
- if (s === 'active') {
- refreshShortcutLayout()
- refreshCustomKeys()
+ void loadTerminalTextScale().then(setTextScale)
+ }, [])
+ const selectTextSize = useCallback((value: TextSizeValue) => {
+ const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value)
+ if (!opt) {
+ return
+ }
+ setTextScale(opt.scale)
+ void saveTerminalTextScale(opt.scale)
+ }, [])
+
+ const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
+ // Why: a fast toggle before the initial load resolves must win — otherwise the
+ // delayed read would clobber the user's choice with the stored (stale) value.
+ const userToggledAutocompleteRef = useRef(false)
+ useEffect(() => {
+ let stale = false
+ void loadTerminalAutocompleteEnabled().then((enabled) => {
+ if (!stale && !userToggledAutocompleteRef.current) {
+ setAutocompleteEnabled(enabled)
}
})
- return () => sub.remove()
- }, [refreshShortcutLayout, refreshCustomKeys])
-
- const toggleBuiltInKey = useCallback(
- (id: string, visible: boolean) => {
- setVisibleBuiltInIds((current) => {
- const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
- persistLayout(next)
- return next
- })
- },
- [persistLayout]
- )
-
- const resetBuiltInKeys = useCallback(() => {
- const next = resetTerminalAccessoryBuiltInIds()
- setVisibleBuiltInIds(next)
- persistLayout(next)
- }, [persistLayout])
+ return () => {
+ stale = true
+ }
+ }, [])
+ const toggleAutocomplete = useCallback((next: boolean) => {
+ userToggledAutocompleteRef.current = true
+ setAutocompleteEnabled(next)
+ void saveTerminalAutocompleteEnabled(next)
+ }, [])
useEffect(() => {
let cancelled = false
@@ -295,10 +233,28 @@ export default function TerminalSettingsScreen() {
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
- const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
+
+ const scrollRef = useAnimatedRef()
+ const scrollOffsetY = useSharedValue(0)
+ const scrollContentHeight = useSharedValue(0)
+ const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollOffsetY.value = event.contentOffset.y
+ })
+ // Why: imperative toggle instead of state — a re-render while a drag gesture
+ // is active would rebuild the row gestures and could cancel the drag.
+ const setScrollEnabled = useCallback(
+ (enabled: boolean) => {
+ scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
+ },
+ [scrollRef]
+ )
+ const handleDragActiveChange = useCallback(
+ (active: boolean) => setScrollEnabled(!active),
+ [setScrollEnabled]
+ )
return (
-
+
router.back()}>
@@ -306,7 +262,16 @@ export default function TerminalSettingsScreen() {
Terminal
-
+ {
+ scrollContentHeight.value = height
+ }}
+ >
WHEN YOU LEAVE THE APP
While you're using a terminal on your phone, Orca shrinks it to fit your screen. When
@@ -340,76 +305,56 @@ export default function TerminalSettingsScreen() {
)}
- SHORTCUT BAR
+ TEXT SIZE
+
+ Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
+ show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
+ itself, which updates this setting. Per-device display only; doesn't change the
+ desktop terminal.
+
- {TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
-
- {idx > 0 && }
- toggleBuiltInKey(shortcutKey.id, visible)}
- />
-
- ))}
-
[styles.row, pressed && styles.rowPressed]}
- onPress={resetBuiltInKeys}
+ onPress={() => setTextSizePickerOpen(true)}
>
+
- Reset Defaults
- Show every built-in shortcut key
-
-
-
-
- CUSTOM SHORTCUTS
-
- {customKeys.length === 0 ? (
-
- No custom shortcuts defined yet.
-
- ) : (
- customKeys.map((key, idx) => (
-
- {idx > 0 && }
-
-
- {key.label}
-
-
- {key.label}
-
- {key.bytes.replace(/\r/g, ' ↵')}
-
-
- [
- styles.deleteButton,
- pressed && styles.deleteButtonPressed
- ]}
- onPress={() => handleDeleteCustomKey(key)}
- >
-
-
-
-
- ))
- )}
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => setShowCustomKeyModal(true)}
- >
-
- Add Custom Shortcut…
- Create key combo or text macro
+ Text size
+ {textSizeSummary(textScale)}
-
+
+ KEYBOARD INPUT
+
+ Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
+ command bar. Off by default so the keyboard never rewrites commands, flags, or paths.
+ Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes,
+ so suggestions don't apply there.
+
+
+
+
+ Autocomplete & autocorrect
+ {autocompleteEnabled ? 'On' : 'Off'}
+
+
+
+
+
+
+
visible={pickerHost != null}
@@ -424,14 +369,15 @@ export default function TerminalSettingsScreen() {
onClose={() => setPickerHostId(null)}
/>
- setShowCustomKeyModal(false)}
- onKeysChanged={(keys) => {
- setCustomKeys(keys)
- }}
+
+ visible={textSizePickerOpen}
+ title="Terminal text size"
+ options={TEXT_SIZE_OPTIONS}
+ selected={textSizeValueFromScale(textScale)}
+ onSelect={selectTextSize}
+ onClose={() => setTextSizePickerOpen(false)}
/>
-
+
)
}
@@ -472,9 +418,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
- groupTopGap: {
- marginTop: spacing.xl
- },
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
@@ -489,6 +432,9 @@ const styles = StyleSheet.create({
sectionTopGap: {
marginTop: spacing.sm
},
+ inputGroupGap: {
+ marginTop: spacing.xl
+ },
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
@@ -517,38 +463,9 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2
},
- keycap: {
- minWidth: 62,
- alignItems: 'center',
- backgroundColor: colors.bgRaised,
- borderRadius: radii.button,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- keycapText: {
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontFamily: typography.monoFamily
- },
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
- },
- emptyContainer: {
- padding: spacing.md,
- alignItems: 'center',
- justifyContent: 'center'
- },
- deleteButton: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(239, 68, 68, 0.1)'
- },
- deleteButtonPressed: {
- backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx
new file mode 100644
index 00000000000..cfee1854f12
--- /dev/null
+++ b/mobile/app/voice-settings.tsx
@@ -0,0 +1,392 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ View
+} from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useRouter } from 'expo-router'
+import { ChevronLeft, ChevronRight } from 'lucide-react-native'
+import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
+import { loadHosts } from '../src/transport/host-store'
+import type { HostProfile } from '../src/transport/types'
+import { useAllHostClients } from '../src/transport/client-context'
+import type { RpcClient } from '../src/transport/rpc-client'
+import { BottomDrawer } from '../src/components/BottomDrawer'
+import { VoiceModelList } from '../src/components/VoiceModelList'
+import {
+ downloadDictationModel,
+ fetchDictationSetup,
+ isModelInFlight,
+ setDictationConfig,
+ type MobileSpeechModel,
+ type MobileSpeechSetup
+} from '../src/dictation/mobile-dictation-setup'
+
+const POLL_INTERVAL_MS = 1500
+
+const DICTATION_MODES = [
+ { value: 'toggle', label: 'Toggle' },
+ { value: 'hold', label: 'Hold' }
+] as const
+
+export default function VoiceSettingsScreen(): React.JSX.Element {
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+
+ const [hosts, setHosts] = useState([])
+ useEffect(() => {
+ void loadHosts().then(setHosts)
+ }, [])
+ const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
+ const hostClients = useAllHostClients(hostIds)
+ // Voice dictation runs on the paired desktop, so pick the first connected host.
+ const client: RpcClient | null = useMemo(
+ () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
+ [hostClients]
+ )
+
+ const [setup, setSetup] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [busyModelId, setBusyModelId] = useState(null)
+ const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
+ const pollRef = useRef | null>(null)
+
+ const refresh = useCallback(async () => {
+ if (!client) {
+ return
+ }
+ try {
+ setSetup(await fetchDictationSetup(client))
+ setError(null)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load voice settings')
+ }
+ }, [client])
+
+ // Initial load once a connected client is available.
+ useEffect(() => {
+ if (!client) {
+ return
+ }
+ setLoading(true)
+ setError(null)
+ void refresh().finally(() => setLoading(false))
+ }, [client, refresh])
+
+ // Poll only while a model is downloading/extracting; stop otherwise.
+ useEffect(() => {
+ const inFlight = setup?.models.some(isModelInFlight) ?? false
+ if (inFlight && client) {
+ pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
+ return () => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current)
+ pollRef.current = null
+ }
+ }
+ }
+ return undefined
+ }, [setup, client, refresh])
+
+ const handleToggleEnabled = useCallback(
+ async (enabled: boolean) => {
+ if (!client) {
+ return
+ }
+ setError(null)
+ // Optimistic flip so the switch responds instantly; reconcile below.
+ setSetup((prev) => (prev ? { ...prev, enabled } : prev))
+ try {
+ setSetup(await setDictationConfig(client, { enabled }))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not update')
+ void refresh()
+ }
+ },
+ [client, refresh]
+ )
+
+ const handleSelectMode = useCallback(
+ async (dictationMode: 'toggle' | 'hold') => {
+ if (!client) {
+ return
+ }
+ setError(null)
+ setSetup((prev) => (prev ? { ...prev, dictationMode } : prev))
+ try {
+ setSetup(await setDictationConfig(client, { dictationMode }))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not update')
+ void refresh()
+ }
+ },
+ [client, refresh]
+ )
+
+ const handleUseModel = useCallback(
+ async (model: MobileSpeechModel) => {
+ if (!client) {
+ return
+ }
+ setBusyModelId(model.id)
+ setError(null)
+ try {
+ setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id }))
+ setModelDrawerOpen(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not select model')
+ } finally {
+ setBusyModelId(null)
+ }
+ },
+ [client]
+ )
+
+ const handleDownload = useCallback(
+ async (model: MobileSpeechModel) => {
+ if (!client) {
+ return
+ }
+ setBusyModelId(model.id)
+ setError(null)
+ try {
+ await downloadDictationModel(client, model.id)
+ await refresh()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Download failed')
+ } finally {
+ setBusyModelId(null)
+ }
+ },
+ [client, refresh]
+ )
+
+ const enabled = setup?.enabled ?? false
+ const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId)
+ const selectedModelLabel = selectedModel?.label ?? 'None selected'
+
+ return (
+
+
+ router.back()}>
+
+
+ Voice
+
+
+ {!client ? (
+
+ Connect to a desktop to manage voice settings.
+
+ ) : loading && setup === null ? (
+
+
+
+ ) : setup === null ? (
+
+ {error ?? 'Failed to load voice settings.'}
+
+ ) : (
+
+ DICTATION
+
+
+
+ Enable Voice Dictation
+
+ Dictate text into any focused pane on your desktop.
+
+
+ void handleToggleEnabled(v)}
+ trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
+ thumbColor={colors.textPrimary}
+ />
+
+
+
+
+
+
+ Dictation Mode
+
+ Toggle: press once to start, again to stop. Hold: dictate while held.
+
+
+
+ {DICTATION_MODES.map((mode) => {
+ const active = setup.dictationMode === mode.value
+ return (
+ void handleSelectMode(mode.value)}
+ style={[styles.segment, active && styles.segmentActive]}
+ >
+
+ {mode.label}
+
+
+ )
+ })}
+
+
+
+
+ SPEECH MODEL
+
+ [
+ styles.row,
+ !enabled && styles.disabled,
+ pressed && styles.rowPressed
+ ]}
+ disabled={!enabled}
+ onPress={() => setModelDrawerOpen(true)}
+ >
+
+ Speech Model
+
+ {selectedModelLabel}
+
+
+
+
+
+
+ {error ? {error} : null}
+
+ )}
+
+ setModelDrawerOpen(false)}>
+ Speech Model
+ {setup ? (
+ void handleUseModel(m)}
+ onDownload={(m) => void handleDownload(m)}
+ />
+ ) : null}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase,
+ paddingHorizontal: spacing.lg
+ },
+ topRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: spacing.sm,
+ marginBottom: spacing.lg
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.sm
+ },
+ heading: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: colors.textPrimary
+ },
+ scrollContent: {
+ paddingBottom: spacing.xl
+ },
+ loading: { paddingVertical: spacing.xl, alignItems: 'center' },
+ groupHeading: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ letterSpacing: 0.5,
+ marginBottom: spacing.xs,
+ paddingHorizontal: spacing.xs
+ },
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.card,
+ overflow: 'hidden'
+ },
+ sectionTopGap: { marginTop: spacing.sm },
+ inputGroupGap: { marginTop: spacing.xl },
+ disabled: { opacity: 0.5 },
+ emptyText: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary,
+ padding: spacing.md
+ },
+ errorText: {
+ fontSize: typography.bodySize,
+ color: colors.statusRed,
+ padding: spacing.md
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: { backgroundColor: colors.bgRaised },
+ rowContent: { flex: 1 },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ drawerTitle: {
+ fontSize: typography.bodySize,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ paddingHorizontal: spacing.md + 2,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.xs
+ },
+ rowSublabel: {
+ fontSize: typography.bodySize - 2,
+ color: colors.textSecondary,
+ marginTop: 2
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ segmented: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.bgBase,
+ borderRadius: radii.button,
+ padding: 2
+ },
+ segment: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: 6,
+ borderRadius: radii.button - 1
+ },
+ segmentActive: { backgroundColor: colors.bgRaised },
+ segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' },
+ segmentTextActive: { color: colors.textPrimary },
+ error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
+})
diff --git a/mobile/fastlane/Appfile b/mobile/fastlane/Appfile
new file mode 100644
index 00000000000..318a617681b
--- /dev/null
+++ b/mobile/fastlane/Appfile
@@ -0,0 +1,2 @@
+app_identifier(ENV["IOS_BUNDLE_IDENTIFIER"] || "com.stably.orca.mobile")
+team_id(ENV["APPLE_TEAM_ID"])
diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile
new file mode 100644
index 00000000000..c3170f0e854
--- /dev/null
+++ b/mobile/fastlane/Fastfile
@@ -0,0 +1,81 @@
+# Orca Mobile iOS release lane.
+#
+# Builds the prebuilt iOS workspace, signs it with the distribution identity
+# imported into the CI keychain plus an explicit App Store provisioning profile
+# fetched via the App Store Connect API key, then uploads the .ipa to
+# TestFlight. All Apple credentials come from CI env vars
+# (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the
+# repo.
+#
+# Why manual signing (not -allowProvisioningUpdates / automatic cloud signing):
+# mixing a pre-imported distribution .p12 with xcodebuild's cloud-managed
+# automatic signing produced "Cloud signing permission error / No profiles
+# found" at exportArchive (cloud signing also needs an Admin-role API key).
+# Instead we fetch an explicit profile with the API key (sigh) and sign
+# manually against the imported cert — works with any team API key.
+
+require "base64"
+
+default_platform(:ios)
+
+WORKSPACE = "ios/Orca.xcworkspace"
+SCHEME = "Orca"
+BUNDLE_ID = "com.stably.orca.mobile"
+
+platform :ios do
+ desc "Build, sign, and upload Orca Mobile to TestFlight"
+ lane :release do
+ api_key = app_store_connect_api_key(
+ key_id: ENV.fetch("ASC_KEY_ID"),
+ issuer_id: ENV.fetch("ASC_ISSUER_ID"),
+ key_content: ENV.fetch("ASC_API_KEY_P8"),
+ is_key_content_base64: true,
+ in_house: false,
+ )
+
+ team_id = ENV.fetch("APPLE_TEAM_ID")
+
+ # Fetch (or create) the App Store distribution profile via the API key and
+ # install it locally, then feed its name to the manual archive + export.
+ get_provisioning_profile(
+ api_key: api_key,
+ app_identifier: BUNDLE_ID,
+ force: true,
+ )
+ # sigh exposes the chosen profile's name in SIGH_NAME (SIGH_PROFILE_MAPPING
+ # doesn't exist in this fastlane version).
+ profile_name = lane_context[SharedValues::SIGH_NAME]
+
+ # Manual signing: the archive needs the team, profile, and signing style set
+ # explicitly (no -allowProvisioningUpdates). Without DEVELOPMENT_TEAM the
+ # archive fails: "Signing for Orca requires a development team".
+ build_app(
+ workspace: WORKSPACE,
+ scheme: SCHEME,
+ configuration: "Release",
+ export_method: "app-store",
+ xcargs: "DEVELOPMENT_TEAM=#{team_id} " \
+ "CODE_SIGN_STYLE=Manual " \
+ "CODE_SIGN_IDENTITY='Apple Distribution' " \
+ "PROVISIONING_PROFILE_SPECIFIER='#{profile_name}'",
+ export_options: {
+ teamID: team_id,
+ signingStyle: "manual",
+ provisioningProfiles: {
+ BUNDLE_ID => profile_name,
+ },
+ },
+ output_directory: "build",
+ output_name: "Orca.ipa",
+ clean: true,
+ )
+
+ upload_to_testflight(
+ api_key: api_key,
+ skip_waiting_for_build_processing: true,
+ # Why: the human still drafts "What's New" + review notes in the ASC web
+ # UI (see the mobile-app-store-release skill). CI only delivers the build.
+ distribute_external: false,
+ )
+ end
+end
diff --git a/mobile/issue-5049-unresponsive-session-findings.md b/mobile/issue-5049-unresponsive-session-findings.md
new file mode 100644
index 00000000000..6440e1c990c
--- /dev/null
+++ b/mobile/issue-5049-unresponsive-session-findings.md
@@ -0,0 +1,92 @@
+# Issue #5049: Android Remote Session Unresponsiveness — Findings
+
+Date: 2026-06-09
+Issue: https://github.com/stablyai/orca/issues/5049
+
+## Reported symptoms
+
+Android + Tailscale remote session intermittently becomes unresponsive: tab/worktree
+taps do nothing, pasted text doesn't execute, the connection "appears stuck instead
+of clearly disconnected", and closing/reopening the app restores the session.
+
+## Root causes found (mobile-side)
+
+All three independently produce the exact reported symptom — a session that looks
+alive but ignores input, recoverable only by an app restart:
+
+1. **Parked reconnect loop with no recovery path (primary).** `rpc-client.ts`
+ stops retrying permanently after `GIVE_UP_AFTER_ATTEMPTS` (12 attempts ≈ 6.5 min
+ of backoff). Android backgrounding + Doze + a Tailscale tunnel drop routinely
+ burns through all 12 attempts while the user is away. Nothing ever restarted the
+ loop: there was **no AppState listener anywhere in the transport layer**, so
+ returning to the foreground did not nudge the client. The state stays
+ `'reconnecting'` forever ("appears stuck instead of clearly disconnected").
+ Reopening the app creates a fresh client with a fresh attempt budget — which is
+ exactly why "closing and reopening usually restores the session".
+
+2. **Half-open socket detection waits up to ~28s, and never starts earlier on
+ resume.** Android can kill the TCP path while backgrounded without delivering
+ `onclose`; `readyState` still reads OPEN, so every `terminal.send` (e.g. paste)
+ silently blackholes. The activity probe (20s interval + 8s timeout) eventually
+ reaps the link, but the first ~28s after resume look like "pasted text does not
+ run immediately" / "switching is very slow".
+
+3. **Stale client after `forceReconnect` (pre-existing `useHostClient` bug).**
+ `forceReconnect` swaps in a fresh `RpcClient`, but `useHostClient` only re-read
+ the client when its ref was still `null`. Any mounted screen kept driving the
+ old, **closed** client forever: the status header (fed by provider-level state
+ listeners) shows "Connected" while every RPC instantly fails with "Client
+ closed" — a session that looks alive but ignores all input.
+
+ Additionally, the session screen (where users actually live) had no recovery
+ affordance at all: just a status label, while the Retry buttons exist only on
+ the home/host/tasks screens.
+
+## Fixes
+
+- `src/transport/rpc-client.ts` — new `notifyForeground()`:
+ - state `connected` → restart the probe interval and run one probe immediately
+ (half-open link reaped in ≤8s instead of ≤28s);
+ - state `reconnecting` → clear any pending backoff timer, reset the attempt
+ budget, reconnect immediately (un-parks the give-up cap).
+ - (Also extracted the duplicated close/error event serialization into
+ `socket-event-debug.ts` to stay under the file's line cap.)
+- `src/transport/client-context.tsx`:
+ - `RpcClientProvider` now listens to AppState and calls `notifyForeground()` on
+ every live client when the app becomes active.
+ - `useHostClient` re-reads the underlying client on every state change, so
+ screens pick up the fresh client after `forceReconnect` instead of driving a
+ closed one.
+- `app/h/[hostId]/session/[worktreeId].tsx` — the status row in the session header
+ becomes tappable once `classifyConnection` escalates to warning/unreachable,
+ showing " — tap to retry" and invoking `forceReconnect`.
+
+## Repro harnesses
+
+- `src/transport/rpc-client.test.ts` → `foreground recovery` describe block:
+ deterministic fake-timer repro of the parked loop (proves it never self-recovers)
+ plus regression coverage for all `notifyForeground()` paths.
+- `src/transport/rpc-client-live-recovery.test.ts`: opt-in live harness running the
+ REAL rpc-client (real sockets, real tweetnacl E2EE, real timers) against an
+ in-process ws server with a blackhole toggle:
+ - `ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/rpc-client-live-recovery.test.ts`
+ — half-open-link scenario (~15s).
+ - `ORCA_MOBILE_LIVE_REPRO_FULL=1 …` — full parked-loop scenario (~8.5 min): waits
+ out all 12 backoff attempts, proves the loop stays parked even after the server
+ returns, then proves `notifyForeground()` recovers it.
+
+## Not addressed (out of scope, noted for future work)
+
+- The diagnostics in `rpc-client.ts` mention a suspected RN/OkHttp process-state
+ poisoning mode (every open instantly fails with 1006 until force-quit). If that
+ mode is real, a foreground nudge reconnect attempt would also fail; the existing
+ `[net]` logs (wsCount / msSinceLast\*) are designed to confirm or rule it out from
+ device logs.
+
+## Follow-up audit (same PR)
+
+- `connection-revival-triggers.ts` (via `expo-network`) extends the foreground
+ nudge to network restoration and Wi-Fi → cellular handoffs.
+- Files and source-control screens' Retry buttons now revive the transport
+ (`forceReconnect`) when disconnected instead of pointlessly re-sending the
+ request into a parked connection.
diff --git a/mobile/package.json b/mobile/package.json
index 91d0aad7783..9df74d8fa61 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -24,9 +24,12 @@
"expo-constants": "^55.0.16",
"expo-crypto": "^55.0.14",
"expo-dev-client": "~55.0.35",
+ "expo-document-picker": "^55.0.13",
"expo-haptics": "^55.0.14",
+ "expo-image-picker": "^55.0.20",
"expo-linking": "^55.0.15",
"expo-modules-core": "~55.0.25",
+ "expo-network": "~55.0.14",
"expo-notifications": "^55.0.22",
"expo-router": "^55.0.14",
"expo-secure-store": "^55.0.13",
@@ -35,6 +38,7 @@
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"react": "^19.2.6",
+ "react-dom": "19.2.6",
"react-native": "^0.83.9",
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.0",
diff --git a/mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts b/mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts
new file mode 100644
index 00000000000..5684579fd4c
--- /dev/null
+++ b/mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts
@@ -0,0 +1,56 @@
+import { PermissionStatus, type PermissionResponse } from 'expo-modules-core'
+
+type EventSubscription = {
+ remove: () => void
+}
+
+type ExpoTwoWayAudioWebModule = {
+ initialize: () => Promise
+ playPCMData: (audioData: Uint8Array) => void
+ bypassVoiceProcessing: (bypass: boolean) => void
+ toggleRecording: (val: boolean) => boolean
+ isRecording: () => boolean
+ tearDown: () => void
+ restart: () => void
+ getMicrophonePermissionsAsync: () => Promise
+ requestMicrophonePermissionsAsync: () => Promise
+ getMicrophoneModeIOS: () => null
+ setMicrophoneModeIOS: () => void
+ isPlaying: () => boolean
+ stopPlayback: () => void
+ pausePlayback: () => void
+ resumePlayback: () => void
+ addListener: (eventName: string, handler: (ev: unknown) => void) => EventSubscription
+}
+
+const deniedMicrophonePermission: PermissionResponse = {
+ status: PermissionStatus.DENIED,
+ expires: 'never',
+ granted: false,
+ canAskAgain: false
+}
+
+const noop = () => undefined
+
+const ExpoTwoWayAudioModule: ExpoTwoWayAudioWebModule = {
+ // Why: the mobile app can be run on web for QA, but dictation depends on
+ // native audio engines that are only available in the iOS/Android builds.
+ initialize: async () => false,
+ playPCMData: noop,
+ bypassVoiceProcessing: noop,
+ toggleRecording: () => false,
+ isRecording: () => false,
+ tearDown: noop,
+ restart: noop,
+ getMicrophonePermissionsAsync: async () => deniedMicrophonePermission,
+ requestMicrophonePermissionsAsync: async () => deniedMicrophonePermission,
+ getMicrophoneModeIOS: () => null,
+ setMicrophoneModeIOS: noop,
+ isPlaying: () => false,
+ stopPlayback: noop,
+ pausePlayback: noop,
+ resumePlayback: noop,
+ addListener: () => ({ remove: noop })
+}
+
+export default ExpoTwoWayAudioModule
diff --git a/mobile/plugins/android-respect-rotation-lock.js b/mobile/plugins/android-respect-rotation-lock.js
new file mode 100644
index 00000000000..276f7550f9f
--- /dev/null
+++ b/mobile/plugins/android-respect-rotation-lock.js
@@ -0,0 +1,16 @@
+const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins')
+
+// Why: Expo's top-level `orientation` only emits portrait/landscape/unspecified.
+// "unspecified" still auto-rotates on many Android devices even when the system
+// rotation lock is on. "fullUser" honors the user's auto-rotate setting (no
+// rotation when locked) while still allowing every orientation when unlocked —
+// matching the iOS UISupportedInterfaceOrientations behavior. iOS is untouched.
+const ANDROID_SCREEN_ORIENTATION = 'fullUser'
+
+module.exports = function withAndroidRespectRotationLock(config) {
+ return withAndroidManifest(config, (cfg) => {
+ const activity = AndroidConfig.Manifest.getMainActivityOrThrow(cfg.modResults)
+ activity.$['android:screenOrientation'] = ANDROID_SCREEN_ORIENTATION
+ return cfg
+ })
+}
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 6bfe118caa0..68e5ea7c27e 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -19,13 +19,13 @@ importers:
version: 6.0.3
expo:
specifier: ^55.0.23
- version: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ version: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-build-properties:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
expo-camera:
specifier: ^55.0.18
- version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-clipboard:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -38,21 +38,30 @@ importers:
expo-dev-client:
specifier: ~55.0.35
version: 55.0.35(expo@55.0.23)
+ expo-document-picker:
+ specifier: ^55.0.13
+ version: 55.0.13(expo@55.0.23)
expo-haptics:
specifier: ^55.0.14
version: 55.0.14(expo@55.0.23)
+ expo-image-picker:
+ specifier: ^55.0.20
+ version: 55.0.20(expo@55.0.23)
expo-linking:
specifier: ^55.0.15
version: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-modules-core:
specifier: ~55.0.25
version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ expo-network:
+ specifier: ~55.0.14
+ version: 55.0.14(expo@55.0.23)(react@19.2.6)
expo-notifications:
specifier: ^55.0.22
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-router:
specifier: ^55.0.14
- version: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
+ version: 55.0.14(f081b44356743acd3a23f42461bb8a57)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
@@ -71,6 +80,9 @@ importers:
react:
specifier: ^19.2.6
version: 19.2.6
+ react-dom:
+ specifier: 19.2.6
+ version: 19.2.6(react@19.2.6)
react-native:
specifier: ^0.83.9
version: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -91,7 +103,7 @@ importers:
version: 15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-web:
specifier: ^0.21.2
- version: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ version: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-native-webview:
specifier: ^13.16.1
version: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1424,56 +1436,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1546,56 +1550,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
@@ -3347,6 +3343,11 @@ packages:
peerDependencies:
expo: '*'
+ expo-document-picker@55.0.13:
+ resolution: {integrity: sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==}
+ peerDependencies:
+ expo: '*'
+
expo-file-system@55.0.19:
resolution: {integrity: sha512-c4smCbMqELLI3YQrGpw21MwZIREXM2e53vQD/+KWQcae1q+hgw8J2TroEqcQ/jVOtFpZYVvyVfgu4HDKNEKmNw==}
peerDependencies:
@@ -3372,6 +3373,16 @@ packages:
peerDependencies:
expo: '*'
+ expo-image-loader@55.0.1:
+ resolution: {integrity: sha512-o8gCo1j59XpXDh0/llgNYPcnfecYQhafQAO0yw5pb+kukPizvNoEqea8tFQIIQmNYqxd6Ljgs7lLXed0gXpOdQ==}
+ peerDependencies:
+ expo: '*'
+
+ expo-image-picker@55.0.20:
+ resolution: {integrity: sha512-lfWt/0rPWdKz8AdDEGmGHZIJSNlVc720Dlx5bfou10FU16ZV5wAbTU63nm2jkXd8hbXke4a/2Ha1dzxCVA+LQQ==}
+ peerDependencies:
+ expo: '*'
+
expo-image@55.0.10:
resolution: {integrity: sha512-We+vq/Z8jy8zmGxcOP8vrhiWkkwyXFdSks8cSlPi0bpu6D0Ei6l9Nj2xHWCD+yoENh92aCEe1+QRujAwXbogGA==}
peerDependencies:
@@ -3421,6 +3432,12 @@ packages:
react-native-worklets:
optional: true
+ expo-network@55.0.14:
+ resolution: {integrity: sha512-Sy544zTPjVh+tbOLUOU8fBX87oRSrNQqUZY6TLO0w0WF/QTNb7yxlwRh6v6wfKKRg9xpZypTIIEtdG/s6q8ZQA==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+
expo-notifications@55.0.22:
resolution: {integrity: sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==}
peerDependencies:
@@ -4975,10 +4992,10 @@ packages:
react-devtools-core@6.1.5:
resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
- react-dom@19.2.5:
- resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
+ react-dom@19.2.6:
+ resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
- react: ^19.2.5
+ react: ^19.2.6
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
@@ -7099,7 +7116,7 @@ snapshots:
'@expo-google-fonts/material-symbols@0.4.34': {}
- '@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)':
+ '@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
'@expo/config': 55.0.16(typescript@5.9.3)
@@ -7116,7 +7133,7 @@ snapshots:
'@expo/plist': 0.5.3
'@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3)
'@expo/require-utils': 55.0.5(typescript@5.9.3)
- '@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@expo/schema-utils': 55.0.4
'@expo/spawn-async': 1.7.2
'@expo/ws-tunnel': 1.0.6
@@ -7133,7 +7150,7 @@ snapshots:
connect: 3.7.0
debug: 4.4.3
dnssd-advertise: 1.1.4
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-server: 55.0.9
fetch-nodeshim: 0.4.10
getenv: 2.0.0
@@ -7160,7 +7177,7 @@ snapshots:
ws: 8.20.1
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
+ expo-router: 55.0.14(f081b44356743acd3a23f42461bb8a57)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -7231,7 +7248,7 @@ snapshots:
'@expo/dom-webview@55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -7289,7 +7306,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
@@ -7298,7 +7315,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
@@ -7325,25 +7342,25 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
- '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
+ '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
'@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pretty-format: 29.7.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
whatwg-fetch: 3.6.20
optionalDependencies:
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -7400,7 +7417,7 @@ snapshots:
'@expo/json-file': 10.0.14
'@react-native/normalize-colors': 0.83.6
debug: 4.4.3
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
xml2js: 0.6.0
@@ -7418,18 +7435,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
debug: 4.4.3
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-server: 55.0.9
react: 19.2.6
optionalDependencies:
- '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- expo-router: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
- react-dom: 19.2.5(react@19.2.6)
+ '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ expo-router: 55.0.14(f081b44356743acd3a23f42461bb8a57)
+ react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -7695,7 +7712,7 @@ snapshots:
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -7819,14 +7836,14 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7842,23 +7859,23 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
aria-hidden: 1.2.6
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7869,15 +7886,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7887,13 +7904,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7904,45 +7921,45 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7960,18 +7977,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
+ '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -8964,7 +8981,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.29.2
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -9839,12 +9856,12 @@ snapshots:
expo-application@55.0.14(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-asset@55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.14(typescript@5.9.3)
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -9855,42 +9872,42 @@ snapshots:
expo-build-properties@55.0.13(expo@55.0.23):
dependencies:
'@expo/schema-utils': 55.0.4
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
- expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
+ expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
barcode-detector: 3.1.3(@types/emscripten@1.41.5)
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
optionalDependencies:
- react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@types/emscripten'
expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
dependencies:
'@expo/env': 2.1.2
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- supports-color
expo-crypto@55.0.14(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-client@55.0.35(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-launcher: 55.0.36(expo@55.0.23)
expo-dev-menu: 55.0.30(expo@55.0.23)
expo-dev-menu-interface: 55.0.2(expo@55.0.23)
@@ -9900,55 +9917,68 @@ snapshots:
expo-dev-launcher@55.0.36(expo@55.0.23):
dependencies:
'@expo/schema-utils': 55.0.4
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu: 55.0.30(expo@55.0.23)
expo-manifests: 55.0.17(expo@55.0.23)
expo-dev-menu-interface@55.0.2(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu@55.0.30(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu-interface: 55.0.2(expo@55.0.23)
+ expo-document-picker@55.0.13(expo@55.0.23):
+ dependencies:
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+
expo-file-system@55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-font@55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
fontfaceobserver: 2.3.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-glass-effect@55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-haptics@55.0.14(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
- expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
+ expo-image-loader@55.0.1(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+
+ expo-image-picker@55.0.20(expo@55.0.23):
+ dependencies:
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo-image-loader: 55.0.1(expo@55.0.23)
+
+ expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
+ dependencies:
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
sf-symbols-typescript: 2.2.0
optionalDependencies:
- react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
expo-json-utils@55.0.2: {}
expo-keep-awake@55.0.8(expo@55.0.23)(react@19.2.6):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
expo-linking@55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
@@ -9963,7 +9993,7 @@ snapshots:
expo-manifests@55.0.17(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.8.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
@@ -10030,12 +10060,17 @@ snapshots:
optionalDependencies:
react-native-worklets: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ expo-network@55.0.14(expo@55.0.23)(react@19.2.6):
+ dependencies:
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ react: 19.2.6
+
expo-notifications@55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.14(typescript@5.9.3)
abort-controller: 3.0.0
badgin: 1.2.3
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-application: 55.0.14(expo@55.0.23)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
react: 19.2.6
@@ -10044,23 +10079,23 @@ snapshots:
- supports-color
- typescript
- expo-router@55.0.14(6866c40ee94ad332d902aab6505cfa05):
+ expo-router@55.0.14(f081b44356743acd3a23f42461bb8a57):
dependencies:
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/schema-utils': 55.0.4
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6)
- '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
expo-glass-effect: 55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-linking: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-server: 55.0.9
expo-symbols: 55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10079,13 +10114,13 @@ snapshots:
sf-symbols-typescript: 2.2.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.2.6)
- vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.8.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
@@ -10095,14 +10130,14 @@ snapshots:
expo-secure-store@55.0.13(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-server@55.0.9: {}
expo-splash-screen@55.0.20(expo@55.0.23)(typescript@5.9.3):
dependencies:
'@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3)
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
- typescript
@@ -10116,7 +10151,7 @@ snapshots:
expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
'@expo-google-fonts/material-symbols': 0.4.34
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -10124,12 +10159,12 @@ snapshots:
expo-updates-interface@55.1.6(expo@55.0.23):
dependencies:
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
- expo@55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
+ expo@55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
- '@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ '@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/config-plugins': 55.0.8
'@expo/devtools': 55.0.3(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10155,7 +10190,7 @@ snapshots:
whatwg-url-minimum: 0.1.1
optionalDependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
+ '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-webview: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
@@ -10839,7 +10874,7 @@ snapshots:
'@jest/create-cache-key-function': 29.7.0
'@jest/globals': 29.7.0
babel-jest: 29.7.0(@babel/core@7.29.0)
- expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
+ expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
@@ -12070,7 +12105,7 @@ snapshots:
- bufferutil
- utf-8-validate
- react-dom@19.2.5(react@19.2.6):
+ react-dom@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
scheduler: 0.27.0
@@ -12129,7 +12164,7 @@ snapshots:
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
warn-once: 0.1.1
- react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6):
+ react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@react-native/normalize-colors': 0.74.89
@@ -12139,7 +12174,7 @@ snapshots:
nullthrows: 1.1.1
postcss-value-parser: 4.2.0
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
styleq: 0.1.3
transitivePeerDependencies:
- encoding
@@ -12955,11 +12990,11 @@ snapshots:
vary@1.1.2: {}
- vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6):
+ vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
- '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
+ '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
- react-dom: 19.2.5(react@19.2.6)
+ react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx
index 0ff706638b9..9d85bf434b7 100644
--- a/mobile/src/components/AccountUsage.tsx
+++ b/mobile/src/components/AccountUsage.tsx
@@ -1,76 +1,25 @@
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
-// Why: keep these shapes in lockstep with src/shared/types.ts and
-// src/shared/rate-limit-types.ts. We don't import from desktop here because
-// the mobile bundle must not pull in Electron-coupled type files.
-export type RateLimitWindow = {
- usedPercent: number
- windowMinutes: number
- resetsAt: number | null
- resetDescription: string | null
-}
-
-export type ProviderRateLimits = {
- provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
- session: RateLimitWindow | null
- weekly: RateLimitWindow | null
- monthly?: RateLimitWindow | null
- updatedAt: number
- error: string | null
- status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
-}
-
-export type InactiveAccountUsage = {
- accountId: string
- claude: ProviderRateLimits | null
- updatedAt: number
- isFetching: boolean
-}
-
-export type ClaudeAccountSummary = {
- id: string
- email: string
- organizationName?: string | null
-}
-
-export type CodexAccountSummary = {
- id: string
- email: string
- workspaceLabel?: string | null
-}
-
-export type AccountsSnapshot = {
- claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
- codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
- rateLimits: {
- claude: ProviderRateLimits | null
- codex: ProviderRateLimits | null
- inactiveClaudeAccounts: InactiveAccountUsage[]
- inactiveCodexAccounts: InactiveAccountUsage[]
- }
-}
-
-export type ProviderKey = 'claude' | 'codex'
-
-export function getActiveProviderRateLimits(
- snapshot: AccountsSnapshot,
- provider: ProviderKey
-): ProviderRateLimits | null {
- return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
-}
-
-export function getInactiveProviderUsage(
- snapshot: AccountsSnapshot,
- provider: ProviderKey,
- accountId: string
-): InactiveAccountUsage | null {
- const list =
- provider === 'claude'
- ? snapshot.rateLimits.inactiveClaudeAccounts
- : snapshot.rateLimits.inactiveCodexAccounts
- return list.find((u) => u.accountId === accountId) ?? null
-}
+// Pure types and selectors live in account-usage-state.ts (no RN imports) so
+// they are unit-testable; re-exported here so existing import sites are stable.
+export type {
+ RateLimitWindow,
+ ProviderRateLimits,
+ InactiveAccountUsage,
+ ClaudeAccountSummary,
+ CodexAccountSummary,
+ AccountsSnapshot,
+ ProviderKey,
+ UsageBarState
+} from './account-usage-state'
+export {
+ getActiveProviderRateLimits,
+ getInactiveProviderUsage,
+ getUsageBarState,
+ hasActiveProviderUsage,
+ hasRenderableUsage
+} from './account-usage-state'
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx
new file mode 100644
index 00000000000..916525ef366
--- /dev/null
+++ b/mobile/src/components/AgentStateDot.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useRef } from 'react'
+import { Animated, Easing, StyleSheet, View } from 'react-native'
+import type { AgentDotState } from '../worktree/agent-row-display'
+
+// Per-agent state indicator, 1:1 with desktop AgentStateDot
+// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
+// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
+// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
+// agent vocabulary into the 5-state rollup the sidebar dot uses.
+const DOT_COLORS: Record, string> = {
+ done: '#10b981',
+ blocked: '#ef4444',
+ waiting: '#ef4444',
+ interrupted: '#ef4444',
+ idle: 'rgba(115,115,115,0.4)'
+}
+
+export function AgentStateDot({ state }: { state: AgentDotState }) {
+ const spinValue = useRef(new Animated.Value(0)).current
+
+ useEffect(() => {
+ if (state === 'working') {
+ const animation = Animated.loop(
+ Animated.timing(spinValue, {
+ toValue: 1,
+ duration: 1000,
+ easing: Easing.linear,
+ useNativeDriver: true
+ })
+ )
+ animation.start()
+ return () => animation.stop()
+ }
+ spinValue.setValue(0)
+ return undefined
+ }, [state, spinValue])
+
+ if (state === 'working') {
+ const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrapper: { width: 10, height: 10, alignItems: 'center', justifyContent: 'center' },
+ dot: { width: 6, height: 6, borderRadius: 3 },
+ spinner: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ borderWidth: 1.5,
+ borderColor: '#eab308',
+ borderTopColor: 'transparent'
+ }
+})
diff --git a/mobile/src/components/AuthFailedBanner.tsx b/mobile/src/components/AuthFailedBanner.tsx
new file mode 100644
index 00000000000..29be8b85150
--- /dev/null
+++ b/mobile/src/components/AuthFailedBanner.tsx
@@ -0,0 +1,66 @@
+import { View, Text, Pressable, StyleSheet } from 'react-native'
+import { colors, spacing } from '../theme/mobile-theme'
+
+// Why: auth-failed is no longer necessarily terminal (issue #5200) — a
+// transient rejection can latch it even though the desktop still lists this
+// device. Offer Retry (fresh client + handshake) ahead of the disruptive
+// re-pair flow so the common transient case recovers without re-pairing.
+export function AuthFailedBanner({
+ canRetry,
+ onRetry,
+ onRepair,
+ onRemove
+}: {
+ canRetry: boolean
+ onRetry: () => void
+ onRepair: () => void
+ onRemove: () => void
+}) {
+ return (
+
+
+ Authentication failed — try reconnecting first; if it keeps failing, re-pair from desktop.
+
+
+ {canRetry && (
+
+ Retry
+
+ )}
+
+ Re-pair
+
+
+ Remove
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ banner: {
+ backgroundColor: colors.bgPanel,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ text: {
+ color: colors.statusRed,
+ fontSize: 13,
+ marginBottom: spacing.sm
+ },
+ actions: {
+ flexDirection: 'row',
+ gap: spacing.lg
+ },
+ action: {
+ paddingVertical: spacing.xs
+ },
+ actionText: {
+ color: colors.accentBlue,
+ fontSize: 13,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx
index 4dff4ca1a2d..2777e310dc8 100644
--- a/mobile/src/components/CustomKeyModal.tsx
+++ b/mobile/src/components/CustomKeyModal.tsx
@@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
onPress={onManageShortcuts}
>
Manage Shortcuts
- Show or hide default shortcut keys
+ Show, hide, or reorder shortcut keys
>
) : null}
diff --git a/mobile/src/components/DragReorderList.tsx b/mobile/src/components/DragReorderList.tsx
new file mode 100644
index 00000000000..387686f0dab
--- /dev/null
+++ b/mobile/src/components/DragReorderList.tsx
@@ -0,0 +1,349 @@
+import { useCallback, useEffect, type ReactNode } from 'react'
+import { StyleSheet, View } from 'react-native'
+import { Gesture, GestureDetector } from 'react-native-gesture-handler'
+import { GripVertical } from 'lucide-react-native'
+import Animated, {
+ measure,
+ runOnJS,
+ scrollTo,
+ useAnimatedStyle,
+ useFrameCallback,
+ useSharedValue,
+ withSpring,
+ type AnimatedRef,
+ type SharedValue
+} from 'react-native-reanimated'
+import { colors, spacing } from '../theme/mobile-theme'
+import { triggerMediumImpact, triggerSelection } from '../platform/haptics'
+import {
+ clampDragReorderIndex,
+ dragReorderPositionsFromKeys,
+ moveDragReorderKey,
+ orderedKeysFromDragReorderPositions,
+ type DragReorderPositions
+} from './drag-reorder-positions'
+
+const ROW_SPRING = { damping: 28, stiffness: 350 }
+const LONG_PRESS_ACTIVATION_MS = 200
+// Why: joins row keys into a change-detection signature; NUL cannot occur in
+// a key, so the joined string is unambiguous.
+const KEY_SEPARATOR = '\u0000'
+// Why: drags near the viewport edges scroll the outer ScrollView so rows can
+// travel further than one screen; speed ramps up the closer the finger gets.
+const AUTO_SCROLL_EDGE = 72
+const AUTO_SCROLL_MAX_SPEED = 560
+
+type DragSharedState = {
+ positions: SharedValue
+ activeKey: SharedValue
+ activeTop: SharedValue
+ dragStartTop: SharedValue
+ dragStartScrollY: SharedValue
+ dragTranslationY: SharedValue
+ dragPointerAbsY: SharedValue
+}
+
+export type DragReorderListProps = {
+ items: ItemT[]
+ itemKey: (item: ItemT) => string
+ rowHeight: number
+ renderRow: (item: ItemT) => ReactNode
+ /** Called with every item key in the new order after a drop changes it. */
+ onReorder: (orderedKeys: string[]) => void
+ /** Lets the owning screen disable its ScrollView while a row is held. */
+ onDragActiveChange?: (active: boolean) => void
+ scrollRef: AnimatedRef
+ scrollOffsetY: SharedValue
+ scrollContentHeight: SharedValue
+}
+
+export function DragReorderList({
+ items,
+ itemKey,
+ rowHeight,
+ renderRow,
+ onReorder,
+ onDragActiveChange,
+ scrollRef,
+ scrollOffsetY,
+ scrollContentHeight
+}: DragReorderListProps): React.JSX.Element {
+ const keys = items.map(itemKey)
+ const count = keys.length
+ const positions = useSharedValue(dragReorderPositionsFromKeys(keys))
+ const activeKey = useSharedValue(null)
+ const activeTop = useSharedValue(0)
+ const dragStartTop = useSharedValue(0)
+ const dragStartScrollY = useSharedValue(0)
+ const dragTranslationY = useSharedValue(0)
+ const dragPointerAbsY = useSharedValue(0)
+
+ // Why: rows can be added, removed, or reordered by the owning screen;
+ // rebuild the position map whenever the rendered key order changes.
+ const keySignature = keys.join(KEY_SEPARATOR)
+ useEffect(() => {
+ positions.value = dragReorderPositionsFromKeys(
+ keySignature ? keySignature.split(KEY_SEPARATOR) : []
+ )
+ }, [keySignature, positions])
+
+ const updateDragPosition = (key: string): void => {
+ 'worklet'
+ const rawTop =
+ dragStartTop.value + dragTranslationY.value + (scrollOffsetY.value - dragStartScrollY.value)
+ const top = Math.min(Math.max(rawTop, 0), Math.max(0, (count - 1) * rowHeight))
+ activeTop.value = top
+ const target = clampDragReorderIndex(Math.round(top / rowHeight), count)
+ if (positions.value[key] !== target) {
+ positions.value = moveDragReorderKey(positions.value, key, target)
+ runOnJS(triggerSelection)()
+ }
+ }
+
+ // Why: pan updates stop while the finger holds still at a screen edge, so a
+ // frame callback keeps scrolling (and re-slotting the row) until it moves.
+ const autoScroll = useFrameCallback((frame) => {
+ const key = activeKey.value
+ if (key === null) {
+ return
+ }
+ const viewport = measure(scrollRef)
+ if (viewport) {
+ const topEdge = viewport.pageY + AUTO_SCROLL_EDGE
+ const bottomEdge = viewport.pageY + viewport.height - AUTO_SCROLL_EDGE
+ let velocity = 0
+ if (dragPointerAbsY.value < topEdge) {
+ velocity =
+ -AUTO_SCROLL_MAX_SPEED * Math.min(1, (topEdge - dragPointerAbsY.value) / AUTO_SCROLL_EDGE)
+ } else if (dragPointerAbsY.value > bottomEdge) {
+ velocity =
+ AUTO_SCROLL_MAX_SPEED *
+ Math.min(1, (dragPointerAbsY.value - bottomEdge) / AUTO_SCROLL_EDGE)
+ }
+ if (velocity !== 0) {
+ const maxOffset = Math.max(0, scrollContentHeight.value - viewport.height)
+ const dtMs = frame.timeSincePreviousFrame ?? 16
+ const next = Math.min(
+ Math.max(scrollOffsetY.value + (velocity * dtMs) / 1000, 0),
+ maxOffset
+ )
+ if (next !== scrollOffsetY.value) {
+ scrollOffsetY.value = next
+ scrollTo(scrollRef, 0, next, false)
+ }
+ }
+ }
+ updateDragPosition(key)
+ }, false)
+
+ const setAutoScrollActive = autoScroll.setActive
+ const handleDragActiveChange = useCallback(
+ (active: boolean) => {
+ setAutoScrollActive(active)
+ onDragActiveChange?.(active)
+ },
+ [setAutoScrollActive, onDragActiveChange]
+ )
+
+ const commitReorder = useCallback(
+ (orderedKeys: string[]) => {
+ // Why: a cancelled or no-op drag should not trigger a persisted write.
+ if (orderedKeys.join(KEY_SEPARATOR) !== keySignature) {
+ onReorder(orderedKeys)
+ }
+ },
+ [onReorder, keySignature]
+ )
+
+ // Why: screen-reader users can't long-press-drag; the handle exposes
+ // move up/down accessibility actions that commit the same reorder.
+ const moveRowByAccessibilityAction = useCallback(
+ (key: string, delta: number) => {
+ const fromIndex = keys.indexOf(key)
+ if (fromIndex === -1) {
+ return
+ }
+ const toIndex = Math.min(Math.max(fromIndex + delta, 0), keys.length - 1)
+ if (toIndex === fromIndex) {
+ return
+ }
+ const next = [...keys]
+ next.splice(fromIndex, 1)
+ next.splice(toIndex, 0, key)
+ onReorder(next)
+ },
+ [keys, onReorder]
+ )
+
+ const shared: DragSharedState = {
+ positions,
+ activeKey,
+ activeTop,
+ dragStartTop,
+ dragStartScrollY,
+ dragTranslationY,
+ dragPointerAbsY
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ {renderRow(item)}
+
+ ))}
+
+ )
+}
+
+function DragReorderRow({
+ rowKey,
+ rowHeight,
+ shared,
+ scrollOffsetY,
+ updateDragPosition,
+ onDragActiveChange,
+ onCommit,
+ onAccessibilityMove,
+ children
+}: {
+ rowKey: string
+ rowHeight: number
+ shared: DragSharedState
+ scrollOffsetY: SharedValue
+ updateDragPosition: (key: string) => void
+ onDragActiveChange: (active: boolean) => void
+ onCommit: (orderedKeys: string[]) => void
+ onAccessibilityMove: (key: string, delta: number) => void
+ children: ReactNode
+}): React.JSX.Element {
+ const {
+ positions,
+ activeKey,
+ activeTop,
+ dragStartTop,
+ dragStartScrollY,
+ dragTranslationY,
+ dragPointerAbsY
+ } = shared
+
+ const pan = Gesture.Pan()
+ .activateAfterLongPress(LONG_PRESS_ACTIVATION_MS)
+ .shouldCancelWhenOutside(false)
+ .onStart((event) => {
+ const index = positions.value[rowKey] ?? 0
+ dragStartTop.value = index * rowHeight
+ dragStartScrollY.value = scrollOffsetY.value
+ dragTranslationY.value = 0
+ dragPointerAbsY.value = event.absoluteY
+ activeTop.value = dragStartTop.value
+ activeKey.value = rowKey
+ runOnJS(onDragActiveChange)(true)
+ runOnJS(triggerMediumImpact)()
+ })
+ .onUpdate((event) => {
+ dragTranslationY.value = event.translationY
+ dragPointerAbsY.value = event.absoluteY
+ updateDragPosition(rowKey)
+ })
+ .onFinalize(() => {
+ if (activeKey.value !== rowKey) {
+ return
+ }
+ activeKey.value = null
+ const orderedKeys = orderedKeysFromDragReorderPositions(positions.value)
+ runOnJS(onCommit)(orderedKeys)
+ runOnJS(onDragActiveChange)(false)
+ })
+
+ const rowStyle = useAnimatedStyle(() => {
+ const index = positions.value[rowKey] ?? 0
+ if (activeKey.value === rowKey) {
+ return {
+ top: activeTop.value,
+ zIndex: 2,
+ elevation: 4,
+ shadowOpacity: 0.3,
+ backgroundColor: colors.bgRaised,
+ transform: [{ scale: 1.02 }]
+ }
+ }
+ return {
+ top: withSpring(index * rowHeight, ROW_SPRING),
+ zIndex: 0,
+ elevation: 0,
+ shadowOpacity: 0,
+ backgroundColor: colors.bgPanel,
+ transform: [{ scale: 1 }]
+ }
+ })
+
+ return (
+
+ {children}
+
+ {
+ if (event.nativeEvent.actionName === 'moveUp') {
+ onAccessibilityMove(rowKey, -1)
+ } else if (event.nativeEvent.actionName === 'moveDown') {
+ onAccessibilityMove(rowKey, 1)
+ }
+ }}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ row: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ flexDirection: 'row',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowRadius: 8
+ },
+ rowContent: {
+ flex: 1
+ },
+ handle: {
+ alignSelf: 'stretch',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.md
+ },
+ rowSeparator: {
+ position: 'absolute',
+ bottom: 0,
+ left: spacing.md,
+ right: spacing.md,
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle
+ }
+})
diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx
index 74b6658f054..ade35feda45 100644
--- a/mobile/src/components/MobileAgentIcon.tsx
+++ b/mobile/src/components/MobileAgentIcon.tsx
@@ -79,7 +79,7 @@ function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number
}
export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
- if (agentId === 'claude') {
+ if (agentId === 'claude' || agentId === 'claude-agent-teams') {
return
}
if (agentId === 'codex') {
diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx
new file mode 100644
index 00000000000..f92a417ca9f
--- /dev/null
+++ b/mobile/src/components/MobileDictationSetupSheet.tsx
@@ -0,0 +1,290 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ View
+} from 'react-native'
+import { Check, Download } from 'lucide-react-native'
+import { BottomDrawer } from './BottomDrawer'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import type { RpcClient } from '../transport/rpc-client'
+import { triggerError, triggerSuccess } from '../platform/haptics'
+import {
+ downloadDictationModel,
+ fetchDictationSetup,
+ isModelInFlight,
+ setDictationConfig,
+ type MobileSpeechModel,
+ type MobileSpeechSetup
+} from '../dictation/mobile-dictation-setup'
+
+const POLL_INTERVAL_MS = 1500
+
+type Props = {
+ visible: boolean
+ client: RpcClient | null
+ onClose: () => void
+ // Called after the user reaches a ready+enabled state, so the caller can retry.
+ onReady?: () => void
+}
+
+function formatSize(bytes: number | null): string {
+ if (!bytes) {
+ return ''
+ }
+ return `${Math.round(bytes / 1_000_000)} MB`
+}
+
+// Lets the user enable dictation and download a speech model on the paired
+// desktop, from the phone. Polls while a download is in flight.
+export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: Props) {
+ const [setup, setSetup] = useState(null)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(null)
+ const pollRef = useRef | null>(null)
+
+ const refresh = useCallback(async () => {
+ if (!client) {
+ return
+ }
+ try {
+ setSetup(await fetchDictationSetup(client))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load')
+ }
+ }, [client])
+
+ useEffect(() => {
+ if (visible) {
+ setError(null)
+ void refresh()
+ }
+ }, [visible, refresh])
+
+ // Poll only while something is downloading/extracting; stop otherwise.
+ useEffect(() => {
+ const inFlight = setup?.models.some(isModelInFlight) ?? false
+ if (visible && inFlight && client) {
+ pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
+ return () => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current)
+ pollRef.current = null
+ }
+ }
+ }
+ return undefined
+ }, [visible, setup, client, refresh])
+
+ const handleDownload = useCallback(
+ async (model: MobileSpeechModel) => {
+ if (!client) {
+ return
+ }
+ setBusy(model.id)
+ setError(null)
+ try {
+ await downloadDictationModel(client, model.id)
+ await refresh()
+ } catch (err) {
+ triggerError()
+ setError(err instanceof Error ? err.message : 'Download failed')
+ } finally {
+ setBusy(null)
+ }
+ },
+ [client, refresh]
+ )
+
+ const handleUseModel = useCallback(
+ async (model: MobileSpeechModel) => {
+ if (!client) {
+ return
+ }
+ setBusy(model.id)
+ setError(null)
+ try {
+ const next = await setDictationConfig(client, { enabled: true, modelId: model.id })
+ setSetup(next)
+ triggerSuccess()
+ onReady?.()
+ } catch (err) {
+ triggerError()
+ setError(err instanceof Error ? err.message : 'Could not select model')
+ } finally {
+ setBusy(null)
+ }
+ },
+ [client, onReady]
+ )
+
+ const handleToggleEnabled = useCallback(
+ async (enabled: boolean) => {
+ if (!client) {
+ return
+ }
+ setError(null)
+ try {
+ setSetup(await setDictationConfig(client, { enabled }))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not update')
+ }
+ },
+ [client]
+ )
+
+ return (
+
+
+ Set up voice dictation
+
+ Download a model and enable dictation on your desktop — all from here.
+
+
+ {setup === null ? (
+
+
+
+ ) : (
+ <>
+
+ Dictation enabled
+ void handleToggleEnabled(v)} />
+
+
+ {setup.models.map((model) => {
+ const isSelected = model.id === setup.selectedModelId
+ const inFlight = isModelInFlight(model)
+ const rowBusy = busy === model.id
+ return (
+
+
+
+ {model.label}
+ {model.recommended ? (
+ Recommended
+ ) : null}
+
+
+ {model.provider === 'openai' ? 'OpenAI API' : formatSize(model.sizeBytes)}
+ {inFlight && model.progress != null
+ ? ` · ${Math.round(model.progress * 100)}%`
+ : model.status === 'extracting'
+ ? ' · extracting…'
+ : ''}
+
+
+ {model.provider === 'openai' ? (
+
+ {model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
+
+ ) : model.status === 'ready' ? (
+ isSelected ? (
+
+
+ In use
+
+ ) : (
+ [
+ styles.actionButton,
+ pressed && styles.actionPressed
+ ]}
+ disabled={rowBusy}
+ onPress={() => void handleUseModel(model)}
+ >
+ Use
+
+ )
+ ) : inFlight ? (
+
+ ) : (
+ [
+ styles.actionButton,
+ pressed && styles.actionPressed
+ ]}
+ disabled={rowBusy}
+ onPress={() => void handleDownload(model)}
+ >
+ {rowBusy ? (
+
+ ) : (
+ <>
+
+ Download
+ >
+ )}
+
+ )}
+
+ )
+ })}
+ >
+ )}
+ {error ? {error} : null}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ scroll: { maxHeight: 460 },
+ heading: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ marginTop: spacing.xs,
+ marginBottom: spacing.md
+ },
+ loading: { paddingVertical: spacing.xl, alignItems: 'center' },
+ enableRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle,
+ marginBottom: spacing.sm
+ },
+ enableLabel: { color: colors.textPrimary, fontSize: typography.bodySize },
+ modelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.md,
+ paddingVertical: spacing.sm
+ },
+ modelInfo: { flex: 1, minWidth: 0 },
+ modelTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ modelLabel: { color: colors.textPrimary, fontSize: typography.bodySize },
+ recommended: {
+ color: colors.statusGreen,
+ fontSize: 10,
+ fontWeight: '700'
+ },
+ modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 },
+ modelStateText: { color: colors.textMuted, fontSize: typography.metaSize },
+ actionButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 6,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ actionPressed: { opacity: 0.7 },
+ actionText: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
+ selectedTag: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ selectedText: { color: colors.statusGreen, fontSize: typography.metaSize, fontWeight: '600' },
+ error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
+})
diff --git a/mobile/src/components/MobileDiffReviewBody.tsx b/mobile/src/components/MobileDiffReviewBody.tsx
new file mode 100644
index 00000000000..76555aeadc6
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewBody.tsx
@@ -0,0 +1,162 @@
+import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native'
+import { RefreshCw } from 'lucide-react-native'
+import type { RefObject } from 'react'
+import type { DiffComment } from '../../../src/shared/types'
+import { colors } from '../theme/mobile-theme'
+import { MobileDiffReviewLine } from './MobileDiffReviewLine'
+import type {
+ ReviewDiffLine,
+ ReviewDiffState,
+ ReviewScreenState
+} from '../session/mobile-diff-review-screen-model'
+import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ activeHunkIndex: number | null
+ commentsByLine: ReadonlyMap
+ currentItem: MobileDiffReviewQueueItem | null
+ diffState: ReviewDiffState
+ filteredCount: number
+ listRef: RefObject | null>
+ screenState: ReviewScreenState
+ staleCommentIds: ReadonlySet
+ onAddNote: (lineNumber: number) => void
+ onEditNote: (comment: DiffComment) => void
+ onRetry: () => void
+}
+
+export function MobileDiffReviewBody({
+ activeHunkIndex,
+ commentsByLine,
+ currentItem,
+ diffState,
+ filteredCount,
+ listRef,
+ screenState,
+ staleCommentIds,
+ onAddNote,
+ onEditNote,
+ onRetry
+}: Props) {
+ if (screenState.kind === 'loading') {
+ return
+ }
+ if (screenState.kind === 'error' || screenState.kind === 'unavailable') {
+ return (
+
+ )
+ }
+ if (filteredCount === 0) {
+ return
+ }
+ if (diffState.kind === 'loading') {
+ return
+ }
+ if (diffState.kind !== 'ready') {
+ return
+ }
+ return (
+ `${currentItem?.key ?? 'diff'}:${index}`}
+ renderItem={({ item, index }) => {
+ const lineNumber = item.newLineNumber ?? -1
+ const active =
+ activeHunkIndex !== null &&
+ index >= (diffState.hunks[activeHunkIndex]?.startIndex ?? -1) &&
+ index <= (diffState.hunks[activeHunkIndex]?.endIndex ?? -1)
+ return (
+
+ )
+ }}
+ contentContainerStyle={styles.diffList}
+ onScrollToIndexFailed={(info) => {
+ listRef.current?.scrollToOffset({
+ offset: Math.max(0, info.averageItemLength * info.index),
+ animated: true
+ })
+ }}
+ ListFooterComponent={
+ diffState.truncated ? (
+ Diff truncated for mobile preview.
+ ) : null
+ }
+ />
+ )
+}
+
+function DiffUnavailableState({
+ diffState,
+ onRetry
+}: {
+ diffState: ReviewDiffState
+ onRetry: () => void
+}) {
+ const title =
+ diffState.kind === 'binary'
+ ? 'Binary Diff'
+ : diffState.kind === 'too-large'
+ ? 'Diff Too Large'
+ : diffState.kind === 'deleted'
+ ? 'Deleted File'
+ : 'Diff Unavailable'
+ const text =
+ diffState.kind === 'binary'
+ ? 'This file cannot be rendered as text on mobile.'
+ : diffState.kind === 'too-large'
+ ? 'This diff is too large for the mobile preview.'
+ : diffState.kind === 'deleted'
+ ? 'This file was deleted. Add a file note or mark it reviewed.'
+ : diffState.kind === 'error'
+ ? diffState.message
+ : 'Select a file to review.'
+ return
+}
+
+function CenteredState({
+ busy,
+ muted,
+ title,
+ text,
+ onRetry
+}: {
+ busy?: boolean
+ muted?: boolean
+ title?: string
+ text: string
+ onRetry?: () => void
+}) {
+ return (
+
+ {busy ? (
+
+ ) : null}
+ {title ? {title} : null}
+ {text}
+ {onRetry ? (
+ [styles.retryButton, pressed && styles.buttonPressed]}
+ onPress={onRetry}
+ accessibilityRole="button"
+ accessibilityLabel="Retry loading review"
+ >
+
+ Retry
+
+ ) : null}
+
+ )
+}
diff --git a/mobile/src/components/MobileDiffReviewDrawers.tsx b/mobile/src/components/MobileDiffReviewDrawers.tsx
new file mode 100644
index 00000000000..c6e05b1cd0a
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewDrawers.tsx
@@ -0,0 +1,294 @@
+import { useMemo } from 'react'
+import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'
+import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
+import type { DiffComment } from '../../../src/shared/types'
+import { colors } from '../theme/mobile-theme'
+import type { ActionSheetAction } from './ActionSheetModal'
+import { ActionSheetModal } from './ActionSheetModal'
+import { BottomDrawer } from './BottomDrawer'
+import { ConfirmModal } from './ConfirmModal'
+import { mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
+import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ controller: ReturnType
+}
+
+export function MobileDiffReviewDrawers({ controller }: Props) {
+ const sendActions = useSendActions(controller)
+ const overflowActions = useOverflowActions(controller)
+ return (
+ <>
+ 0
+ ? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
+ : undefined
+ }
+ actions={overflowActions}
+ onClose={() => controller.setShowOverflow(false)}
+ />
+ controller.setSendSheet(null)}
+ />
+ {
+ const target = controller.discardTarget
+ controller.setDiscardTarget(null)
+ if (target) {
+ void controller.runGitMutation('git.discard', target)
+ }
+ }}
+ onCancel={() => controller.setDiscardTarget(null)}
+ />
+
+
+ >
+ )
+}
+
+function useSendActions(controller: ReturnType) {
+ return useMemo(() => {
+ const comments = controller.unsentComments
+ const terminalActions =
+ controller.sendSheet?.kind === 'ready' || controller.sendSheet?.kind === 'error'
+ ? controller.sendSheet.terminals.map((terminal) => ({
+ label: `${terminal.title || 'Terminal'} (${terminal.terminal.slice(0, 6)})`,
+ icon: Send,
+ disabled: comments.length === 0,
+ skipAutoClose: true,
+ onPress: () => void controller.sendPromptToTerminal(terminal.terminal, comments)
+ }))
+ : []
+ return [
+ ...terminalActions,
+ {
+ label: 'New Agent Session',
+ icon: Plus,
+ disabled: comments.length === 0,
+ skipAutoClose: true,
+ onPress: () => void controller.createTerminalAndSend(comments)
+ },
+ {
+ label: 'Copy Notes',
+ icon: Copy,
+ disabled:
+ controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
+ onPress: () => void controller.copyNotes()
+ }
+ ]
+ }, [controller])
+}
+
+function useOverflowActions(controller: ReturnType) {
+ return useMemo(
+ () => [
+ {
+ label: 'Copy Notes',
+ icon: Copy,
+ disabled:
+ controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
+ onPress: () => void controller.copyNotes()
+ },
+ {
+ label: 'Send Unsent Notes',
+ icon: Send,
+ disabled: controller.unsentComments.length === 0,
+ skipAutoClose: true,
+ onPress: () => void controller.openSendSheet()
+ },
+ {
+ label: 'Clear Sent Notes',
+ icon: Trash2,
+ disabled:
+ controller.screenState.kind !== 'ready' ||
+ controller.screenState.comments.every((comment) => comment.sentAt === undefined),
+ skipAutoClose: true,
+ onPress: () => void controller.clearSentNotes()
+ },
+ {
+ label: 'Stage Reviewed Files',
+ icon: Check,
+ disabled: controller.reviewedUnstagedCount === 0 || controller.busyAction !== null,
+ skipAutoClose: true,
+ onPress: () => void controller.stageReviewedFiles()
+ },
+ {
+ label: 'Mark Unreviewed',
+ icon: X,
+ disabled:
+ controller.screenState.kind !== 'ready' ||
+ !controller.currentItem ||
+ !controller.currentItem.isReviewed,
+ skipAutoClose: true,
+ onPress: () => void controller.markUnreviewed()
+ },
+ {
+ label: 'Open in Session',
+ icon: FileText,
+ disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
+ onPress: () => void controller.openInSession()
+ }
+ ],
+ [controller]
+ )
+}
+
+function sendSheetMessage(
+ controller: ReturnType
+): string | undefined {
+ return controller.sendSheet?.kind === 'loading'
+ ? 'Loading agent sessions...'
+ : controller.sendSheet?.kind === 'error'
+ ? controller.sendSheet.message
+ : `${controller.unsentComments.length} unsent notes`
+}
+
+function NoteComposerDrawer({ controller }: Props) {
+ const composer = controller.composer
+ return (
+
+
+
+
+
+ {composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'}
+
+
+ {composer?.mode === 'create' && composer.lineNumber > 0
+ ? `Line ${composer.lineNumber}`
+ : 'File note'}
+
+
+ [styles.iconButton, pressed && styles.iconButtonPressed]}
+ onPress={controller.closeComposer}
+ accessibilityRole="button"
+ accessibilityLabel="Cancel note"
+ >
+
+
+
+
+
+ {composer?.mode === 'edit' ? (
+
+ ) : null}
+
+
+
+
+ )
+}
+
+function composerLabel(
+ composer: { mode: 'create'; lineNumber: number } | { mode: 'edit'; comment: DiffComment } | null
+): string {
+ return composer?.mode === 'create' && composer.lineNumber > 0
+ ? `Save note on line ${composer.lineNumber}`
+ : 'Review note'
+}
+
+function DeleteNoteButton({ onPress }: { onPress: () => Promise }) {
+ return (
+ [styles.secondaryButton, pressed && styles.buttonPressed]}
+ onPress={() => void onPress()}
+ accessibilityRole="button"
+ accessibilityLabel="Delete note"
+ >
+
+ Delete
+
+ )
+}
+
+function SaveNoteButton({
+ controller,
+ composer
+}: {
+ controller: ReturnType
+ composer: ReturnType['composer']
+}) {
+ const disabled = controller.composerBody.trim().length === 0
+ return (
+ [
+ styles.primaryButton,
+ disabled && styles.buttonDisabled,
+ pressed && styles.buttonPressed
+ ]}
+ disabled={disabled}
+ onPress={() => void controller.saveComposer()}
+ accessibilityRole="button"
+ accessibilityLabel={composerLabel(composer)}
+ >
+
+ Save
+
+ )
+}
+
+function CompletionDrawer({ controller }: Props) {
+ const noteCount =
+ controller.screenState.kind === 'ready' ? controller.screenState.comments.length : 0
+ return (
+ controller.setShowCompletion(false)}
+ >
+ Review Complete
+
+ {mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '}
+ {mobileReviewCountLabel(noteCount, 'note', 'notes')}
+
+
+ [styles.secondaryButton, pressed && styles.buttonPressed]}
+ disabled={controller.reviewedUnstagedCount === 0}
+ onPress={() => void controller.stageReviewedFiles()}
+ accessibilityRole="button"
+ accessibilityLabel="Stage reviewed files"
+ >
+
+ Stage Reviewed
+
+ [styles.primaryButton, pressed && styles.buttonPressed]}
+ disabled={controller.unsentComments.length === 0}
+ onPress={() => void controller.openSendSheet()}
+ accessibilityRole="button"
+ accessibilityLabel="Send notes to agent"
+ >
+
+ Send Notes
+
+
+
+ )
+}
diff --git a/mobile/src/components/MobileDiffReviewFileSummary.tsx b/mobile/src/components/MobileDiffReviewFileSummary.tsx
new file mode 100644
index 00000000000..a27910f4d6f
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewFileSummary.tsx
@@ -0,0 +1,129 @@
+import { Pressable, Text, View } from 'react-native'
+import { ArrowDown, ArrowUp } from 'lucide-react-native'
+import type { DiffComment } from '../../../src/shared/types'
+import { colors } from '../theme/mobile-theme'
+import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
+import { MOBILE_GIT_STATUS_LABELS } from '../source-control/mobile-git-status'
+import {
+ mobileReviewCountLabel,
+ mobileReviewScopeLabel,
+ type ReviewDiffState
+} from '../session/mobile-diff-review-screen-model'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ currentIndex: number
+ diffState: ReviewDiffState
+ fileNotes: DiffComment[]
+ filteredCount: number
+ item: MobileDiffReviewQueueItem
+ staleCommentIds: ReadonlySet
+ onEditNote: (comment: DiffComment) => void
+ onJumpHunk: (direction: 'next' | 'previous') => void
+}
+
+function statusColor(status: MobileDiffReviewQueueItem['status']): string {
+ switch (status) {
+ case 'added':
+ case 'copied':
+ return colors.statusGreen
+ case 'deleted':
+ return colors.statusRed
+ case 'renamed':
+ return colors.accentBlue
+ case 'untracked':
+ return colors.statusAmber
+ case 'modified':
+ default:
+ return colors.textSecondary
+ }
+}
+
+export function MobileDiffReviewFileSummary({
+ currentIndex,
+ diffState,
+ fileNotes,
+ filteredCount,
+ item,
+ staleCommentIds,
+ onEditNote,
+ onJumpHunk
+}: Props) {
+ const hunkDisabled = diffState.kind !== 'ready' || diffState.hunks.length === 0
+ const badgeColor = statusColor(item.status)
+ return (
+
+
+
+
+ {MOBILE_GIT_STATUS_LABELS[item.status]}
+
+
+
+
+ {item.filePath}
+
+
+ {mobileReviewScopeLabel(item)}
+ {item.oldPath ? ` from ${item.oldPath}` : ''}
+
+
+
+
+
+ {currentIndex + 1}/{filteredCount}
+
+ {item.isReviewed ? Reviewed : null}
+ {item.changedSinceReview ? Changed : null}
+ {item.noteCount > 0 ? (
+
+ {mobileReviewCountLabel(item.noteCount, 'note', 'notes')}
+
+ ) : null}
+ {item.staleNoteCount > 0 ? (
+ {item.staleNoteCount} stale
+ ) : null}
+
+ {fileNotes.length > 0 ? (
+
+ {fileNotes.map((note) => (
+ [styles.fileNote, pressed && styles.fileNotePressed]}
+ onPress={() => onEditNote(note)}
+ accessibilityRole="button"
+ accessibilityLabel="Edit file note"
+ >
+
+ {note.body}
+
+ {staleCommentIds.has(note.id) ? Stale : null}
+
+ ))}
+
+ ) : null}
+
+ [styles.hunkButton, pressed && styles.hunkButtonPressed]}
+ disabled={hunkDisabled}
+ onPress={() => onJumpHunk('previous')}
+ accessibilityRole="button"
+ accessibilityLabel="Previous hunk"
+ >
+
+ Hunk
+
+ [styles.hunkButton, pressed && styles.hunkButtonPressed]}
+ disabled={hunkDisabled}
+ onPress={() => onJumpHunk('next')}
+ accessibilityRole="button"
+ accessibilityLabel="Next hunk"
+ >
+
+ Hunk
+
+
+
+ )
+}
diff --git a/mobile/src/components/MobileDiffReviewFooter.tsx b/mobile/src/components/MobileDiffReviewFooter.tsx
new file mode 100644
index 00000000000..a5ba9583de2
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewFooter.tsx
@@ -0,0 +1,121 @@
+import { Pressable, Text, View } from 'react-native'
+import {
+ Check,
+ ChevronLeft,
+ ChevronRight,
+ FileText,
+ Plus,
+ Trash2,
+ Undo2
+} from 'lucide-react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
+import type { GitMutationMethod } from '../session/mobile-diff-review-screen-model'
+import { colors, spacing } from '../theme/mobile-theme'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ busyAction: string | null
+ item: MobileDiffReviewQueueItem
+ onAddFileNote: () => void
+ onDiscard: (item: MobileDiffReviewQueueItem) => void
+ onGitMutation: (method: GitMutationMethod, item: MobileDiffReviewQueueItem) => void
+ onMarkReviewed: () => void
+ onMoveFile: (direction: 'next' | 'previous') => void
+}
+
+export function MobileDiffReviewFooter({
+ busyAction,
+ item,
+ onAddFileNote,
+ onDiscard,
+ onGitMutation,
+ onMarkReviewed,
+ onMoveFile
+}: Props) {
+ const insets = useSafeAreaInsets()
+ return (
+
+
+ {item.canStage ? (
+ [styles.secondaryButton, pressed && styles.buttonPressed]}
+ disabled={busyAction !== null}
+ onPress={() => onGitMutation('git.stage', item)}
+ accessibilityRole="button"
+ accessibilityLabel="Stage file"
+ >
+
+ Stage
+
+ ) : null}
+ {item.canUnstage ? (
+ [styles.secondaryButton, pressed && styles.buttonPressed]}
+ disabled={busyAction !== null}
+ onPress={() => onGitMutation('git.unstage', item)}
+ accessibilityRole="button"
+ accessibilityLabel="Unstage file"
+ >
+
+ Unstage
+
+ ) : null}
+ {item.canDiscard ? (
+ [styles.secondaryButton, pressed && styles.buttonPressed]}
+ disabled={busyAction !== null}
+ onPress={() => onDiscard(item)}
+ accessibilityRole="button"
+ accessibilityLabel="Discard file"
+ >
+
+ Discard
+
+ ) : null}
+
+
+ [styles.navButton, pressed && styles.buttonPressed]}
+ onPress={() => onMoveFile('previous')}
+ accessibilityRole="button"
+ accessibilityLabel="Previous file"
+ >
+
+
+ [styles.footerButton, pressed && styles.buttonPressed]}
+ onPress={onAddFileNote}
+ accessibilityRole="button"
+ accessibilityLabel="Add file note"
+ >
+
+ Note
+
+ [
+ styles.primaryButton,
+ item.isReviewed && styles.primaryButtonDone,
+ pressed && styles.buttonPressed
+ ]}
+ onPress={onMarkReviewed}
+ accessibilityRole="button"
+ accessibilityLabel="Mark file reviewed"
+ >
+
+
+ {item.isReviewed ? 'Reviewed' : 'Mark Reviewed'}
+
+
+ [styles.navButton, pressed && styles.buttonPressed]}
+ onPress={() => onMoveFile('next')}
+ accessibilityRole="button"
+ accessibilityLabel="Next file"
+ >
+
+
+
+
+ )
+}
diff --git a/mobile/src/components/MobileDiffReviewHeader.tsx b/mobile/src/components/MobileDiffReviewHeader.tsx
new file mode 100644
index 00000000000..42e48ea6f6c
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewHeader.tsx
@@ -0,0 +1,91 @@
+import { FlatList, Pressable, Text, View } from 'react-native'
+import { ChevronLeft, MoreHorizontal } from 'lucide-react-native'
+import { colors } from '../theme/mobile-theme'
+import type { MobileDiffReviewQueueFilter } from '../session/mobile-diff-review-queue'
+import { REVIEW_FILTERS, mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ filter: MobileDiffReviewQueueFilter
+ queueLength: number
+ reviewedCount: number
+ unsentCount: number
+ worktreeLabel: string
+ onBack: () => void
+ onOpenActions: () => void
+ onSelectFilter: (filter: MobileDiffReviewQueueFilter) => void
+}
+
+export function MobileDiffReviewHeader({
+ filter,
+ queueLength,
+ reviewedCount,
+ unsentCount,
+ worktreeLabel,
+ onBack,
+ onOpenActions,
+ onSelectFilter
+}: Props) {
+ return (
+
+
+ [styles.iconButton, pressed && styles.iconButtonPressed]}
+ onPress={onBack}
+ accessibilityRole="button"
+ accessibilityLabel="Back"
+ >
+
+
+
+
+ Review Changes
+
+
+ {worktreeLabel}
+
+
+ [styles.iconButton, pressed && styles.iconButtonPressed]}
+ onPress={onOpenActions}
+ accessibilityRole="button"
+ accessibilityLabel="Open review actions"
+ >
+
+
+
+
+
+ {reviewedCount}/{queueLength} reviewed
+
+
+ {mobileReviewCountLabel(unsentCount, 'unsent note', 'unsent notes')}
+
+
+ item}
+ contentContainerStyle={styles.filterRow}
+ renderItem={({ item }) => (
+ [
+ styles.filterChip,
+ filter === item && styles.filterChipActive,
+ pressed && styles.filterChipPressed
+ ]}
+ onPress={() => onSelectFilter(item)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: filter === item }}
+ accessibilityLabel={`Show ${item} review files`}
+ >
+
+ {item === 'all' ? 'All' : item[0]?.toUpperCase() + item.slice(1)}
+
+
+ )}
+ />
+
+ )
+}
diff --git a/mobile/src/components/MobileDiffReviewLine.tsx b/mobile/src/components/MobileDiffReviewLine.tsx
new file mode 100644
index 00000000000..74ed24c10db
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewLine.tsx
@@ -0,0 +1,160 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { MessageSquare } from 'lucide-react-native'
+import type { DiffComment } from '../../../src/shared/types'
+import type { MobileDiffLine } from '../session/mobile-diff-lines'
+import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
+import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import { MobileSyntaxSegments } from './MobileSyntaxSegments'
+
+type Props = {
+ line: MobileHighlightedDiffLine
+ comments: readonly DiffComment[]
+ staleCommentIds: ReadonlySet
+ active: boolean
+ onAddNote: (lineNumber: number) => void
+ onEditNote: (comment: DiffComment) => void
+}
+
+function accessibilityLabelForLine(line: MobileDiffLine): string {
+ const number = mobileDiffLineNumber(line)
+ const label = line.kind === 'add' ? 'Added' : line.kind === 'delete' ? 'Deleted' : 'Context'
+ return number ? `${label} line ${number}` : `${label} line`
+}
+
+function canCommentOnLine(line: MobileDiffLine): boolean {
+ return line.kind !== 'delete' && line.newLineNumber !== undefined
+}
+
+export function MobileDiffReviewLine({
+ line,
+ comments,
+ staleCommentIds,
+ active,
+ onAddNote,
+ onEditNote
+}: Props) {
+ const lineNumber = mobileDiffLineNumber(line)
+ const canComment = canCommentOnLine(line)
+
+ return (
+
+ {mobileDiffLinePrefix(line.kind)}
+ {lineNumber ? String(lineNumber) : ''}
+ [styles.code, pressed && canComment && styles.codePressed]}
+ disabled={!canComment}
+ onPress={() => {
+ if (canComment && line.newLineNumber !== undefined) {
+ onAddNote(line.newLineNumber)
+ }
+ }}
+ accessibilityRole={canComment ? 'button' : 'text'}
+ accessibilityLabel={
+ canComment && line.newLineNumber !== undefined
+ ? `Add note on line ${line.newLineNumber}`
+ : accessibilityLabelForLine(line)
+ }
+ >
+
+
+
+
+ {comments.length > 0 ? (
+
+ {comments.map((comment) => (
+ [styles.noteButton, pressed && styles.noteButtonPressed]}
+ onPress={() => onEditNote(comment)}
+ accessibilityRole="button"
+ accessibilityLabel={`Edit note on line ${comment.lineNumber}`}
+ >
+
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ row: {
+ minHeight: 32,
+ flexDirection: 'row',
+ alignItems: 'stretch',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ addedRow: {
+ backgroundColor: colors.diffAddedBg
+ },
+ deletedRow: {
+ backgroundColor: colors.diffDeletedBg
+ },
+ activeRow: {
+ borderLeftWidth: 2,
+ borderLeftColor: colors.accentBlue
+ },
+ prefix: {
+ width: 18,
+ paddingTop: spacing.sm,
+ textAlign: 'center',
+ color: colors.textMuted,
+ fontFamily: typography.monoFamily,
+ fontSize: typography.metaSize
+ },
+ lineNumber: {
+ width: 44,
+ paddingTop: spacing.sm,
+ paddingRight: spacing.xs,
+ textAlign: 'right',
+ color: colors.textMuted,
+ fontFamily: typography.monoFamily,
+ fontSize: typography.metaSize
+ },
+ code: {
+ flex: 1,
+ minWidth: 0,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.sm
+ },
+ codePressed: {
+ backgroundColor: colors.bgRaised
+ },
+ codeText: {
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 12,
+ lineHeight: 18
+ },
+ notes: {
+ width: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 2
+ },
+ noteButton: {
+ minWidth: 32,
+ minHeight: 28,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ noteButtonPressed: {
+ opacity: 0.72
+ }
+})
diff --git a/mobile/src/components/MobileDiffReviewScreenView.tsx b/mobile/src/components/MobileDiffReviewScreenView.tsx
new file mode 100644
index 00000000000..33af6b35f0d
--- /dev/null
+++ b/mobile/src/components/MobileDiffReviewScreenView.tsx
@@ -0,0 +1,73 @@
+import { SafeAreaView } from 'react-native-safe-area-context'
+import { Text, View } from 'react-native'
+import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
+import { MobileDiffReviewBody } from './MobileDiffReviewBody'
+import { MobileDiffReviewDrawers } from './MobileDiffReviewDrawers'
+import { MobileDiffReviewFileSummary } from './MobileDiffReviewFileSummary'
+import { MobileDiffReviewFooter } from './MobileDiffReviewFooter'
+import { MobileDiffReviewHeader } from './MobileDiffReviewHeader'
+import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
+
+type Props = {
+ controller: ReturnType
+ onBack: () => void
+}
+
+export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
+ return (
+
+ controller.setShowOverflow(true)}
+ onSelectFilter={controller.selectFilter}
+ />
+ {controller.currentItem ? (
+
+ ) : null}
+ {controller.actionError ? (
+
+ {controller.actionError}
+
+ ) : null}
+
+ {controller.currentItem ? (
+ controller.openComposer(0)}
+ onDiscard={controller.setDiscardTarget}
+ onGitMutation={(method, item) => void controller.runGitMutation(method, item)}
+ onMarkReviewed={() => void controller.markReviewed()}
+ onMoveFile={controller.moveFile}
+ />
+ ) : null}
+
+
+ )
+}
diff --git a/mobile/src/components/MobileHtmlPreview.tsx b/mobile/src/components/MobileHtmlPreview.tsx
new file mode 100644
index 00000000000..335d2600ff9
--- /dev/null
+++ b/mobile/src/components/MobileHtmlPreview.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react'
+import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
+import { WebView } from 'react-native-webview'
+import { Code, Eye } from 'lucide-react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+type Props = {
+ html: string
+ // Rendered when the user flips to "Source" (the existing syntax view).
+ renderSource: () => React.ReactNode
+}
+
+// Renders an agent-produced HTML artifact in a sandboxed WebView, with a
+// Preview/Source toggle. Navigation is locked: only the initial inline document
+// loads in-place; any link tap opens externally so a page can't hijack the
+// review surface.
+export function MobileHtmlPreview({ html, renderSource }: Props) {
+ const [mode, setMode] = useState<'preview' | 'source'>('preview')
+
+ return (
+
+
+ setMode('preview')}
+ accessibilityLabel="Preview rendered HTML"
+ >
+
+ Preview
+
+ setMode('source')}
+ accessibilityLabel="View HTML source"
+ >
+
+ Source
+
+
+ {mode === 'preview' ? (
+ {
+ if (request.url === 'about:blank' || request.url.startsWith('data:')) {
+ return true
+ }
+ void Linking.openURL(request.url).catch(() => {})
+ return false
+ }}
+ />
+ ) : (
+ renderSource()
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ toolbar: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ toggle: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 4,
+ borderRadius: 6,
+ backgroundColor: colors.bgRaised
+ },
+ toggleActive: {
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ toggleText: { color: colors.textSecondary, fontSize: typography.metaSize },
+ webview: { flex: 1, backgroundColor: '#ffffff' }
+})
diff --git a/mobile/src/components/MobilePrComposeSheet.tsx b/mobile/src/components/MobilePrComposeSheet.tsx
new file mode 100644
index 00000000000..46b2359ba4f
--- /dev/null
+++ b/mobile/src/components/MobilePrComposeSheet.tsx
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useState } from 'react'
+import {
+ ActivityIndicator,
+ Linking,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ View
+} from 'react-native'
+import { Sparkles } from 'lucide-react-native'
+import type { HostedReviewProvider } from '../../../src/shared/hosted-review'
+import { BottomDrawer } from './BottomDrawer'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+import { triggerError, triggerSuccess } from '../platform/haptics'
+import { createMobilePr } from '../source-control/mobile-pr-create'
+
+type PrPrefill = {
+ base: string
+ title: string
+ body: string
+ provider: HostedReviewProvider
+}
+
+type Props = {
+ visible: boolean
+ client: RpcClient | null
+ worktreeId: string
+ prefill: PrPrefill
+ onClose: () => void
+ onCreated: (url: string) => void
+}
+
+// PR compose sheet: title/body/base/draft with AI prefill (git.generate
+// PullRequestFields), submitting via hostedReview.create. Mirrors the desktop
+// CreateHostedReviewComposer flow at mobile scale.
+export function MobilePrComposeSheet({
+ visible,
+ client,
+ worktreeId,
+ prefill,
+ onClose,
+ onCreated
+}: Props) {
+ const [title, setTitle] = useState(prefill.title)
+ const [body, setBody] = useState(prefill.body)
+ const [base, setBase] = useState(prefill.base)
+ const [draft, setDraft] = useState(false)
+ const [generating, setGenerating] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (visible) {
+ setTitle(prefill.title)
+ setBody(prefill.body)
+ setBase(prefill.base)
+ setDraft(false)
+ setError(null)
+ }
+ // Why: depend on the prefill *fields*, not the object identity — a parent
+ // rerender that produces a new prefill object would otherwise wipe the
+ // user's in-progress edits while the sheet is open.
+ }, [visible, prefill.title, prefill.body, prefill.base])
+
+ const generate = useCallback(async () => {
+ if (!client || generating) {
+ return
+ }
+ setGenerating(true)
+ setError(null)
+ try {
+ const response = await client.sendRequest('git.generatePullRequestFields', {
+ worktree: `id:${worktreeId}`,
+ base,
+ title,
+ body,
+ draft
+ })
+ if (!response.ok) {
+ setError(response.error?.message || 'Failed to generate PR fields')
+ return
+ }
+ const result = (response as RpcSuccess).result as {
+ success?: boolean
+ fields?: { base: string; title: string; body: string; draft: boolean }
+ error?: string
+ }
+ if (result.success && result.fields) {
+ setBase(result.fields.base || base)
+ setTitle(result.fields.title || title)
+ setBody(result.fields.body || body)
+ setDraft(result.fields.draft)
+ } else if (result.error) {
+ setError(result.error)
+ }
+ } finally {
+ setGenerating(false)
+ }
+ }, [base, body, client, draft, generating, title, worktreeId])
+
+ const submit = useCallback(async () => {
+ if (!client || submitting || title.trim().length === 0) {
+ return
+ }
+ setSubmitting(true)
+ setError(null)
+ try {
+ const outcome = await createMobilePr(client, worktreeId, {
+ provider: prefill.provider,
+ base,
+ title,
+ body,
+ draft
+ })
+ if (outcome.ok) {
+ triggerSuccess()
+ onCreated(outcome.url)
+ } else {
+ triggerError()
+ setError(outcome.error)
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }, [base, body, client, draft, onCreated, prefill.provider, submitting, title, worktreeId])
+
+ return (
+
+
+ Create Pull Request
+
+ Title
+ [styles.genButton, pressed && styles.genButtonPressed]}
+ disabled={generating || submitting}
+ onPress={() => void generate()}
+ accessibilityLabel="Generate PR fields with AI"
+ >
+ {generating ? (
+
+ ) : (
+
+ )}
+
+
+
+ Base branch
+
+ Description
+
+
+ Draft
+
+
+ {error ? {error} : null}
+ [
+ styles.submit,
+ (submitting || title.trim().length === 0) && styles.submitDisabled,
+ pressed && styles.submitPressed
+ ]}
+ disabled={submitting || title.trim().length === 0}
+ onPress={() => void submit()}
+ >
+ {submitting ? (
+
+ ) : (
+ Create Pull Request
+ )}
+
+
+
+ )
+}
+
+export function openMobilePrUrl(url: string): void {
+ void Linking.openURL(url)
+}
+
+const styles = StyleSheet.create({
+ scroll: { maxHeight: 460 },
+ heading: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '700',
+ marginBottom: spacing.sm
+ },
+ fieldRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between'
+ },
+ label: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ marginTop: spacing.md,
+ marginBottom: spacing.xs
+ },
+ genButton: {
+ width: 32,
+ height: 32,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: spacing.sm
+ },
+ genButtonPressed: { opacity: 0.7 },
+ titleInput: {
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize
+ },
+ bodyInput: {
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ minHeight: 96,
+ textAlignVertical: 'top'
+ },
+ draftRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: spacing.md
+ },
+ error: {
+ color: colors.statusRed,
+ fontSize: typography.metaSize,
+ marginTop: spacing.md
+ },
+ submit: {
+ marginTop: spacing.lg,
+ minHeight: 46,
+ borderRadius: radii.button,
+ backgroundColor: colors.textPrimary,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ submitDisabled: { opacity: 0.45 },
+ submitPressed: { opacity: 0.8 },
+ submitText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/src/components/MobileRepoIcon.tsx b/mobile/src/components/MobileRepoIcon.tsx
new file mode 100644
index 00000000000..e4f7f9664cf
--- /dev/null
+++ b/mobile/src/components/MobileRepoIcon.tsx
@@ -0,0 +1,92 @@
+import {
+ Bot,
+ Box,
+ Braces,
+ Briefcase,
+ Building2,
+ Code2,
+ Cpu,
+ Database,
+ Folder,
+ Gauge,
+ Globe,
+ Layers,
+ type LucideIcon,
+ Package,
+ Palette,
+ Rocket,
+ Server,
+ Shapes,
+ Sparkles,
+ SquareTerminal,
+ Wrench
+} from 'lucide-react-native'
+import { Image, StyleSheet, Text, View } from 'react-native'
+import type { RepoIcon } from '../../../src/shared/repo-icon'
+import { colors } from '../theme/mobile-theme'
+
+// The lucide names the desktop repo-icon picker offers (src/renderer/src/
+// components/repo/repo-icon.tsx). Mobile renders the same glyph so the project
+// header icon matches desktop instead of a bare colored dot.
+const REPO_LUCIDE_ICONS: Record = {
+ Folder,
+ Code2,
+ SquareTerminal,
+ Bot,
+ Package,
+ Database,
+ Globe,
+ Server,
+ Layers,
+ Box,
+ Braces,
+ Briefcase,
+ Building2,
+ Cpu,
+ Gauge,
+ Palette,
+ Rocket,
+ Shapes,
+ Sparkles,
+ Wrench
+}
+
+type Props = {
+ repoIcon?: RepoIcon | null
+ size?: number
+ color?: string
+}
+
+// Renders a repo/project icon matching the desktop sidebar: a custom image
+// (favicon/avatar/upload), an emoji, or a lucide glyph. Falls back to Folder,
+// the desktop default, so a project always shows an icon rather than a dot.
+export function MobileRepoIcon({ repoIcon, size = 14, color = colors.textSecondary }: Props) {
+ if (repoIcon?.type === 'image') {
+ return (
+
+ )
+ }
+ if (repoIcon?.type === 'emoji') {
+ return {repoIcon.emoji}
+ }
+ const Icon = (repoIcon?.type === 'lucide' && REPO_LUCIDE_ICONS[repoIcon.name]) || Folder
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ emoji: {
+ textAlign: 'center'
+ },
+ glyph: {
+ alignItems: 'center',
+ justifyContent: 'center'
+ }
+})
diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx
index e50bc2e19ca..c9e0b44e082 100644
--- a/mobile/src/components/MobileRichMarkdownEditor.tsx
+++ b/mobile/src/components/MobileRichMarkdownEditor.tsx
@@ -19,6 +19,7 @@ import {
} from 'lucide-react-native'
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
import { colors, radii, spacing } from '../theme/mobile-theme'
+import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
@@ -73,12 +74,14 @@ type Props = {
content: string
editable: boolean
onChange: (content: string) => void
+ onKeyboardInsetChange?: (bottom: number) => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
| { type: 'openLink'; url: string }
+ | { type: 'keyboardInset'; bottom: number }
type ToolbarItem = {
command: RichMarkdownCommand
@@ -104,7 +107,12 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
-function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
+function MobileRichMarkdownEditorInner({
+ content,
+ editable,
+ onChange,
+ onKeyboardInsetChange
+}: Props) {
const webViewRef = useRef(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
@@ -150,6 +158,12 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
}
}, [applyEditable, editable])
+ // Clear any reported keyboard inset when the editor unmounts so a lifted
+ // Save/Discard bar settles back once the tab closes.
+ useEffect(() => {
+ return () => onKeyboardInsetChange?.(0)
+ }, [onKeyboardInsetChange])
+
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: unknown
@@ -182,9 +196,16 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
if (url) {
void Linking.openURL(url).catch(() => {})
}
+ return
+ }
+ if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') {
+ const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom)
+ if (bottom !== null) {
+ onKeyboardInsetChange?.(bottom)
+ }
}
},
- [applyContent, applyEditable, content, editable, onChange]
+ [applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange]
)
const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => {
diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx
new file mode 100644
index 00000000000..18d882aa3fb
--- /dev/null
+++ b/mobile/src/components/TerminalShortcutSettings.tsx
@@ -0,0 +1,428 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ AppState,
+ View,
+ Text,
+ StyleSheet,
+ Pressable,
+ Switch,
+ type AppStateStatus
+} from 'react-native'
+import { useFocusEffect } from 'expo-router'
+import { ChevronRight, X } from 'lucide-react-native'
+import type Animated from 'react-native-reanimated'
+import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
+import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
+import { DragReorderList } from './DragReorderList'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import {
+ TERMINAL_ACCESSORY_KEYS,
+ type TerminalAccessoryKey
+} from '../terminal/terminal-accessory-keys'
+import {
+ getDefaultTerminalAccessoryLayout,
+ loadTerminalAccessoryLayout,
+ reorderTerminalAccessoryBuiltInIds,
+ saveTerminalAccessoryLayout,
+ setTerminalAccessoryBuiltInVisible,
+ type TerminalAccessoryLayout
+} from '../terminal/terminal-accessory-layout'
+
+// Why: DragReorderList absolutely positions rows, so every row in a
+// reorderable section must share one fixed height.
+const REORDER_ROW_HEIGHT = 56
+
+function ShortcutBarRow({
+ shortcutKey,
+ visible,
+ onToggle
+}: {
+ shortcutKey: TerminalAccessoryKey
+ visible: boolean
+ onToggle: (visible: boolean) => void
+}): React.JSX.Element {
+ return (
+
+
+ {shortcutKey.label}
+
+
+ {shortcutKey.accessibilityLabel ?? shortcutKey.label}
+
+
+
+ )
+}
+
+type Props = {
+ scrollRef: AnimatedRef
+ scrollOffsetY: SharedValue
+ scrollContentHeight: SharedValue
+ onDragActiveChange: (active: boolean) => void
+}
+
+export function TerminalShortcutSettings({
+ scrollRef,
+ scrollOffsetY,
+ scrollContentHeight,
+ onDragActiveChange
+}: Props): React.JSX.Element {
+ const [customKeys, setCustomKeys] = useState([])
+ const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
+ const [shortcutLayout, setShortcutLayout] = useState(
+ getDefaultTerminalAccessoryLayout
+ )
+ const layoutWriteChainRef = useRef>(Promise.resolve())
+ const layoutWriteSeqRef = useRef(0)
+ const pendingLayoutWritesRef = useRef(0)
+
+ const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
+ layoutWriteSeqRef.current += 1
+ pendingLayoutWritesRef.current += 1
+ layoutWriteChainRef.current = layoutWriteChainRef.current
+ .catch(() => {})
+ .then(() => saveTerminalAccessoryLayout(next))
+ .catch(() => {})
+ .finally(() => {
+ pendingLayoutWritesRef.current -= 1
+ })
+ }, [])
+
+ const refreshShortcutLayout = useCallback(() => {
+ const refreshSeq = layoutWriteSeqRef.current
+ void loadTerminalAccessoryLayout().then((layout) => {
+ if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
+ return
+ }
+ setShortcutLayout({
+ orderedBuiltInIds: layout.orderedBuiltInIds,
+ visibleBuiltInIds: layout.visibleBuiltInIds
+ })
+ })
+ }, [])
+
+ const customKeysWriteChainRef = useRef>(Promise.resolve())
+ const customKeysWriteSeqRef = useRef(0)
+ const pendingCustomKeysWritesRef = useRef(0)
+
+ // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
+ // racing an in-flight save must not overwrite the optimistic state.
+ const persistCustomKeys = useCallback((next: CustomKey[]) => {
+ customKeysWriteSeqRef.current += 1
+ pendingCustomKeysWritesRef.current += 1
+ customKeysWriteChainRef.current = customKeysWriteChainRef.current
+ .catch(() => {})
+ .then(() => saveCustomKeys(next))
+ .catch(() => {})
+ .finally(() => {
+ pendingCustomKeysWritesRef.current -= 1
+ })
+ }, [])
+
+ const refreshCustomKeys = useCallback(() => {
+ const refreshSeq = customKeysWriteSeqRef.current
+ void loadCustomKeys().then((keys) => {
+ if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
+ return
+ }
+ setCustomKeys(keys)
+ })
+ }, [])
+
+ const handleDeleteCustomKey = useCallback(
+ (key: CustomKey) => {
+ setCustomKeys((current) => {
+ const updated = current.filter((k) => k.id !== key.id)
+ persistCustomKeys(updated)
+ return updated
+ })
+ },
+ [persistCustomKeys]
+ )
+
+ useFocusEffect(
+ useCallback(() => {
+ refreshShortcutLayout()
+ refreshCustomKeys()
+ }, [refreshShortcutLayout, refreshCustomKeys])
+ )
+
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
+ if (s === 'active') {
+ refreshShortcutLayout()
+ refreshCustomKeys()
+ }
+ })
+ return () => sub.remove()
+ }, [refreshShortcutLayout, refreshCustomKeys])
+
+ const toggleBuiltInKey = useCallback(
+ (id: string, visible: boolean) => {
+ setShortcutLayout((current) => {
+ const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
+ persistLayout(next)
+ return next
+ })
+ },
+ [persistLayout]
+ )
+
+ const reorderBuiltInKeys = useCallback(
+ (orderedKeys: string[]) => {
+ setShortcutLayout((current) => {
+ const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys)
+ persistLayout(next)
+ return next
+ })
+ },
+ [persistLayout]
+ )
+
+ const resetBuiltInKeys = useCallback(() => {
+ const next = getDefaultTerminalAccessoryLayout()
+ setShortcutLayout(next)
+ persistLayout(next)
+ }, [persistLayout])
+
+ const reorderCustomKeys = useCallback(
+ (orderedKeys: string[]) => {
+ setCustomKeys((current) => {
+ const byId = new Map(current.map((key) => [key.id, key]))
+ const reordered = orderedKeys.flatMap((id) => {
+ const key = byId.get(id)
+ return key ? [key] : []
+ })
+ if (reordered.length !== current.length) {
+ return current
+ }
+ persistCustomKeys(reordered)
+ return reordered
+ })
+ },
+ [persistCustomKeys]
+ )
+
+ const visibleBuiltInSet = useMemo(
+ () => new Set(shortcutLayout.visibleBuiltInIds),
+ [shortcutLayout.visibleBuiltInIds]
+ )
+ const orderedAccessoryKeys = useMemo(() => {
+ const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
+ return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
+ const key = byId.get(id)
+ return key ? [key] : []
+ })
+ }, [shortcutLayout.orderedBuiltInIds])
+
+ return (
+ <>
+ SHORTCUT BAR
+
+ Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on
+ the terminal shortcut bar.
+
+
+ shortcutKey.id}
+ rowHeight={REORDER_ROW_HEIGHT}
+ scrollRef={scrollRef}
+ scrollOffsetY={scrollOffsetY}
+ scrollContentHeight={scrollContentHeight}
+ onDragActiveChange={onDragActiveChange}
+ onReorder={reorderBuiltInKeys}
+ renderRow={(shortcutKey) => (
+ toggleBuiltInKey(shortcutKey.id, visible)}
+ />
+ )}
+ />
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={resetBuiltInKeys}
+ >
+
+ Reset Defaults
+
+ Show every built-in shortcut key in the original order
+
+
+
+
+
+ CUSTOM SHORTCUTS
+
+ {customKeys.length === 0 ? (
+ <>
+
+ No custom shortcuts defined yet.
+
+
+ >
+ ) : (
+ key.id}
+ rowHeight={REORDER_ROW_HEIGHT}
+ scrollRef={scrollRef}
+ scrollOffsetY={scrollOffsetY}
+ scrollContentHeight={scrollContentHeight}
+ onDragActiveChange={onDragActiveChange}
+ onReorder={reorderCustomKeys}
+ renderRow={(key) => (
+
+
+ {key.label}
+
+
+ {key.label}
+
+ {key.bytes.replace(/\r/g, ' ↵')}
+
+
+ [
+ styles.deleteButton,
+ pressed && styles.deleteButtonPressed
+ ]}
+ onPress={() => handleDeleteCustomKey(key)}
+ >
+
+
+
+ )}
+ />
+ )}
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={() => setShowCustomKeyModal(true)}
+ >
+
+ Add Custom Shortcut…
+ Create key combo or text macro
+
+
+
+
+
+ setShowCustomKeyModal(false)}
+ onKeysChanged={(keys) => {
+ // Why: the modal already persisted this list; bumping the sequence
+ // discards refreshes that read storage before its save landed.
+ customKeysWriteSeqRef.current += 1
+ setCustomKeys(keys)
+ }}
+ />
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ groupHeading: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ letterSpacing: 0.5,
+ marginBottom: spacing.xs,
+ paddingHorizontal: spacing.xs
+ },
+ groupTopGap: {
+ marginTop: spacing.xl
+ },
+ groupDescription: {
+ fontSize: typography.bodySize - 1,
+ color: colors.textSecondary,
+ lineHeight: 20,
+ paddingHorizontal: spacing.xs
+ },
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.card,
+ overflow: 'hidden'
+ },
+ sectionTopGap: {
+ marginTop: spacing.sm
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ // Why: rows inside DragReorderList get a fixed height and a trailing grip
+ // handle from the list itself, so content only pads on the left.
+ reorderRowContent: {
+ flex: 1,
+ height: '100%',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingLeft: spacing.md + 2
+ },
+ rowContent: {
+ flex: 1
+ },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ rowSublabel: {
+ fontSize: typography.bodySize - 2,
+ color: colors.textSecondary,
+ marginTop: 2
+ },
+ keycap: {
+ minWidth: 62,
+ alignItems: 'center',
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ keycapText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ emptyContainer: {
+ padding: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ emptyText: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary,
+ padding: spacing.md
+ },
+ deleteButton: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(239, 68, 68, 0.1)'
+ },
+ deleteButtonPressed: {
+ backgroundColor: 'rgba(239, 68, 68, 0.2)'
+ }
+})
diff --git a/mobile/src/components/VoiceModelList.tsx b/mobile/src/components/VoiceModelList.tsx
new file mode 100644
index 00000000000..4f74d18b526
--- /dev/null
+++ b/mobile/src/components/VoiceModelList.tsx
@@ -0,0 +1,151 @@
+import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
+import { Check, Download } from 'lucide-react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import {
+ isModelInFlight,
+ type MobileSpeechModel,
+ type MobileSpeechSetup
+} from '../dictation/mobile-dictation-setup'
+
+type Props = {
+ setup: MobileSpeechSetup
+ // Disabled mirrors desktop: the model list greys out when dictation is off.
+ disabled: boolean
+ busyModelId: string | null
+ onUseModel: (model: MobileSpeechModel) => void
+ onDownload: (model: MobileSpeechModel) => void
+}
+
+function formatSize(bytes: number | null): string {
+ if (!bytes) {
+ return ''
+ }
+ return `${Math.round(bytes / 1_000_000)} MB`
+}
+
+function modelMeta(model: MobileSpeechModel): string {
+ if (model.provider === 'openai') {
+ return 'OpenAI API'
+ }
+ const inFlight = isModelInFlight(model)
+ if (inFlight && model.progress != null) {
+ return `${formatSize(model.sizeBytes)} · ${Math.round(model.progress * 100)}%`
+ }
+ if (model.status === 'extracting') {
+ return `${formatSize(model.sizeBytes)} · extracting…`
+ }
+ return formatSize(model.sizeBytes)
+}
+
+// Renders the speech-model rows shared between the setup sheet and the Voice
+// settings page: size/progress, recommended badge, selected check, download.
+export function VoiceModelList({
+ setup,
+ disabled,
+ busyModelId,
+ onUseModel,
+ onDownload
+}: Props): React.JSX.Element {
+ return (
+
+ {setup.models.map((model, idx) => {
+ const isSelected = model.id === setup.selectedModelId
+ const inFlight = isModelInFlight(model)
+ const rowBusy = busyModelId === model.id
+ return (
+
+ {idx > 0 && }
+
+
+
+ {model.label}
+ {model.recommended ? Recommended : null}
+
+ {modelMeta(model)}
+
+ {model.provider === 'openai' ? (
+
+ {model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
+
+ ) : model.status === 'ready' ? (
+ isSelected ? (
+
+
+ In use
+
+ ) : (
+ [styles.actionButton, pressed && styles.actionPressed]}
+ disabled={rowBusy}
+ onPress={() => onUseModel(model)}
+ >
+ Use
+
+ )
+ ) : inFlight ? (
+
+ ) : (
+ [styles.iconButton, pressed && styles.actionPressed]}
+ disabled={rowBusy}
+ onPress={() => onDownload(model)}
+ accessibilityLabel={'Download ' + model.label}
+ >
+ {rowBusy ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ disabled: { opacity: 0.5 },
+ modelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.md,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ modelInfo: { flex: 1, minWidth: 0 },
+ modelTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ modelLabel: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '500' },
+ recommended: { color: colors.statusGreen, fontSize: 10, fontWeight: '700' },
+ modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 },
+ modelStateText: { color: colors.textMuted, fontSize: typography.metaSize },
+ actionButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ paddingHorizontal: spacing.md,
+ paddingVertical: 6,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ actionPressed: { opacity: 0.7 },
+ actionText: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
+ iconButton: {
+ width: 34,
+ height: 34,
+ borderRadius: radii.button,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised
+ },
+ selectedTag: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ selectedText: { color: colors.statusGreen, fontSize: typography.metaSize, fontWeight: '600' },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ }
+})
diff --git a/mobile/src/components/WorktreeAgentList.tsx b/mobile/src/components/WorktreeAgentList.tsx
new file mode 100644
index 00000000000..dc357ad685b
--- /dev/null
+++ b/mobile/src/components/WorktreeAgentList.tsx
@@ -0,0 +1,39 @@
+import { useMemo } from 'react'
+import { StyleSheet, View } from 'react-native'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import { flattenAgentRowLineage } from '../worktree/agent-row-lineage'
+import { WorktreeAgentRow } from './WorktreeAgentRow'
+
+type Props = {
+ agents: RuntimeWorktreeAgentRow[]
+ now: number
+ unvisited: boolean
+}
+
+// Inline agent list for one worktree row: flattens the spawn lineage and renders
+// a depth-indented WorktreeAgentRow per agent, mirroring the desktop sidebar's
+// WorktreeCardAgents.
+export function WorktreeAgentList({ agents, now, unvisited }: Props) {
+ // Why: rebuild the lineage tree only when the agent list changes, not on every
+ // re-render (the shared useNow tick re-renders this list every 30s).
+ const nodes = useMemo(() => flattenAgentRowLineage(agents), [agents])
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ list: {
+ marginTop: 3
+ }
+})
diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx
new file mode 100644
index 00000000000..509b3f6031c
--- /dev/null
+++ b/mobile/src/components/WorktreeAgentRow.tsx
@@ -0,0 +1,60 @@
+import { StyleSheet, Text, View } from 'react-native'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import { colors, spacing } from '../theme/mobile-theme'
+import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display'
+import { AgentStateDot } from './AgentStateDot'
+import { MobileAgentIcon } from './MobileAgentIcon'
+
+const INDENT_PER_DEPTH = 14
+
+type Props = {
+ agent: RuntimeWorktreeAgentRow
+ depth: number
+ now: number
+ // Bold/foreground until the user has visited the worktree, mirroring desktop's
+ // unvisited rule (the workspace title and its agent rows share one signal).
+ unvisited: boolean
+}
+
+// One inline agent row: state dot → identity → last message/prompt → time ago.
+// Mirrors desktop DashboardAgentRow's compact in-card layout.
+export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
+ const dotState = agentDotState(agent, now)
+ const label = agentDisplayLabel(agent, now)
+ const ts = formatTimeAgo(agent.stateStartedAt, now)
+
+ return (
+
+
+ {/* Agent identity logo (Claude/Codex/…), matching the desktop sidebar's
+ agent icons instead of a two-letter text code. */}
+ {agent.agentType ? : null}
+
+ {label}
+
+ {ts}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ marginTop: 3
+ },
+ label: {
+ flex: 1,
+ fontSize: 11,
+ color: colors.textMuted
+ },
+ labelUnvisited: {
+ color: colors.textPrimary,
+ fontWeight: '600'
+ },
+ time: {
+ fontSize: 10,
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx
new file mode 100644
index 00000000000..0695a49e999
--- /dev/null
+++ b/mobile/src/components/WorktreeListRow.tsx
@@ -0,0 +1,239 @@
+import { Bell, GitPullRequest } from 'lucide-react-native'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import type { RepoIcon } from '../../../src/shared/repo-icon'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import { triggerMediumImpact } from '../platform/haptics'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import { AgentSpinner } from './AgentSpinner'
+import { MobileRepoIcon } from './MobileRepoIcon'
+import { WorktreeAgentList } from './WorktreeAgentList'
+import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs'
+
+// Strip the refs/heads/ prefix for display, matching the desktop sidebar
+// (WorktreeCardHelpers.formatBranchName).
+function displayBranch(branch: string): string {
+ return branch.replace(/^refs\/heads\//, '')
+}
+
+// Minimal row shape needed for rendering — a structural subset of the screen's
+// Worktree so this component stays decoupled from the screen's local type.
+export type WorktreeListRowItem = {
+ worktreeId: string
+ repo: string
+ branch: string
+ displayName: string
+ liveTerminalCount: number
+ preview: string
+ unread: boolean
+ isActive?: boolean
+ linkedPR: { number: number; state: string } | null
+ linkedIssue?: number | null
+ linkedLinearIssue?: string | null
+ linkedGitLabMR?: number | null
+ linkedGitLabIssue?: number | null
+ comment?: string
+ agents?: RuntimeWorktreeAgentRow[]
+}
+
+type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
+
+type Props = {
+ item: T
+ isReadOnly: boolean
+ now: number
+ repoColor: string
+ repoIcon?: RepoIcon | null
+ // When the list is already grouped under this repo's section header, the row
+ // omits its own repo icon+name to avoid the redundant "📁 orca" on every row.
+ hideRepo?: boolean
+ status: WorktreeRollupStatus
+ onPress: (item: T) => void
+ onLongPress: (item: T) => void
+}
+
+export function WorktreeListRow({
+ item,
+ isReadOnly,
+ now,
+ repoColor,
+ repoIcon,
+ hideRepo = false,
+ status,
+ onPress,
+ onLongPress
+}: Props) {
+ return (
+ [
+ styles.worktreeRow,
+ item.isActive && styles.worktreeRowActive,
+ pressed && styles.worktreeRowPressed
+ ]}
+ disabled={isReadOnly}
+ onPress={() => onPress(item)}
+ onLongPress={() => {
+ triggerMediumImpact()
+ onLongPress(item)
+ }}
+ delayLongPress={400}
+ >
+
+
+ {item.unread && (
+
+ )}
+
+
+
+
+
+ {item.displayName || item.repo}
+
+ {item.linkedPR && (
+
+
+
+ #{item.linkedPR.number}
+
+
+ )}
+
+
+
+ {/* Repo glyph+name only when not already grouped under this repo;
+ MobileRepoIcon falls back to a Folder (matching desktop's default)
+ rather than a bare colored dot. */}
+ {!hideRepo && (
+ <>
+
+
+ {item.repo}
+
+ >
+ )}
+
+ {displayBranch(item.branch)}
+
+
+ {/* Only agents get a secondary activity line, matching desktop. A plain
+ terminal's shell-output tail is intentionally not surfaced here. */}
+ {item.agents && item.agents.length > 0 ? (
+
+ ) : null}
+
+
+ {item.liveTerminalCount > 0 && (
+ {item.liveTerminalCount}
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ worktreeRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ paddingVertical: spacing.sm + 2,
+ paddingHorizontal: spacing.lg,
+ // Reserve the active accent bar width so active/inactive rows align.
+ borderLeftWidth: 2,
+ borderLeftColor: 'transparent'
+ },
+ worktreeRowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ // Highlight the worktree currently focused on the desktop, mirroring the
+ // desktop sidebar's selected-card treatment (raised fill + left accent).
+ worktreeRowActive: {
+ backgroundColor: colors.bgPanel,
+ // Neutral grey accent, matching the desktop's active-tab indicator rather
+ // than a blue line.
+ borderLeftColor: colors.textSecondary
+ },
+ indicatorCol: {
+ width: 20,
+ alignItems: 'center',
+ paddingTop: 6,
+ marginRight: spacing.sm,
+ gap: 4
+ },
+ unreadBell: {
+ marginTop: 2
+ },
+ worktreeMain: {
+ flex: 1,
+ marginRight: spacing.sm
+ },
+ worktreeNameRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ worktreeName: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ flexShrink: 1
+ },
+ worktreeNameUnread: {
+ fontWeight: '700'
+ },
+ textReadOnly: {
+ opacity: 0.5
+ },
+ prBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 3,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: 5,
+ paddingVertical: 1,
+ borderRadius: 4
+ },
+ prNumber: {
+ fontSize: 10,
+ color: colors.textSecondary
+ },
+ worktreeMetaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 2,
+ gap: spacing.xs
+ },
+ repoName: {
+ fontSize: 11,
+ color: colors.textSecondary,
+ maxWidth: 100
+ },
+ branchName: {
+ fontSize: 11,
+ color: colors.textMuted,
+ fontFamily: typography.monoFamily,
+ flexShrink: 1
+ },
+ terminalCount: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ minWidth: 16,
+ textAlign: 'right',
+ paddingTop: 3
+ }
+})
diff --git a/mobile/src/components/WorktreeMetaGlyphs.tsx b/mobile/src/components/WorktreeMetaGlyphs.tsx
new file mode 100644
index 00000000000..38d67cc815c
--- /dev/null
+++ b/mobile/src/components/WorktreeMetaGlyphs.tsx
@@ -0,0 +1,68 @@
+import { CircleDot, GitMerge, StickyNote } from 'lucide-react-native'
+import { StyleSheet, Text, View } from 'react-native'
+import { colors } from '../theme/mobile-theme'
+
+// PR chip color by state, mirroring the desktop ReviewIcon palette: merged =
+// purple, open = green, closed = red, draft/unknown = muted.
+export function prStateColor(state: string): string {
+ const s = state.toLowerCase()
+ if (s === 'merged') {
+ return '#a78bfa'
+ }
+ if (s === 'open') {
+ return colors.statusGreen
+ }
+ if (s === 'closed') {
+ return colors.statusRed
+ }
+ return colors.textSecondary
+}
+
+type Props = {
+ comment?: string | null
+ linkedLinearIssue?: string | null
+ linkedGitLabMR?: number | null
+ linkedIssue?: number | null
+ linkedGitLabIssue?: number | null
+}
+
+// Presence glyphs for linked notes / Linear / GitLab MR / issue, matching the
+// desktop WorktreeCardMetaBadges row. Mobile shows presence only; the full
+// detail (title/state/labels) is a follow-up detail sheet.
+export function WorktreeMetaGlyphs({
+ comment,
+ linkedLinearIssue,
+ linkedGitLabMR,
+ linkedIssue,
+ linkedGitLabIssue
+}: Props) {
+ const hasNotes = (comment ?? '').trim().length > 0
+ const hasLinear = Boolean(linkedLinearIssue)
+ const hasGitLabMR = linkedGitLabMR != null
+ const hasIssue = linkedIssue != null || linkedGitLabIssue != null
+ if (!hasNotes && !hasLinear && !hasGitLabMR && !hasIssue) {
+ return null
+ }
+ return (
+
+ {hasNotes && }
+ {hasIssue && }
+ {hasLinear && L }
+ {hasGitLabMR && }
+
+ )
+}
+
+const styles = StyleSheet.create({
+ metaGlyphs: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ marginLeft: 2
+ },
+ linearGlyph: {
+ fontSize: 10,
+ fontWeight: '700',
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts
new file mode 100644
index 00000000000..f567143584e
--- /dev/null
+++ b/mobile/src/components/account-usage-state.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getInactiveProviderUsage,
+ getUsageBarState,
+ hasActiveProviderUsage,
+ hasRenderableUsage,
+ type AccountsSnapshot,
+ type InactiveAccountUsage,
+ type ProviderRateLimits
+} from './account-usage-state'
+
+function makeLimits(overrides: Partial = {}): ProviderRateLimits {
+ return {
+ provider: 'claude',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: 0,
+ error: null,
+ status: 'idle',
+ ...overrides
+ }
+}
+
+function makeSnapshot(
+ overrides: {
+ claudeLimits?: ProviderRateLimits | null
+ codexLimits?: ProviderRateLimits | null
+ claudeAccounts?: AccountsSnapshot['claude']['accounts']
+ codexAccounts?: AccountsSnapshot['codex']['accounts']
+ inactiveClaudeAccounts?: InactiveAccountUsage[]
+ inactiveCodexAccounts?: InactiveAccountUsage[]
+ } = {}
+): AccountsSnapshot {
+ return {
+ claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null },
+ codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null },
+ rateLimits: {
+ claude: overrides.claudeLimits ?? null,
+ codex: overrides.codexLimits ?? null,
+ inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [],
+ inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? []
+ }
+ }
+}
+
+describe('hasActiveProviderUsage', () => {
+ it('is false when there are no rate limits at all', () => {
+ expect(hasActiveProviderUsage(null)).toBe(false)
+ })
+
+ it('is true when a session window has data', () => {
+ expect(
+ hasActiveProviderUsage(
+ makeLimits({
+ status: 'ok',
+ session: { usedPercent: 12, windowMinutes: 300, resetsAt: null, resetDescription: null }
+ })
+ )
+ ).toBe(true)
+ })
+
+ it('is true when a successful fetch returned ok even with empty windows', () => {
+ expect(hasActiveProviderUsage(makeLimits({ status: 'ok' }))).toBe(true)
+ })
+
+ it('is false for an unavailable/error provider with no window data (no creds)', () => {
+ expect(hasActiveProviderUsage(makeLimits({ status: 'unavailable' }))).toBe(false)
+ expect(hasActiveProviderUsage(makeLimits({ status: 'error', error: 'nope' }))).toBe(false)
+ })
+})
+
+describe('hasRenderableUsage', () => {
+ it('is true when the provider has at least one managed account', () => {
+ const snapshot = makeSnapshot({
+ claudeAccounts: [{ id: 'a', email: 'x@y.z' }]
+ })
+ expect(hasRenderableUsage(snapshot, 'claude')).toBe(true)
+ })
+
+ // The bug: system-default auth has zero managed accounts but real usage data,
+ // and the home screen used to hide it entirely.
+ it('is true with zero managed accounts when active rate-limit data exists (system default)', () => {
+ const snapshot = makeSnapshot({
+ codexLimits: makeLimits({
+ provider: 'codex',
+ status: 'ok',
+ session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }
+ })
+ })
+ expect(hasRenderableUsage(snapshot, 'codex')).toBe(true)
+ })
+
+ it('is false with zero accounts and no usable rate-limit data', () => {
+ const snapshot = makeSnapshot({
+ claudeLimits: makeLimits({ status: 'unavailable' })
+ })
+ expect(hasRenderableUsage(snapshot, 'claude')).toBe(false)
+ expect(hasRenderableUsage(makeSnapshot(), 'claude')).toBe(false)
+ })
+})
+
+describe('getInactiveProviderUsage', () => {
+ it('returns inactive usage using the runtime rateLimits payload shape', () => {
+ const limits = makeLimits({
+ status: 'ok',
+ session: { usedPercent: 52, windowMinutes: 300, resetsAt: null, resetDescription: null }
+ })
+ const snapshot = makeSnapshot({
+ inactiveClaudeAccounts: [
+ { accountId: 'account-1', rateLimits: limits, updatedAt: 123, isFetching: false }
+ ]
+ })
+
+ expect(getInactiveProviderUsage(snapshot, 'claude', 'account-1')?.rateLimits).toBe(limits)
+ })
+})
+
+describe('getUsageBarState', () => {
+ it('keeps stale window data visible during a transient error', () => {
+ const bar = getUsageBarState(
+ makeLimits({
+ status: 'error',
+ error: 'temporarily unavailable',
+ session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
+ }),
+ 'session'
+ )
+
+ expect(bar).toEqual({ usedPercent: 72, unavailable: false, loading: false })
+ })
+
+ it('shows loading for a fetching provider without a window', () => {
+ expect(getUsageBarState(makeLimits({ status: 'fetching' }), 'weekly')).toEqual({
+ usedPercent: null,
+ unavailable: false,
+ loading: true
+ })
+ })
+})
diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts
new file mode 100644
index 00000000000..c2bee731cd0
--- /dev/null
+++ b/mobile/src/components/account-usage-state.ts
@@ -0,0 +1,129 @@
+// Why: keep these shapes in lockstep with src/shared/types.ts and
+// src/shared/rate-limit-types.ts. We don't import from desktop here because
+// the mobile bundle must not pull in Electron-coupled type files.
+//
+// Pure state/selectors live here (no React Native imports) so they can be
+// unit-tested directly; AccountUsage.tsx re-exports them alongside the
+// UsageBar component.
+export type RateLimitWindow = {
+ usedPercent: number
+ windowMinutes: number
+ resetsAt: number | null
+ resetDescription: string | null
+}
+
+export type ProviderRateLimits = {
+ provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
+ session: RateLimitWindow | null
+ weekly: RateLimitWindow | null
+ monthly?: RateLimitWindow | null
+ buckets?: Array
+ updatedAt: number
+ error: string | null
+ status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
+}
+
+export type InactiveAccountUsage = {
+ accountId: string
+ rateLimits: ProviderRateLimits | null
+ updatedAt: number
+ isFetching: boolean
+}
+
+export type ClaudeAccountSummary = {
+ id: string
+ email: string
+ organizationName?: string | null
+}
+
+export type CodexAccountSummary = {
+ id: string
+ email: string
+ workspaceLabel?: string | null
+}
+
+export type AccountsSnapshot = {
+ claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
+ codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
+ rateLimits: {
+ claude: ProviderRateLimits | null
+ codex: ProviderRateLimits | null
+ inactiveClaudeAccounts: InactiveAccountUsage[]
+ inactiveCodexAccounts: InactiveAccountUsage[]
+ }
+}
+
+export type ProviderKey = 'claude' | 'codex'
+
+export type UsageBarState = {
+ usedPercent: number | null
+ unavailable: boolean
+ loading: boolean
+}
+
+export function getActiveProviderRateLimits(
+ snapshot: AccountsSnapshot,
+ provider: ProviderKey
+): ProviderRateLimits | null {
+ return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
+}
+
+export function getInactiveProviderUsage(
+ snapshot: AccountsSnapshot,
+ provider: ProviderKey,
+ accountId: string
+): InactiveAccountUsage | null {
+ const list =
+ provider === 'claude'
+ ? snapshot.rateLimits.inactiveClaudeAccounts
+ : snapshot.rateLimits.inactiveCodexAccounts
+ return list.find((u) => u.accountId === accountId) ?? null
+}
+
+// Why: rate limits are fetched for the active target even when no Orca-managed
+// account exists (the default target is the agent's own system-default login).
+// Treat a provider as having usage worth showing when a fetch succeeded or any
+// window has data; an unavailable/error provider with no windows means the
+// system-default login has no credentials for it, so there is nothing to show.
+export function hasActiveProviderUsage(limits: ProviderRateLimits | null): boolean {
+ if (!limits) {
+ return false
+ }
+ if (
+ limits.session != null ||
+ limits.weekly != null ||
+ limits.monthly != null ||
+ (limits.buckets && limits.buckets.length > 0)
+ ) {
+ return true
+ }
+ return limits.status === 'ok'
+}
+
+// Why: transient errors keep the last successful window data, so availability
+// is per window rather than per provider status.
+export function getUsageBarState(
+ limits: ProviderRateLimits | null,
+ windowKey: 'session' | 'weekly',
+ isFetchingOverride?: boolean
+): UsageBarState {
+ const window = limits?.[windowKey] ?? null
+ const fetching =
+ isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle')
+ return {
+ usedPercent: window?.usedPercent ?? null,
+ unavailable: window == null && !fetching,
+ loading: fetching && window == null
+ }
+}
+
+// Why: the usage UI must render for the system-default login, not only for
+// Orca-managed accounts. Show a provider when it has at least one managed
+// account OR active rate-limit data for the system-default target.
+export function hasRenderableUsage(snapshot: AccountsSnapshot, provider: ProviderKey): boolean {
+ const accounts = provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
+ if (accounts.length > 0) {
+ return true
+ }
+ return hasActiveProviderUsage(getActiveProviderRateLimits(snapshot, provider))
+}
diff --git a/mobile/src/components/drag-reorder-positions.test.ts b/mobile/src/components/drag-reorder-positions.test.ts
new file mode 100644
index 00000000000..4eeaf0d77a2
--- /dev/null
+++ b/mobile/src/components/drag-reorder-positions.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ clampDragReorderIndex,
+ dragReorderPositionsFromKeys,
+ moveDragReorderKey,
+ orderedKeysFromDragReorderPositions
+} from './drag-reorder-positions'
+
+describe('drag reorder positions', () => {
+ it('round-trips keys through positions', () => {
+ const keys = ['escape', 'tab', 'enter']
+ expect(orderedKeysFromDragReorderPositions(dragReorderPositionsFromKeys(keys))).toEqual(keys)
+ })
+
+ it('clamps drag indexes to the list bounds', () => {
+ expect(clampDragReorderIndex(-2, 3)).toBe(0)
+ expect(clampDragReorderIndex(1, 3)).toBe(1)
+ expect(clampDragReorderIndex(7, 3)).toBe(2)
+ expect(clampDragReorderIndex(0, 0)).toBe(0)
+ })
+
+ it('shifts intermediate rows down when dragging a row later', () => {
+ const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
+ expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'a', 2))).toEqual([
+ 'b',
+ 'c',
+ 'a',
+ 'd'
+ ])
+ })
+
+ it('shifts intermediate rows up when dragging a row earlier', () => {
+ const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
+ expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
+ 'a',
+ 'd',
+ 'b',
+ 'c'
+ ])
+ })
+
+ it('returns the same positions for no-op or unknown moves', () => {
+ const positions = dragReorderPositionsFromKeys(['a', 'b'])
+ expect(moveDragReorderKey(positions, 'a', 0)).toBe(positions)
+ expect(moveDragReorderKey(positions, 'missing', 1)).toBe(positions)
+ })
+})
diff --git a/mobile/src/components/drag-reorder-positions.ts b/mobile/src/components/drag-reorder-positions.ts
new file mode 100644
index 00000000000..04fa3d273ea
--- /dev/null
+++ b/mobile/src/components/drag-reorder-positions.ts
@@ -0,0 +1,54 @@
+// Index math for DragReorderList. Kept worklet-safe (no captures, plain
+// objects) because moveDragReorderKey runs on the UI thread during a drag.
+
+export type DragReorderPositions = Record
+
+export function dragReorderPositionsFromKeys(keys: string[]): DragReorderPositions {
+ 'worklet'
+ const positions: DragReorderPositions = {}
+ for (let i = 0; i < keys.length; i++) {
+ positions[keys[i]!] = i
+ }
+ return positions
+}
+
+export function orderedKeysFromDragReorderPositions(positions: DragReorderPositions): string[] {
+ 'worklet'
+ const keys = Object.keys(positions)
+ keys.sort((a, b) => positions[a]! - positions[b]!)
+ return keys
+}
+
+export function clampDragReorderIndex(index: number, count: number): number {
+ 'worklet'
+ if (count <= 0) {
+ return 0
+ }
+ return Math.min(Math.max(index, 0), count - 1)
+}
+
+export function moveDragReorderKey(
+ positions: DragReorderPositions,
+ key: string,
+ toIndex: number
+): DragReorderPositions {
+ 'worklet'
+ const fromIndex = positions[key]
+ if (fromIndex === undefined || fromIndex === toIndex) {
+ return positions
+ }
+ const next: DragReorderPositions = {}
+ for (const currentKey of Object.keys(positions)) {
+ const position = positions[currentKey]!
+ if (currentKey === key) {
+ next[currentKey] = toIndex
+ } else if (fromIndex < toIndex && position > fromIndex && position <= toIndex) {
+ next[currentKey] = position - 1
+ } else if (toIndex < fromIndex && position >= toIndex && position < fromIndex) {
+ next[currentKey] = position + 1
+ } else {
+ next[currentKey] = position
+ }
+ }
+ return next
+}
diff --git a/mobile/src/components/mobile-diff-review-control-styles.ts b/mobile/src/components/mobile-diff-review-control-styles.ts
new file mode 100644
index 00000000000..9752b282f36
--- /dev/null
+++ b/mobile/src/components/mobile-diff-review-control-styles.ts
@@ -0,0 +1,129 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileDiffReviewControlStyles = StyleSheet.create({
+ footer: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.sm,
+ gap: spacing.sm,
+ backgroundColor: colors.bgBase,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ fileActionRow: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ footerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ navButton: {
+ width: 44,
+ minHeight: 44,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ footerButton: {
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ footerButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ primaryButton: {
+ flex: 1,
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.textPrimary
+ },
+ primaryButtonDone: {
+ backgroundColor: colors.statusGreen
+ },
+ primaryButtonText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '800'
+ },
+ secondaryButton: {
+ flex: 1,
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ secondaryButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ destructiveText: {
+ color: colors.statusRed,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ buttonPressed: {
+ opacity: 0.76
+ },
+ buttonDisabled: {
+ opacity: 0.45
+ },
+ composerHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: spacing.md,
+ marginBottom: spacing.md
+ },
+ drawerTitle: {
+ color: colors.textPrimary,
+ fontSize: typography.titleSize,
+ fontWeight: '700'
+ },
+ drawerSubtitle: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ marginTop: 2
+ },
+ composerInput: {
+ minHeight: 112,
+ borderRadius: radii.input,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ lineHeight: 20,
+ padding: spacing.md,
+ textAlignVertical: 'top'
+ },
+ drawerButtonRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ marginTop: spacing.md
+ }
+})
diff --git a/mobile/src/components/mobile-diff-review-layout-styles.ts b/mobile/src/components/mobile-diff-review-layout-styles.ts
new file mode 100644
index 00000000000..c665554b41d
--- /dev/null
+++ b/mobile/src/components/mobile-diff-review-layout-styles.ts
@@ -0,0 +1,246 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileDiffReviewLayoutStyles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ header: {
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.sm,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ topBar: {
+ minHeight: 50,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ iconButton: {
+ width: 44,
+ height: 44,
+ borderRadius: radii.button,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ iconButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ titleBlock: {
+ flex: 1,
+ minWidth: 0
+ },
+ title: {
+ color: colors.textPrimary,
+ fontSize: typography.titleSize,
+ fontWeight: '700'
+ },
+ subtitle: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ marginTop: 2
+ },
+ progressRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: spacing.md
+ },
+ progressText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ filterRow: {
+ gap: spacing.sm,
+ paddingTop: spacing.md,
+ paddingBottom: spacing.xs
+ },
+ filterChip: {
+ minHeight: 34,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgPanel,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle
+ },
+ filterChipActive: {
+ backgroundColor: colors.textPrimary,
+ borderColor: colors.textPrimary
+ },
+ filterChipPressed: {
+ opacity: 0.78
+ },
+ filterText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ filterTextActive: {
+ color: colors.bgBase
+ },
+ fileHeader: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.md,
+ paddingBottom: spacing.sm,
+ backgroundColor: colors.bgBase,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ fileTitleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ statusBadge: {
+ width: 28,
+ height: 28,
+ borderRadius: radii.button,
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ statusBadgeText: {
+ fontSize: typography.metaSize,
+ fontWeight: '800'
+ },
+ fileTitleBlock: {
+ flex: 1,
+ minWidth: 0
+ },
+ filePath: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ fileMeta: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ marginTop: 2
+ },
+ fileMetaRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ marginTop: spacing.sm,
+ flexWrap: 'wrap'
+ },
+ reviewedPill: {
+ color: colors.statusGreen,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ stalePill: {
+ color: colors.statusAmber,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ staleText: {
+ color: colors.statusAmber,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ fileNotes: {
+ gap: spacing.xs,
+ marginTop: spacing.sm
+ },
+ fileNote: {
+ minHeight: 44,
+ padding: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle
+ },
+ fileNotePressed: {
+ backgroundColor: colors.bgRaised
+ },
+ fileNoteText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ lineHeight: 17
+ },
+ hunkRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ marginTop: spacing.sm
+ },
+ hunkButton: {
+ minHeight: 36,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel
+ },
+ hunkButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ hunkButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ actionError: {
+ marginHorizontal: spacing.lg,
+ marginTop: spacing.sm,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.statusAmber
+ },
+ actionErrorText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize
+ },
+ diffList: {
+ paddingBottom: 140,
+ backgroundColor: colors.editorSurface
+ },
+ truncatedText: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ padding: spacing.md,
+ textAlign: 'center'
+ },
+ state: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ gap: spacing.md
+ },
+ stateTitle: {
+ color: colors.textPrimary,
+ fontSize: typography.titleSize,
+ fontWeight: '700',
+ textAlign: 'center'
+ },
+ stateText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ textAlign: 'center',
+ lineHeight: 20
+ },
+ retryButton: {
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ retryText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ }
+})
diff --git a/mobile/src/components/mobile-diff-review-screen-styles.ts b/mobile/src/components/mobile-diff-review-screen-styles.ts
new file mode 100644
index 00000000000..556ef0b0f7b
--- /dev/null
+++ b/mobile/src/components/mobile-diff-review-screen-styles.ts
@@ -0,0 +1,7 @@
+import { mobileDiffReviewControlStyles } from './mobile-diff-review-control-styles'
+import { mobileDiffReviewLayoutStyles } from './mobile-diff-review-layout-styles'
+
+export const mobileDiffReviewStyles = {
+ ...mobileDiffReviewLayoutStyles,
+ ...mobileDiffReviewControlStyles
+}
diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts
index 826bd1693d0..32fc12b59b2 100644
--- a/mobile/src/components/mobile-rich-markdown-editor-html.ts
+++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts
@@ -1,4 +1,5 @@
import { colors } from '../theme/mobile-theme'
+import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -669,13 +670,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
});
- window.__orcaRichMarkdown = {
- setMarkdown: setMarkdown,
- setEditable: setEditable,
- runCommand: runCommand,
- currentMarkdown: currentMarkdown
- };
-
+ window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
+${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts
new file mode 100644
index 00000000000..c29b6ab5ba9
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
+
+describe('normalizeMobileRichMarkdownKeyboardInset', () => {
+ it('rounds finite inset measurements for native layout', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(42.6)).toBe(43)
+ })
+
+ it('clamps negative inset measurements to zero', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(-8)).toBe(0)
+ })
+
+ it('rejects non-finite inset measurements', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(Number.NaN)).toBeNull()
+ expect(normalizeMobileRichMarkdownKeyboardInset(Number.POSITIVE_INFINITY)).toBeNull()
+ })
+})
diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts
new file mode 100644
index 00000000000..4182d133f00
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts
@@ -0,0 +1,28 @@
+// In-page script that reports the height covered by the on-screen keyboard.
+// Native Keyboard events are unreliable while focus lives in the editor
+// WebView, so measure the covered region directly from visualViewport and let
+// RN lift its native Save/Discard bar above it.
+export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null {
+ if (!Number.isFinite(value)) {
+ return null
+ }
+ return Math.max(0, Math.round(value))
+}
+
+export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = `
+ var lastInset = -1;
+ function reportKeyboardInset() {
+ var viewport = window.visualViewport;
+ var bottom = viewport
+ ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
+ : 0;
+ var rounded = Math.round(bottom);
+ if (rounded === lastInset) return;
+ lastInset = rounded;
+ post({ type: 'keyboardInset', bottom: rounded });
+ }
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', reportKeyboardInset);
+ window.visualViewport.addEventListener('scroll', reportKeyboardInset);
+ reportKeyboardInset();
+ }`
diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts
new file mode 100644
index 00000000000..7fa1ca22137
--- /dev/null
+++ b/mobile/src/dictation/mobile-dictation-setup.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import {
+ downloadDictationModel,
+ fetchDictationSetup,
+ isDictationReady,
+ isDictationSetupRequiredError,
+ isModelInFlight,
+ setDictationConfig,
+ type MobileSpeechModel,
+ type MobileSpeechSetup
+} from './mobile-dictation-setup'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail('unexpected')
+ })
+ }
+}
+
+function model(overrides: Partial = {}): MobileSpeechModel {
+ return {
+ id: 'm1',
+ label: 'M1',
+ provider: 'local',
+ sizeBytes: 100,
+ recommended: true,
+ status: 'not-downloaded',
+ progress: null,
+ ...overrides
+ }
+}
+
+describe('isDictationSetupRequiredError', () => {
+ it('matches the setup-required error codes', () => {
+ expect(isDictationSetupRequiredError('voice_dictation_disabled')).toBe(true)
+ expect(isDictationSetupRequiredError('voice_model_not_selected')).toBe(true)
+ expect(isDictationSetupRequiredError('voice_model_not_ready:not-downloaded')).toBe(true)
+ expect(isDictationSetupRequiredError('dictation_already_active')).toBe(false)
+ expect(isDictationSetupRequiredError('network down')).toBe(false)
+ })
+})
+
+describe('rpc wrappers', () => {
+ it('fetches setup', async () => {
+ const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
+ const client = clientWith([ok(setup)])
+ await expect(fetchDictationSetup(client)).resolves.toEqual(setup)
+ expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
+ })
+
+ it('starts a download', async () => {
+ const client = clientWith([ok({ started: true })])
+ await downloadDictationModel(client, 'm1')
+ expect(client.calls[0]).toEqual({ method: 'speech.models.download', params: { modelId: 'm1' } })
+ })
+
+ it('sets config', async () => {
+ const setup: MobileSpeechSetup = { enabled: true, selectedModelId: 'm1', models: [] }
+ const client = clientWith([ok(setup)])
+ await expect(setDictationConfig(client, { enabled: true, modelId: 'm1' })).resolves.toEqual(
+ setup
+ )
+ expect(client.calls[0]).toEqual({
+ method: 'speech.dictation.setup',
+ params: { enabled: true, modelId: 'm1' }
+ })
+ })
+
+ it('surfaces RPC failures as errors', async () => {
+ const client = clientWith([fail('disconnected')])
+ await expect(fetchDictationSetup(client)).rejects.toThrow('disconnected')
+ })
+})
+
+describe('state helpers', () => {
+ it('isModelInFlight covers downloading + extracting', () => {
+ expect(isModelInFlight(model({ status: 'downloading' }))).toBe(true)
+ expect(isModelInFlight(model({ status: 'extracting' }))).toBe(true)
+ expect(isModelInFlight(model({ status: 'ready' }))).toBe(false)
+ })
+
+ it('isDictationReady requires enabled + selected + ready', () => {
+ expect(
+ isDictationReady({
+ enabled: true,
+ selectedModelId: 'm1',
+ models: [model({ status: 'ready' })]
+ })
+ ).toBe(true)
+ expect(
+ isDictationReady({
+ enabled: false,
+ selectedModelId: 'm1',
+ models: [model({ status: 'ready' })]
+ })
+ ).toBe(false)
+ expect(
+ isDictationReady({
+ enabled: true,
+ selectedModelId: 'm1',
+ models: [model({ status: 'not-downloaded' })]
+ })
+ ).toBe(false)
+ expect(isDictationReady({ enabled: true, selectedModelId: '', models: [] })).toBe(false)
+ })
+})
diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts
new file mode 100644
index 00000000000..9c822922b02
--- /dev/null
+++ b/mobile/src/dictation/mobile-dictation-setup.ts
@@ -0,0 +1,60 @@
+import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+
+export type MobileSpeechSetup = RuntimeSpeechSetupState
+export type MobileSpeechModel = RuntimeSpeechSetupState['models'][number]
+
+// Dictation-setup errors startMobileDictation throws when the desktop isn't
+// configured. Mapping them lets the mic entry point open the setup sheet
+// instead of dead-ending on a toast.
+const SETUP_REQUIRED_CODES = new Set(['voice_dictation_disabled', 'voice_model_not_selected'])
+
+export function isDictationSetupRequiredError(message: string): boolean {
+ return SETUP_REQUIRED_CODES.has(message) || message.startsWith('voice_model_not_ready:')
+}
+
+export async function fetchDictationSetup(
+ client: Pick
+): Promise {
+ const response = await client.sendRequest('speech.models.list', null)
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to load dictation models')
+ }
+ return (response as RpcSuccess).result as MobileSpeechSetup
+}
+
+export async function downloadDictationModel(
+ client: Pick,
+ modelId: string
+): Promise {
+ const response = await client.sendRequest('speech.models.download', { modelId })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to start download')
+ }
+}
+
+export async function setDictationConfig(
+ client: Pick,
+ params: { enabled?: boolean; modelId?: string; dictationMode?: 'toggle' | 'hold' }
+): Promise {
+ const response = await client.sendRequest('speech.dictation.setup', params)
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to update dictation settings')
+ }
+ return (response as RpcSuccess).result as MobileSpeechSetup
+}
+
+// A model is mid-download (or extracting) and the sheet should keep polling.
+export function isModelInFlight(model: MobileSpeechModel): boolean {
+ return model.status === 'downloading' || model.status === 'extracting'
+}
+
+// Whether dictation can be used right now: enabled + a selected model that's ready.
+export function isDictationReady(setup: MobileSpeechSetup): boolean {
+ if (!setup.enabled || !setup.selectedModelId) {
+ return false
+ }
+ const selected = setup.models.find((m) => m.id === setup.selectedModelId)
+ return selected?.status === 'ready'
+}
diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts
new file mode 100644
index 00000000000..c4777ca2188
--- /dev/null
+++ b/mobile/src/files/file-tree.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
+
+function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
+ return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
+}
+
+describe('file-tree', () => {
+ it('nests files under their directories', () => {
+ const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
+ expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
+ expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
+ 'src/lib/util.ts'
+ )
+ })
+
+ it('flattens with directories before files and only expands open dirs', () => {
+ const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
+ const collapsed = flattenTree(root, new Set())
+ expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
+
+ const expanded = flattenTree(root, new Set(['src']))
+ expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
+ })
+
+ it('preserves the binary kind on flattened rows', () => {
+ const root = buildTree([entry('assets/logo.png', 'binary')])
+ const rows = flattenTree(root, new Set(['assets']))
+ expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
+ })
+
+ it('detects markdown paths', () => {
+ expect(isMarkdownPath('docs/readme.md')).toBe(true)
+ expect(isMarkdownPath('notes.markdown')).toBe(true)
+ expect(isMarkdownPath('app.ts')).toBe(false)
+ })
+})
diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts
new file mode 100644
index 00000000000..e453cc152e4
--- /dev/null
+++ b/mobile/src/files/file-tree.ts
@@ -0,0 +1,91 @@
+// Pure tree model for the mobile file explorer: turns the flat files.list
+// result into a nested directory structure and flattens it into renderable
+// rows. Kept out of the screen component so the screen stays under its line cap.
+
+export type MobileFileEntry = {
+ relativePath: string
+ basename: string
+ kind: 'text' | 'binary'
+}
+
+export type FilesListResult = {
+ files: MobileFileEntry[]
+ totalCount: number
+ truncated: boolean
+}
+
+export type TreeNode = {
+ id: string
+ name: string
+ relativePath: string
+ depth: number
+ kind: 'directory' | 'text' | 'binary'
+}
+
+export type DirectoryNode = {
+ name: string
+ relativePath: string
+ directories: Map
+ files: MobileFileEntry[]
+}
+
+function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
+ return { name, relativePath, directories: new Map(), files: [] }
+}
+
+export function buildTree(files: MobileFileEntry[]): DirectoryNode {
+ const root = createDirectoryNode('', '')
+ for (const file of files) {
+ const parts = file.relativePath.split('/').filter(Boolean)
+ let current = root
+ for (let index = 0; index < parts.length - 1; index += 1) {
+ const name = parts[index]!
+ const relativePath = parts.slice(0, index + 1).join('/')
+ let child = current.directories.get(name)
+ if (!child) {
+ child = createDirectoryNode(name, relativePath)
+ current.directories.set(name, child)
+ }
+ current = child
+ }
+ current.files.push(file)
+ }
+ return root
+}
+
+export function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] {
+ const rows: TreeNode[] = []
+ const visit = (directory: DirectoryNode, depth: number): void => {
+ const dirs = Array.from(directory.directories.values()).sort((a, b) =>
+ a.name.localeCompare(b.name)
+ )
+ for (const child of dirs) {
+ rows.push({
+ id: `dir:${child.relativePath}`,
+ name: child.name,
+ relativePath: child.relativePath,
+ depth,
+ kind: 'directory'
+ })
+ if (expanded.has(child.relativePath)) {
+ visit(child, depth + 1)
+ }
+ }
+ const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
+ for (const file of files) {
+ rows.push({
+ id: `file:${file.relativePath}`,
+ name: file.basename,
+ relativePath: file.relativePath,
+ depth,
+ kind: file.kind
+ })
+ }
+ }
+ visit(root, 0)
+ return rows
+}
+
+export function isMarkdownPath(relativePath: string): boolean {
+ return /\.(md|mdx|markdown)$/i.test(relativePath)
+}
diff --git a/mobile/src/hooks/use-active-worktree-scroll.ts b/mobile/src/hooks/use-active-worktree-scroll.ts
new file mode 100644
index 00000000000..7600f3ceafe
--- /dev/null
+++ b/mobile/src/hooks/use-active-worktree-scroll.ts
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import type { SectionList, SectionListData } from 'react-native'
+
+type WithId = { worktreeId: string; isActive?: boolean }
+
+// Scrolls the desktop-focused worktree into view when the active selection
+// changes, so the mobile list mirrors the desktop's current workspace. Fires
+// only on a *change* of active id (not every re-render) so it never yanks the
+// list while the user scrolls or searches. Returns the ref to attach to the
+// SectionList and the onScrollToIndexFailed handler it needs for rows that
+// aren't measured yet (variable heights from the inline agent list).
+export function useActiveWorktreeScroll(
+ sections: ReadonlyArray & { data: readonly T[] }>
+): {
+ sectionListRef: React.RefObject | null>
+ onScrollToIndexFailed: (info: { averageItemLength: number }) => void
+} {
+ const sectionListRef = useRef>(null)
+ const lastScrolledActiveIdRef = useRef(null)
+
+ const activeWorktreeId = useMemo(() => {
+ for (const section of sections) {
+ const match = section.data.find((w) => w.isActive)
+ if (match) {
+ return match.worktreeId
+ }
+ }
+ return null
+ }, [sections])
+
+ // Live mirror of the current active id so the deferred retry can bail if the
+ // selection changed during its timeout (avoids a brief scroll to a stale row).
+ const activeWorktreeIdRef = useRef(activeWorktreeId)
+ activeWorktreeIdRef.current = activeWorktreeId
+
+ const scrollToWorktree = useCallback(
+ (worktreeId: string): boolean => {
+ for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
+ const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId)
+ if (itemIndex >= 0) {
+ sectionListRef.current?.scrollToLocation({
+ sectionIndex,
+ itemIndex,
+ viewPosition: 0.5,
+ animated: true
+ })
+ return true
+ }
+ }
+ return false
+ },
+ [sections]
+ )
+
+ useEffect(() => {
+ if (!activeWorktreeId || activeWorktreeId === lastScrolledActiveIdRef.current) {
+ return
+ }
+ if (scrollToWorktree(activeWorktreeId)) {
+ lastScrolledActiveIdRef.current = activeWorktreeId
+ }
+ }, [activeWorktreeId, scrollToWorktree])
+
+ const onScrollToIndexFailed = useCallback(
+ (info: { averageItemLength: number }) => {
+ const target = lastScrolledActiveIdRef.current
+ if (!target) {
+ return
+ }
+ setTimeout(
+ () => {
+ // Bail if the active selection moved on while we waited — otherwise we'd
+ // scroll to a now-stale row before the effect corrects it.
+ if (activeWorktreeIdRef.current === target) {
+ scrollToWorktree(target)
+ }
+ },
+ info.averageItemLength > 0 ? 120 : 0
+ )
+ },
+ [scrollToWorktree]
+ )
+
+ return { sectionListRef, onScrollToIndexFailed }
+}
diff --git a/mobile/src/hooks/use-now.ts b/mobile/src/hooks/use-now.ts
new file mode 100644
index 00000000000..1114ff6998b
--- /dev/null
+++ b/mobile/src/hooks/use-now.ts
@@ -0,0 +1,14 @@
+import { useEffect, useState } from 'react'
+
+// One shared interval per caller, mirroring desktop's useNow: relative
+// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list
+// owns a single tick that drives every visible agent row, rather than each row
+// running its own interval.
+export function useNow(intervalMs = 30_000): number {
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), intervalMs)
+ return () => clearInterval(id)
+ }, [intervalMs])
+ return now
+}
diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts
index f0c2646283e..46eab52ec92 100644
--- a/mobile/src/notifications/mobile-notifications.test.ts
+++ b/mobile/src/notifications/mobile-notifications.test.ts
@@ -32,6 +32,14 @@ describe('subscribeToDesktopNotifications', () => {
}
}
+ function makeDeferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void
+ const promise = new Promise((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+ }
+
it('drops the local stream when disposed before the desktop returns ready', () => {
const unsubscribeStream = vi.fn()
const client = {
@@ -106,6 +114,142 @@ describe('subscribeToDesktopNotifications', () => {
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
})
+ it('dedupes concurrent notification events with the same desktop notification id', async () => {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-concurrent')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:concurrent'
+ })
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:concurrent'
+ })
+ await flushAsync()
+
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
+ })
+
+ it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ let resolveSchedule!: (identifier: string) => void
+ vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSchedule = resolve
+ })
+ )
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-dismiss-race')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:pending'
+ })
+ await flushAsync()
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
+ resolveSchedule('scheduled-pending')
+ await flushAsync()
+
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
+ })
+
+ it('does not carry a failed pending dismiss into a future schedule', async () => {
+ const secondEnabled = makeDeferred()
+ vi.mocked(loadPushNotificationsEnabled)
+ .mockResolvedValueOnce(true)
+ .mockReturnValueOnce(secondEnabled.promise)
+ .mockResolvedValueOnce(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockResolvedValueOnce('scheduled-1')
+ .mockResolvedValueOnce('scheduled-2')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done',
+ body: 'Finished.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done again',
+ body: 'Finished again.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
+ secondEnabled.resolve(false)
+ await flushAsync()
+
+ onEvent?.({
+ type: 'notification',
+ source: 'agent-task-complete',
+ title: 'Done later',
+ body: 'Finished later.',
+ notificationId: 'agent:stale-dismiss'
+ })
+ await flushAsync()
+
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
+ })
+
it('treats unknown dismiss events as no-ops', async () => {
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 03441b6f7b7..83abea6fb8a 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -23,7 +23,13 @@ type SubscribeResult = {
subscriptionId: string
}
-const scheduledNotificationIdsByHostAndNotificationId = new Map()
+type ScheduledNotificationState = {
+ identifier?: string
+ pending?: Promise
+ dismissAfterSchedule?: boolean
+}
+
+const scheduledNotificationsByHostAndNotificationId = new Map()
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
@@ -69,38 +75,91 @@ function configureNotificationChannel(): void {
}
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise {
- const enabled = await loadPushNotificationsEnabled()
- if (!enabled) {
- return
- }
-
- const granted = await ensureNotificationPermissions()
- if (!granted) {
- return
- }
-
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
- const previousIdentifier = storedKey
- ? scheduledNotificationIdsByHostAndNotificationId.get(storedKey)
- : undefined
- if (storedKey && previousIdentifier) {
- await Notifications.dismissNotificationAsync(previousIdentifier).catch(() => {})
- scheduledNotificationIdsByHostAndNotificationId.delete(storedKey)
+
+ if (!storedKey) {
+ const enabled = await loadPushNotificationsEnabled()
+ if (!enabled) {
+ return
+ }
+
+ const granted = await ensureNotificationPermissions()
+ if (!granted) {
+ return
+ }
+
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: event.title,
+ body: event.body,
+ data: buildLocalNotificationData(event, hostId),
+ ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
+ },
+ trigger: null
+ })
+ return
}
- const scheduledIdentifier = await Notifications.scheduleNotificationAsync({
- content: {
- title: event.title,
- body: event.body,
- data: buildLocalNotificationData(event, hostId),
- ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
- },
- trigger: null
- })
- if (storedKey) {
- scheduledNotificationIdsByHostAndNotificationId.set(storedKey, scheduledIdentifier)
+ let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
+ if (state?.pending) {
+ return
+ }
+ if (!state) {
+ state = {}
+ scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
+ }
+ const notificationState = state
+
+ const pending = (async () => {
+ const enabled = await loadPushNotificationsEnabled()
+ if (!enabled) {
+ return null
+ }
+
+ const granted = await ensureNotificationPermissions()
+ if (!granted) {
+ return null
+ }
+
+ if (notificationState.identifier) {
+ await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
+ notificationState.identifier = undefined
+ }
+
+ return Notifications.scheduleNotificationAsync({
+ content: {
+ title: event.title,
+ body: event.body,
+ data: buildLocalNotificationData(event, hostId),
+ ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
+ },
+ trigger: null
+ })
+ })()
+ notificationState.pending = pending
+
+ try {
+ const scheduledIdentifier = await pending
+ if (!scheduledIdentifier) {
+ if (!notificationState.identifier) {
+ scheduledNotificationsByHostAndNotificationId.delete(storedKey)
+ }
+ return
+ }
+ if (notificationState.dismissAfterSchedule) {
+ notificationState.dismissAfterSchedule = false
+ scheduledNotificationsByHostAndNotificationId.delete(storedKey)
+ await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
+ return
+ }
+ notificationState.identifier = scheduledIdentifier
+ } finally {
+ if (notificationState.pending === pending) {
+ notificationState.pending = undefined
+ notificationState.dismissAfterSchedule = false
+ }
}
}
@@ -112,12 +171,21 @@ async function dismissLocalNotification(
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
- const identifier = scheduledNotificationIdsByHostAndNotificationId.get(storedKey)
- if (!identifier) {
+ const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
+ if (!state) {
return
}
- scheduledNotificationIdsByHostAndNotificationId.delete(storedKey)
- await Notifications.dismissNotificationAsync(identifier).catch(() => {})
+ if (state.pending) {
+ // Why: desktop can send dismiss while iOS/Android is still scheduling the
+ // matching local notification. Remember it so no stale banner survives.
+ state.dismissAfterSchedule = true
+ return
+ }
+ if (!state.identifier) {
+ return
+ }
+ scheduledNotificationsByHostAndNotificationId.delete(storedKey)
+ await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
// Why: each host connection gets its own notification subscription. When the
diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx
new file mode 100644
index 00000000000..45d93a5b867
--- /dev/null
+++ b/mobile/src/session/TerminalPaneView.tsx
@@ -0,0 +1,99 @@
+import { useCallback } from 'react'
+import { StyleSheet, View } from 'react-native'
+import {
+ TerminalWebView,
+ type MobileTerminalTheme,
+ type TerminalKeyboardAvoidanceMetrics,
+ type TerminalModes,
+ type TerminalWebViewHandle
+} from '../terminal/TerminalWebView'
+
+type TerminalPaneViewProps = {
+ handle: string
+ active: boolean
+ keyboardLift: number
+ terminalTheme?: MobileTerminalTheme
+ textScale: number
+ onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
+ onWebReady: (handle: string) => void
+ onSelectionMode: (handle: string, active: boolean) => void
+ onSelectionCopy: (handle: string, text: string) => void
+ onSelectionEvicted: (handle: string) => void
+ onModesChanged: (handle: string, modes: TerminalModes) => void
+ onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
+ onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
+ onTerminalInput: (handle: string, bytes: string) => void
+ onTerminalTap: (handle: string) => void
+ onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
+ onTextScaleChange: (scale: number) => void
+}
+
+export function TerminalPaneView({
+ handle,
+ active,
+ keyboardLift,
+ terminalTheme,
+ textScale,
+ onRef,
+ onWebReady,
+ onSelectionMode,
+ onSelectionCopy,
+ onSelectionEvicted,
+ onModesChanged,
+ onKeyboardAvoidanceMetrics,
+ onHaptic,
+ onTerminalInput,
+ onTerminalTap,
+ onFileTap,
+ onTextScaleChange
+}: TerminalPaneViewProps) {
+ const setRef = useCallback(
+ (ref: TerminalWebViewHandle | null) => {
+ onRef(handle, ref)
+ },
+ [handle, onRef]
+ )
+
+ return (
+ 0 && { transform: [{ translateY: -keyboardLift }] },
+ !active && styles.terminalPaneHidden
+ ]}
+ >
+ onWebReady(handle)}
+ onSelectionMode={(a) => onSelectionMode(handle, a)}
+ onSelectionCopy={(t) => onSelectionCopy(handle, t)}
+ onSelectionEvicted={() => onSelectionEvicted(handle)}
+ onModesChanged={(m) => onModesChanged(handle, m)}
+ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
+ onHaptic={onHaptic}
+ onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
+ onTerminalTap={() => onTerminalTap(handle)}
+ onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
+ onTextScaleChange={onTextScaleChange}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ terminalPane: {
+ ...StyleSheet.absoluteFillObject
+ },
+ terminalPaneHidden: {
+ opacity: 0
+ },
+ terminalWebView: {
+ flex: 1
+ }
+})
diff --git a/mobile/src/session/mobile-artifact-kind.test.ts b/mobile/src/session/mobile-artifact-kind.test.ts
new file mode 100644
index 00000000000..376d1da0cc5
--- /dev/null
+++ b/mobile/src/session/mobile-artifact-kind.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { classifyMobileArtifact } from './mobile-artifact-kind'
+
+describe('classifyMobileArtifact', () => {
+ it('classifies raster image extensions (case-insensitive)', () => {
+ for (const p of ['a.png', 'b.JPG', 'c/d.jpeg', 'e.gif', 'f.webp', 'g.bmp', 'h.ico']) {
+ expect(classifyMobileArtifact(p)).toBe('image')
+ }
+ })
+
+ it('treats svg as other (RN Image cannot decode svg data URIs; render as source)', () => {
+ expect(classifyMobileArtifact('logo.svg')).toBe('other')
+ })
+
+ it('classifies html extensions', () => {
+ expect(classifyMobileArtifact('index.html')).toBe('html')
+ expect(classifyMobileArtifact('a/b/page.HTM')).toBe('html')
+ })
+
+ it('treats code/text/unknown as other', () => {
+ for (const p of ['main.ts', 'README.md', 'data.csv', 'notes', 'a.pdf', 'x.json']) {
+ expect(classifyMobileArtifact(p)).toBe('other')
+ }
+ })
+
+ it('treats a dotfile or no-extension path as other', () => {
+ expect(classifyMobileArtifact('.gitignore')).toBe('other')
+ expect(classifyMobileArtifact('Makefile')).toBe('other')
+ expect(classifyMobileArtifact('dir/.env')).toBe('other')
+ })
+})
diff --git a/mobile/src/session/mobile-artifact-kind.ts b/mobile/src/session/mobile-artifact-kind.ts
new file mode 100644
index 00000000000..4c1cd31ed5d
--- /dev/null
+++ b/mobile/src/session/mobile-artifact-kind.ts
@@ -0,0 +1,34 @@
+// Classifies a file path into how the mobile viewer should render it. Images
+// route through files.readPreview (base64) and render as an ; HTML routes
+// through files.read (text) and renders in a sandboxed WebView with a source
+// toggle; everything else stays on the existing text/syntax path.
+export type MobileArtifactKind = 'image' | 'html' | 'other'
+
+// Raster image extensions React Native's can decode from a base64 data
+// URI (host returns these via files.readPreview). SVG is intentionally excluded:
+// RN can't render image/svg+xml data URIs, so .svg falls through to the
+// text path and renders as (meaningful) XML source instead of a blank image.
+const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'])
+
+const HTML_EXTENSIONS = new Set(['html', 'htm'])
+
+function extensionOf(path: string): string {
+ const base = path.split(/[\\/]/).pop() ?? ''
+ const dot = base.lastIndexOf('.')
+ // A leading dot (dotfile, no real extension) or no dot → no extension.
+ if (dot <= 0) {
+ return ''
+ }
+ return base.slice(dot + 1).toLowerCase()
+}
+
+export function classifyMobileArtifact(path: string): MobileArtifactKind {
+ const ext = extensionOf(path)
+ if (IMAGE_EXTENSIONS.has(ext)) {
+ return 'image'
+ }
+ if (HTML_EXTENSIONS.has(ext)) {
+ return 'html'
+ }
+ return 'other'
+}
diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts
new file mode 100644
index 00000000000..b7a7edb6922
--- /dev/null
+++ b/mobile/src/session/mobile-clipboard-image.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ buildMobileImagePastePayload,
+ MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
+ normalizeMobileClipboardImageBase64,
+ saveMobileClipboardImageAsTempFile
+} from './mobile-clipboard-image'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function fail(id: string, code: string, message: string): RpcFailure {
+ return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('mobile clipboard image paste helpers', () => {
+ it('strips data URL image prefixes', () => {
+ expect(normalizeMobileClipboardImageBase64('data:image/png;base64,aGVsbG8=')).toBe('aGVsbG8=')
+ })
+
+ it('rejects non-base64 image data', () => {
+ expect(() => normalizeMobileClipboardImageBase64('not base64!')).toThrow(
+ 'Clipboard image content must be base64'
+ )
+ })
+
+ it('uploads mobile clipboard images in ordered chunks and commits', async () => {
+ const base64 = 'a'.repeat(MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4)
+ const client = clientWithResponses([
+ ok('start', { uploadId: 'upload-1' }),
+ ok('append-1', { receivedBase64Length: MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS }),
+ ok('append-2', { receivedBase64Length: base64.length }),
+ ok('commit', '/tmp/orca-paste-image.png')
+ ])
+
+ await expect(
+ saveMobileClipboardImageAsTempFile(client, `data:image/png;base64,${base64}`, {
+ connectionId: 'ssh-1'
+ })
+ ).resolves.toBe('/tmp/orca-paste-image.png')
+
+ expect(client.calls).toEqual([
+ {
+ method: 'clipboard.startImageUpload',
+ params: { expectedBase64Length: base64.length, connectionId: 'ssh-1' }
+ },
+ {
+ method: 'clipboard.appendImageUploadChunk',
+ params: {
+ uploadId: 'upload-1',
+ offset: 0,
+ contentBase64: base64.slice(0, MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)
+ }
+ },
+ {
+ method: 'clipboard.appendImageUploadChunk',
+ params: {
+ uploadId: 'upload-1',
+ offset: MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
+ contentBase64: base64.slice(MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)
+ }
+ },
+ { method: 'clipboard.commitImageUpload', params: { uploadId: 'upload-1' } }
+ ])
+ })
+
+ it('falls back to the legacy single-frame image save method when needed', async () => {
+ const client = clientWithResponses([
+ fail('start', 'method_not_found', 'missing'),
+ ok('save', '/tmp/orca-paste-image.png')
+ ])
+
+ await expect(saveMobileClipboardImageAsTempFile(client, 'aGVsbG8=')).resolves.toBe(
+ '/tmp/orca-paste-image.png'
+ )
+
+ expect(client.calls).toEqual([
+ {
+ method: 'clipboard.startImageUpload',
+ params: { expectedBase64Length: 8, connectionId: null }
+ },
+ {
+ method: 'clipboard.saveImageAsTempFile',
+ params: { contentBase64: 'aGVsbG8=', connectionId: null }
+ }
+ ])
+ })
+
+ it('aborts chunked upload state when append fails', async () => {
+ const client = clientWithResponses([
+ ok('start', { uploadId: 'upload-1' }),
+ fail('append', 'invalid_argument', 'bad chunk'),
+ ok('abort', { aborted: true })
+ ])
+
+ await expect(saveMobileClipboardImageAsTempFile(client, 'aGVsbG8=')).rejects.toThrow(
+ 'bad chunk'
+ )
+ expect(client.calls.at(-1)).toEqual({
+ method: 'clipboard.abortImageUpload',
+ params: { uploadId: 'upload-1' }
+ })
+ })
+
+ it('brackets generated image paths before sending to the terminal', () => {
+ expect(buildMobileImagePastePayload('/tmp/orca.png')).toBe('\x1b[200~/tmp/orca.png\x1b[201~')
+ expect(buildMobileImagePastePayload('/tmp/\x1b.png')).toBe('\x1b[200~/tmp/\u241b.png\x1b[201~')
+ })
+})
diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts
new file mode 100644
index 00000000000..a6a8e9eee93
--- /dev/null
+++ b/mobile/src/session/mobile-clipboard-image.ts
@@ -0,0 +1,87 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+
+export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024
+export const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
+export const MOBILE_CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
+
+const DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
+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('Clipboard image is too large')
+ }
+ if (contentBase64.length % 4 === 1 || !BASE64_PATTERN.test(contentBase64)) {
+ throw new Error('Clipboard image content must be base64')
+ }
+ return contentBase64
+}
+
+function assertSuccess(response: RpcSuccess | RpcFailure): T {
+ if (!response.ok) {
+ throw new Error(response.error.message)
+ }
+ return response.result as T
+}
+
+export async function saveMobileClipboardImageAsTempFile(
+ client: Pick,
+ imageData: string,
+ args?: { connectionId?: string | null }
+): Promise {
+ const contentBase64 = normalizeMobileClipboardImageBase64(imageData)
+ const connectionId = args?.connectionId ?? null
+ const startResponse = await client.sendRequest('clipboard.startImageUpload', {
+ expectedBase64Length: contentBase64.length,
+ connectionId
+ })
+
+ if (!startResponse.ok) {
+ if (
+ startResponse.error.code === 'method_not_found' &&
+ contentBase64.length <= MOBILE_CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS
+ ) {
+ return assertSuccess(
+ await client.sendRequest('clipboard.saveImageAsTempFile', { contentBase64, connectionId })
+ )
+ }
+ throw new Error(startResponse.error.message)
+ }
+
+ const { uploadId } = startResponse.result as { uploadId: string }
+ try {
+ for (
+ let offset = 0;
+ offset < contentBase64.length;
+ offset += MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
+ ) {
+ assertSuccess(
+ await client.sendRequest('clipboard.appendImageUploadChunk', {
+ uploadId,
+ offset,
+ contentBase64: contentBase64.slice(
+ offset,
+ offset + MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
+ )
+ })
+ )
+ }
+ return assertSuccess(
+ await client.sendRequest('clipboard.commitImageUpload', { uploadId })
+ )
+ } catch (error) {
+ // Why: failed mobile image sends create server-side upload state; abort so
+ // the bounded upload slot is released immediately instead of waiting for TTL.
+ await client.sendRequest('clipboard.abortImageUpload', { uploadId }).catch(() => {})
+ throw error
+ }
+}
+
+export function buildMobileImagePastePayload(filePath: string): string {
+ // Why: generated image paths are paste payloads, not ordinary typed input.
+ // Bracket the path even when it is one line so agents receive it atomically
+ // and stale terminal paste state cannot turn it into shell commands.
+ return `\x1b[200~${filePath.split('\x1b').join('\u241b')}\x1b[201~`
+}
diff --git a/mobile/src/session/mobile-diff-comment-edit.test.ts b/mobile/src/session/mobile-diff-comment-edit.test.ts
new file mode 100644
index 00000000000..8e6482d8bb0
--- /dev/null
+++ b/mobile/src/session/mobile-diff-comment-edit.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import type { DiffComment } from '../../../src/shared/types'
+import {
+ clearSentMobileDiffComments,
+ countUnsentMobileDiffComments,
+ getUnsentMobileDiffComments,
+ markMobileDiffCommentsSent,
+ updateMobileDiffComment
+} from './mobile-diff-comment-edit'
+
+function comment(overrides: Partial & Pick): DiffComment {
+ const { id, ...rest } = overrides
+ return {
+ id,
+ worktreeId: 'wt-1',
+ filePath: 'src/app.ts',
+ source: 'diff',
+ lineNumber: 4,
+ body: 'check this',
+ createdAt: 100,
+ side: 'modified',
+ ...rest
+ }
+}
+
+describe('mobile diff comment editing', () => {
+ it('edits notes and clears sent state', () => {
+ const result = updateMobileDiffComment([comment({ id: 'a', sentAt: 150 })], {
+ id: 'a',
+ body: ' updated ',
+ updatedAt: 200
+ })
+
+ expect(result.comment).toMatchObject({ id: 'a', body: 'updated', updatedAt: 200 })
+ expect(result.comment?.sentAt).toBeUndefined()
+ })
+
+ it('marks notes sent and excludes them from unsent counts', () => {
+ const comments = markMobileDiffCommentsSent(
+ [comment({ id: 'a' }), comment({ id: 'b' })],
+ new Set(['a']),
+ 250
+ )
+
+ expect(comments[0]?.sentAt).toBe(250)
+ expect(countUnsentMobileDiffComments(comments)).toBe(1)
+ expect(getUnsentMobileDiffComments(comments)).toEqual([comment({ id: 'b' })])
+ })
+
+ it('clears sent notes without removing unsent edits', () => {
+ expect(
+ clearSentMobileDiffComments([comment({ id: 'a', sentAt: 1 }), comment({ id: 'b' })])
+ ).toEqual([comment({ id: 'b' })])
+ })
+})
diff --git a/mobile/src/session/mobile-diff-comment-edit.ts b/mobile/src/session/mobile-diff-comment-edit.ts
new file mode 100644
index 00000000000..3da63af3e1e
--- /dev/null
+++ b/mobile/src/session/mobile-diff-comment-edit.ts
@@ -0,0 +1,54 @@
+import type { DiffComment } from '../../../src/shared/types'
+
+export type UpdateMobileDiffCommentInput = {
+ id: string
+ body: string
+ updatedAt: number
+}
+
+export function updateMobileDiffComment(
+ comments: readonly DiffComment[],
+ input: UpdateMobileDiffCommentInput
+): { comments: DiffComment[]; comment: DiffComment | null } {
+ const body = input.body.trim()
+ if (!body) {
+ return { comments: [...comments], comment: null }
+ }
+ let updatedComment: DiffComment | null = null
+ const next = comments.map((comment) => {
+ if (comment.id !== input.id) {
+ return comment
+ }
+ updatedComment = {
+ ...comment,
+ body,
+ updatedAt: input.updatedAt,
+ sentAt: undefined
+ }
+ return updatedComment
+ })
+ return { comments: next, comment: updatedComment }
+}
+
+export function markMobileDiffCommentsSent(
+ comments: readonly DiffComment[],
+ ids: ReadonlySet,
+ sentAt: number
+): DiffComment[] {
+ if (ids.size === 0) {
+ return [...comments]
+ }
+ return comments.map((comment) => (ids.has(comment.id) ? { ...comment, sentAt } : comment))
+}
+
+export function clearSentMobileDiffComments(comments: readonly DiffComment[]): DiffComment[] {
+ return comments.filter((comment) => comment.sentAt === undefined)
+}
+
+export function getUnsentMobileDiffComments(comments: readonly DiffComment[]): DiffComment[] {
+ return comments.filter((comment) => comment.sentAt === undefined)
+}
+
+export function countUnsentMobileDiffComments(comments: readonly DiffComment[]): number {
+ return getUnsentMobileDiffComments(comments).length
+}
diff --git a/mobile/src/session/mobile-diff-comments.test.ts b/mobile/src/session/mobile-diff-comments.test.ts
index 0fa0c5be624..e86d6e81bab 100644
--- a/mobile/src/session/mobile-diff-comments.test.ts
+++ b/mobile/src/session/mobile-diff-comments.test.ts
@@ -3,14 +3,16 @@ import type { DiffComment } from '../../../src/shared/types'
import {
addMobileDiffComment,
formatDiffComments,
+ formatMobileDiffReviewPrompt,
normalizeMobileDiffComments,
removeDeliveredMobileDiffComments,
removeMobileDiffComments
} from './mobile-diff-comments'
function comment(overrides: Partial & Pick): DiffComment {
+ const { id, ...rest } = overrides
return {
- id: overrides.id,
+ id,
worktreeId: 'wt-1',
filePath: 'src/app.ts',
source: 'diff',
@@ -18,7 +20,7 @@ function comment(overrides: Partial & Pick): Dif
body: 'check this',
createdAt: 100,
side: 'modified',
- ...overrides
+ ...rest
}
}
@@ -58,6 +60,28 @@ describe('mobile diff comments', () => {
expect(result.comments).toHaveLength(1)
})
+ it('creates file-level scoped comments', () => {
+ const result = addMobileDiffComment([], {
+ id: 'mobile-1',
+ worktreeId: 'wt-1',
+ filePath: 'src/app.ts',
+ oldPath: 'src/old-app.ts',
+ lineNumber: 0,
+ body: ' File note ',
+ createdAt: 200,
+ scope: 'branch',
+ diffIdentity: 'd1'
+ })
+
+ expect(result.comment).toMatchObject({
+ lineNumber: 0,
+ body: 'File note',
+ scope: 'branch',
+ oldPath: 'src/old-app.ts',
+ diffIdentity: 'd1'
+ })
+ })
+
it('rejects blank comment bodies', () => {
const existing = [comment({ id: 'a' })]
const result = addMobileDiffComment(existing, {
@@ -98,4 +122,56 @@ describe('mobile diff comments', () => {
['File: src/app.ts', 'Line: 4', 'User comment: "quote \\"this\\""'].join('\n')
)
})
+
+ it('formats file-level notes with file scope', () => {
+ expect(formatDiffComments([comment({ id: 'a', lineNumber: 0 })])).toBe(
+ ['File: src/app.ts', 'Scope: file', 'User comment: "check this"'].join('\n')
+ )
+ })
+
+ it('wraps sent review notes in the mobile agent prompt', () => {
+ expect(formatMobileDiffReviewPrompt([comment({ id: 'a' })])).toBe(
+ [
+ 'You are reviewing the current worktree. Address the following mobile review notes.',
+ '',
+ 'File: src/app.ts',
+ 'Line: 4',
+ 'User comment: "check this"',
+ '',
+ 'After applying fixes:',
+ '1. Summarize changed files.',
+ '2. Run relevant tests.',
+ '3. Tell me if anything remains risky.'
+ ].join('\n')
+ )
+ })
+
+ it('keeps review metadata while normalizing persisted notes', () => {
+ expect(
+ normalizeMobileDiffComments(
+ [
+ comment({
+ id: 'a',
+ lineNumber: 0,
+ updatedAt: 200,
+ sentAt: 300,
+ scope: 'staged',
+ oldPath: 'src/old.ts',
+ diffIdentity: 'd1'
+ })
+ ],
+ 'wt-1'
+ )
+ ).toEqual([
+ comment({
+ id: 'a',
+ lineNumber: 0,
+ updatedAt: 200,
+ sentAt: 300,
+ scope: 'staged',
+ oldPath: 'src/old.ts',
+ diffIdentity: 'd1'
+ })
+ ])
+ })
})
diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts
index 15c628a58de..7ac5e8d3676 100644
--- a/mobile/src/session/mobile-diff-comments.ts
+++ b/mobile/src/session/mobile-diff-comments.ts
@@ -1,12 +1,15 @@
-import type { DiffComment } from '../../../src/shared/types'
+import type { DiffComment, DiffReviewScope } from '../../../src/shared/types'
export type CreateMobileDiffCommentInput = {
worktreeId: string
filePath: string
+ oldPath?: string
lineNumber: number
body: string
id: string
createdAt: number
+ scope?: DiffReviewScope
+ diffIdentity?: string
}
function isRecord(value: unknown): value is Record {
@@ -17,6 +20,10 @@ function isMarkdownComment(comment: Pick): boolean {
return comment.source === 'markdown'
}
+function normalizeScope(value: unknown): DiffReviewScope | undefined {
+ return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined
+}
+
// Why: mobile Vitest/Metro run from the mobile package and cannot transform
// runtime imports from root src/shared. Keep this byte-for-byte compatible with
// the desktop shared formatter contract.
@@ -26,22 +33,40 @@ export function formatDiffComment(c: DiffComment): string {
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
- const lineLabel =
- c.startLine !== undefined && c.startLine !== c.lineNumber
- ? `Lines: ${c.startLine}-${c.lineNumber}`
- : `Line: ${c.lineNumber}`
+ const locationLabel =
+ c.lineNumber === 0
+ ? 'Scope: file'
+ : c.startLine !== undefined && c.startLine !== c.lineNumber
+ ? `Lines: ${c.startLine}-${c.lineNumber}`
+ : `Line: ${c.lineNumber}`
if (!isMarkdownComment(c)) {
- return [`File: ${c.filePath}`, lineLabel, `User comment: "${escaped}"`].join('\n')
+ return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n')
}
- return [`File: ${c.filePath}`, 'Source: markdown', lineLabel, `User comment: "${escaped}"`].join(
- '\n'
- )
+ return [
+ `File: ${c.filePath}`,
+ 'Source: markdown',
+ locationLabel,
+ `User comment: "${escaped}"`
+ ].join('\n')
}
export function formatDiffComments(comments: readonly DiffComment[]): string {
return comments.map(formatDiffComment).join('\n\n')
}
+export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string {
+ return [
+ 'You are reviewing the current worktree. Address the following mobile review notes.',
+ '',
+ formatDiffComments(comments),
+ '',
+ 'After applying fixes:',
+ '1. Summarize changed files.',
+ '2. Run relevant tests.',
+ '3. Tell me if anything remains risky.'
+ ].join('\n')
+}
+
export function normalizeMobileDiffComments(value: unknown, worktreeId: string): DiffComment[] {
if (!Array.isArray(value)) {
return []
@@ -55,7 +80,7 @@ export function normalizeMobileDiffComments(value: unknown, worktreeId: string):
const lineNumber = typeof candidate.lineNumber === 'number' ? candidate.lineNumber : NaN
const body = typeof candidate.body === 'string' ? candidate.body.trim() : ''
const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now()
- if (!id || !filePath || !Number.isFinite(lineNumber) || !body) {
+ if (!id || !filePath || !Number.isFinite(lineNumber) || lineNumber < 0 || !body) {
return []
}
return [
@@ -70,7 +95,12 @@ export function normalizeMobileDiffComments(value: unknown, worktreeId: string):
lineNumber,
body,
createdAt,
+ updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : undefined,
sentAt: typeof candidate.sentAt === 'number' ? candidate.sentAt : undefined,
+ scope: normalizeScope(candidate.scope),
+ oldPath: typeof candidate.oldPath === 'string' ? candidate.oldPath : undefined,
+ diffIdentity:
+ typeof candidate.diffIdentity === 'string' ? candidate.diffIdentity : undefined,
side: 'modified'
}
]
@@ -79,17 +109,20 @@ export function normalizeMobileDiffComments(value: unknown, worktreeId: string):
export function createMobileDiffComment(input: CreateMobileDiffCommentInput): DiffComment | null {
const body = input.body.trim()
- if (!body || !Number.isFinite(input.lineNumber) || input.lineNumber <= 0) {
+ if (!body || !Number.isFinite(input.lineNumber) || input.lineNumber < 0) {
return null
}
return {
id: input.id,
worktreeId: input.worktreeId,
filePath: input.filePath,
+ oldPath: input.oldPath,
source: 'diff',
lineNumber: input.lineNumber,
body,
createdAt: input.createdAt,
+ scope: input.scope,
+ diffIdentity: input.diffIdentity,
side: 'modified'
}
}
diff --git a/mobile/src/session/mobile-diff-hunks.test.ts b/mobile/src/session/mobile-diff-hunks.test.ts
new file mode 100644
index 00000000000..3847d6fb56f
--- /dev/null
+++ b/mobile/src/session/mobile-diff-hunks.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import type { MobileDiffLine } from './mobile-diff-lines'
+import {
+ buildMobileDiffHunks,
+ findNextMobileDiffHunkIndex,
+ findPreviousMobileDiffHunkIndex
+} from './mobile-diff-hunks'
+
+const lines: MobileDiffLine[] = [
+ { kind: 'context', text: 'one', oldLineNumber: 1, newLineNumber: 1 },
+ { kind: 'delete', text: 'two', oldLineNumber: 2 },
+ { kind: 'add', text: 'TWO', newLineNumber: 2 },
+ { kind: 'context', text: 'three', oldLineNumber: 3, newLineNumber: 3 },
+ { kind: 'add', text: 'four', newLineNumber: 4 }
+]
+
+describe('mobile diff hunks', () => {
+ it('extracts contiguous changed lines as hunks', () => {
+ expect(buildMobileDiffHunks(lines)).toEqual([
+ {
+ index: 0,
+ startIndex: 1,
+ endIndex: 2,
+ addedLines: 1,
+ deletedLines: 1,
+ firstLineNumber: 2
+ },
+ {
+ index: 1,
+ startIndex: 4,
+ endIndex: 4,
+ addedLines: 1,
+ deletedLines: 0,
+ firstLineNumber: 4
+ }
+ ])
+ })
+
+ it('wraps next and previous hunk navigation', () => {
+ const hunks = buildMobileDiffHunks(lines)
+
+ expect(findNextMobileDiffHunkIndex(hunks, 1)).toBe(1)
+ expect(findNextMobileDiffHunkIndex(hunks, 4)).toBe(0)
+ expect(findPreviousMobileDiffHunkIndex(hunks, 4)).toBe(0)
+ expect(findPreviousMobileDiffHunkIndex(hunks, 1)).toBe(1)
+ })
+})
diff --git a/mobile/src/session/mobile-diff-hunks.ts b/mobile/src/session/mobile-diff-hunks.ts
new file mode 100644
index 00000000000..ec3ef184a9c
--- /dev/null
+++ b/mobile/src/session/mobile-diff-hunks.ts
@@ -0,0 +1,88 @@
+import type { MobileDiffLine } from './mobile-diff-lines'
+
+export type MobileDiffHunk = {
+ index: number
+ startIndex: number
+ endIndex: number
+ addedLines: number
+ deletedLines: number
+ firstLineNumber: number | null
+}
+
+function isChangedLine(line: MobileDiffLine): boolean {
+ return line.kind === 'add' || line.kind === 'delete'
+}
+
+function lineNumberForHunk(line: MobileDiffLine): number | null {
+ return line.newLineNumber ?? line.oldLineNumber ?? null
+}
+
+export function buildMobileDiffHunks(lines: readonly MobileDiffLine[]): MobileDiffHunk[] {
+ const hunks: MobileDiffHunk[] = []
+ let startIndex: number | null = null
+ let addedLines = 0
+ let deletedLines = 0
+ let firstLineNumber: number | null = null
+
+ const closeHunk = (endIndex: number) => {
+ if (startIndex === null) {
+ return
+ }
+ hunks.push({
+ index: hunks.length,
+ startIndex,
+ endIndex,
+ addedLines,
+ deletedLines,
+ firstLineNumber
+ })
+ startIndex = null
+ addedLines = 0
+ deletedLines = 0
+ firstLineNumber = null
+ }
+
+ lines.forEach((line, index) => {
+ if (!isChangedLine(line)) {
+ closeHunk(index - 1)
+ return
+ }
+ if (startIndex === null) {
+ startIndex = index
+ firstLineNumber = lineNumberForHunk(line)
+ }
+ if (line.kind === 'add') {
+ addedLines += 1
+ } else {
+ deletedLines += 1
+ }
+ })
+ closeHunk(lines.length - 1)
+ return hunks
+}
+
+export function findNextMobileDiffHunkIndex(
+ hunks: readonly MobileDiffHunk[],
+ currentLineIndex: number
+): number | null {
+ if (hunks.length === 0) {
+ return null
+ }
+ return hunks.find((hunk) => hunk.startIndex > currentLineIndex)?.index ?? hunks[0]?.index ?? null
+}
+
+export function findPreviousMobileDiffHunkIndex(
+ hunks: readonly MobileDiffHunk[],
+ currentLineIndex: number
+): number | null {
+ if (hunks.length === 0) {
+ return null
+ }
+ for (let index = hunks.length - 1; index >= 0; index -= 1) {
+ const hunk = hunks[index]
+ if (hunk && hunk.startIndex < currentLineIndex) {
+ return hunk.index
+ }
+ }
+ return hunks[hunks.length - 1]?.index ?? null
+}
diff --git a/mobile/src/session/mobile-diff-review-loaders.ts b/mobile/src/session/mobile-diff-review-loaders.ts
new file mode 100644
index 00000000000..dfdaed2cd7c
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-loaders.ts
@@ -0,0 +1,180 @@
+import { buildMobileDiffLines } from './mobile-diff-lines'
+import { buildMobileDiffReviewQueue } from './mobile-diff-review-queue'
+import {
+ mergeMobileDiffReviewState,
+ normalizeMobileDiffReviewState
+} from './mobile-diff-review-state'
+import { normalizeMobileDiffComments } from './mobile-diff-comments'
+import { buildMobileDiffHunks } from './mobile-diff-hunks'
+import { highlightMobileDiffLines, resolveMobileSyntaxLanguage } from './mobile-file-syntax'
+import {
+ readMobileBranchCompareResult,
+ readMobileGitStatusResult,
+ readMobileReviewGitDiffResult,
+ readMobileReviewWorktreeMetadata
+} from './mobile-diff-review-rpc'
+import {
+ canOpenMobileBranchCompareDiff,
+ type MobileGitBranchCompareResult
+} from '../source-control/mobile-branch-compare'
+import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref'
+import { isMobileGitUnavailable } from '../source-control/mobile-git-status'
+import type { RpcClient } from '../transport/rpc-client'
+import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
+import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model'
+import { reviewDescriptorFromItem } from './mobile-diff-review-screen-model'
+
+type BranchCompareLoadResult = {
+ result: MobileGitBranchCompareResult | null
+ error?: string
+}
+
+type DiffLoadInput = {
+ client: RpcClient
+ worktreeId: string
+ item: MobileDiffReviewQueueItem
+ branchCompare: MobileGitBranchCompareResult | null
+}
+
+export async function loadMobileDiffReviewBranchCompare(
+ client: RpcClient,
+ worktreeId: string
+): Promise {
+ try {
+ const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
+ if (!baseRef) {
+ return { result: null }
+ }
+ const response = await client.sendRequest('git.branchCompare', {
+ worktree: `id:${worktreeId}`,
+ baseRef
+ })
+ if (!response.ok) {
+ if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
+ return { result: null }
+ }
+ return { result: null, error: response.error?.message || 'Committed changes unavailable' }
+ }
+ const parsed = readMobileBranchCompareResult(response.result)
+ return parsed
+ ? { result: parsed }
+ : { result: null, error: 'Committed changes response was invalid' }
+ } catch (err) {
+ return { result: null, error: err instanceof Error ? err.message : 'Committed changes failed' }
+ }
+}
+
+export async function loadMobileDiffReviewSnapshot(
+ client: RpcClient,
+ worktreeId: string
+): Promise {
+ const statusResponse = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
+ if (!statusResponse.ok) {
+ if (isMobileGitUnavailable(statusResponse.error?.code, statusResponse.error?.message)) {
+ return { kind: 'unavailable', message: 'Update Orca desktop to review changes on mobile.' }
+ }
+ throw new Error(statusResponse.error?.message || 'Unable to load changes')
+ }
+ const status = readMobileGitStatusResult(statusResponse.result)
+ if (!status) {
+ throw new Error('Source control response was invalid')
+ }
+
+ const [branch, worktreeResponse] = await Promise.all([
+ loadMobileDiffReviewBranchCompare(client, worktreeId),
+ client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` })
+ ])
+ if (!worktreeResponse.ok) {
+ throw new Error(worktreeResponse.error?.message || 'Unable to load review notes')
+ }
+
+ const metadata = readMobileReviewWorktreeMetadata(worktreeResponse.result)
+ const comments = normalizeMobileDiffComments(metadata.diffComments, worktreeId)
+ const normalizedReviewState = normalizeMobileDiffReviewState(metadata.mobileDiffReview)
+ const branchEntries =
+ branch.result && canOpenMobileBranchCompareDiff(branch.result.summary)
+ ? branch.result.entries
+ : []
+ const queue = buildMobileDiffReviewQueue({
+ worktreeId,
+ statusEntries: status.entries,
+ branchEntries,
+ branchHeadOid: branch.result?.summary.headOid,
+ branchMergeBase: branch.result?.summary.mergeBase,
+ comments,
+ reviewState: normalizedReviewState
+ })
+
+ return {
+ kind: 'ready',
+ status,
+ branchCompare: branch.result,
+ branchError: branch.error,
+ comments,
+ reviewState: mergeMobileDiffReviewState(
+ normalizedReviewState,
+ queue.map(reviewDescriptorFromItem),
+ Date.now()
+ )
+ }
+}
+
+export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise {
+ const { client, worktreeId, item, branchCompare } = input
+ const response =
+ item.scope === 'branch'
+ ? await loadBranchFileDiff(client, worktreeId, item, branchCompare)
+ : await client.sendRequest('git.diff', {
+ worktree: `id:${worktreeId}`,
+ filePath: item.filePath,
+ staged: item.scope === 'staged'
+ })
+ if (!response.ok) {
+ if (item.status === 'deleted') {
+ return { kind: 'deleted', itemKey: item.key }
+ }
+ throw new Error(response.error?.message || 'Unable to load diff')
+ }
+ const result = readMobileReviewGitDiffResult(response.result)
+ if (!result) {
+ throw new Error('Diff response was invalid')
+ }
+ if (result.kind === 'binary') {
+ return { kind: 'binary', itemKey: item.key }
+ }
+ if (result.kind === 'too-large') {
+ return { kind: 'too-large', itemKey: item.key, byteLength: result.byteLength }
+ }
+ const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
+ const language = resolveMobileSyntaxLanguage(item.filePath)
+ return {
+ kind: 'ready',
+ itemKey: item.key,
+ lines: highlightMobileDiffLines(diff.lines, language),
+ hunks: buildMobileDiffHunks(diff.lines),
+ truncated: diff.truncated
+ }
+}
+
+async function loadBranchFileDiff(
+ client: RpcClient,
+ worktreeId: string,
+ item: MobileDiffReviewQueueItem,
+ branchCompare: MobileGitBranchCompareResult | null
+) {
+ const summary = branchCompare?.summary
+ if (!summary || !summary.headOid || !summary.mergeBase) {
+ throw new Error('Committed diff is unavailable')
+ }
+ return client.sendRequest('git.branchDiff', {
+ worktree: `id:${worktreeId}`,
+ filePath: item.filePath,
+ ...(item.oldPath ? { oldPath: item.oldPath } : {}),
+ compare: {
+ baseRef: summary.baseRef,
+ ...(summary.baseOid ? { baseOid: summary.baseOid } : {}),
+ headOid: summary.headOid,
+ mergeBase: summary.mergeBase
+ }
+ })
+}
diff --git a/mobile/src/session/mobile-diff-review-queue.test.ts b/mobile/src/session/mobile-diff-review-queue.test.ts
new file mode 100644
index 00000000000..551f378d142
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-queue.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/types'
+import type { MobileGitBranchChangeEntry } from '../source-control/mobile-branch-compare'
+import type { MobileGitStatusEntry } from '../source-control/mobile-git-status'
+import {
+ buildMobileDiffReviewQueue,
+ createMobileDiffReviewFileKey,
+ filterMobileDiffReviewQueue
+} from './mobile-diff-review-queue'
+
+const emptyReviewState: MobileDiffReviewState = { version: 1, files: {} }
+
+function statusEntry(overrides: Partial): MobileGitStatusEntry {
+ return {
+ path: 'src/app.ts',
+ status: 'modified',
+ area: 'unstaged',
+ ...overrides
+ }
+}
+
+function branchEntry(overrides: Partial): MobileGitBranchChangeEntry {
+ return {
+ path: 'src/branch.ts',
+ status: 'modified',
+ ...overrides
+ }
+}
+
+function comment(overrides: Partial & Pick): DiffComment {
+ const { id, ...rest } = overrides
+ return {
+ id,
+ worktreeId: 'wt-1',
+ filePath: 'src/app.ts',
+ source: 'diff',
+ lineNumber: 2,
+ body: 'note',
+ createdAt: 10,
+ side: 'modified',
+ ...rest
+ }
+}
+
+describe('mobile diff review queue', () => {
+ it('builds unstaged, staged, and branch entries in review order', () => {
+ const queue = buildMobileDiffReviewQueue({
+ worktreeId: 'wt-1',
+ statusEntries: [
+ statusEntry({ path: 'z.ts', area: 'staged' }),
+ statusEntry({ path: 'a.ts', area: 'unstaged' })
+ ],
+ branchEntries: [branchEntry({ path: 'b.ts' })],
+ branchHeadOid: 'head',
+ branchMergeBase: 'base',
+ comments: [],
+ reviewState: emptyReviewState
+ })
+
+ expect(queue.map((item) => `${item.scope}:${item.filePath}`)).toEqual([
+ 'unstaged:a.ts',
+ 'staged:z.ts',
+ 'branch:b.ts'
+ ])
+ })
+
+ it('uses stable keys for renamed files', () => {
+ expect(createMobileDiffReviewFileKey('branch', 'branch', 'new.ts', 'old.ts')).toBe(
+ 'branch\0branch\0old.ts\0new.ts'
+ )
+ })
+
+ it('counts unsent and stale notes for matching review items', () => {
+ const queue = buildMobileDiffReviewQueue({
+ worktreeId: 'wt-1',
+ statusEntries: [statusEntry({ path: 'src/app.ts', area: 'unstaged' })],
+ branchEntries: [],
+ comments: [
+ comment({ id: 'a', scope: 'unstaged', diffIdentity: 'stale' }),
+ comment({ id: 'b', scope: 'unstaged', sentAt: 20 })
+ ],
+ reviewState: emptyReviewState
+ })
+
+ expect(queue[0]).toMatchObject({ noteCount: 2, unsentNoteCount: 1, staleNoteCount: 1 })
+ })
+
+ it('filters unreviewed files and noted files', () => {
+ const reviewState: MobileDiffReviewState = {
+ version: 1,
+ files: {
+ [createMobileDiffReviewFileKey('unstaged', 'unstaged', 'a.ts')]: {
+ key: createMobileDiffReviewFileKey('unstaged', 'unstaged', 'a.ts'),
+ filePath: 'a.ts',
+ scope: 'unstaged',
+ reviewedAt: 11,
+ reviewDiffIdentity: 'wrong'
+ }
+ }
+ }
+ const queue = buildMobileDiffReviewQueue({
+ worktreeId: 'wt-1',
+ statusEntries: [
+ statusEntry({ path: 'a.ts', area: 'unstaged' }),
+ statusEntry({ path: 'b.ts', area: 'unstaged' })
+ ],
+ branchEntries: [],
+ comments: [comment({ id: 'a', filePath: 'b.ts' })],
+ reviewState
+ })
+
+ expect(filterMobileDiffReviewQueue(queue, 'unreviewed').map((item) => item.filePath)).toEqual([
+ 'a.ts',
+ 'b.ts'
+ ])
+ expect(filterMobileDiffReviewQueue(queue, 'notes').map((item) => item.filePath)).toEqual([
+ 'b.ts'
+ ])
+ })
+})
diff --git a/mobile/src/session/mobile-diff-review-queue.ts b/mobile/src/session/mobile-diff-review-queue.ts
new file mode 100644
index 00000000000..a87c56dab3b
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-queue.ts
@@ -0,0 +1,270 @@
+import type { DiffComment, DiffReviewScope, MobileDiffReviewState } from '../../../src/shared/types'
+import type { MobileGitBranchChangeEntry } from '../source-control/mobile-branch-compare'
+import {
+ isMobileGitDiscardableEntry,
+ isMobileGitStageableEntry,
+ type MobileGitFileStatus,
+ type MobileGitStagingArea,
+ type MobileGitStatusEntry
+} from '../source-control/mobile-git-status'
+import {
+ buildMobileDiffIdentity,
+ didMobileDiffReviewFileChangeSinceReview,
+ isMobileDiffReviewFileReviewed
+} from './mobile-diff-review-state'
+
+export type MobileDiffReviewQueueFilter =
+ | 'all'
+ | 'unreviewed'
+ | 'notes'
+ | 'unstaged'
+ | 'staged'
+ | 'branch'
+
+export type MobileDiffReviewQueueItem = {
+ key: string
+ scope: DiffReviewScope
+ area: MobileGitStagingArea | 'branch'
+ filePath: string
+ oldPath?: string
+ status: MobileGitFileStatus
+ title: string
+ subtitle: string
+ added?: number
+ removed?: number
+ canStage: boolean
+ canUnstage: boolean
+ canDiscard: boolean
+ isGeneratedOrLockFile: boolean
+ diffIdentity: string
+ noteCount: number
+ unsentNoteCount: number
+ staleNoteCount: number
+ reviewedAt?: number
+ isReviewed: boolean
+ changedSinceReview: boolean
+}
+
+export type BuildMobileDiffReviewQueueInput = {
+ worktreeId: string
+ statusEntries: readonly MobileGitStatusEntry[]
+ branchEntries: readonly MobileGitBranchChangeEntry[]
+ branchHeadOid?: string | null
+ branchMergeBase?: string | null
+ comments: readonly DiffComment[]
+ reviewState: MobileDiffReviewState
+}
+
+const SCOPE_SORT_ORDER: Record = {
+ unstaged: 0,
+ staged: 1,
+ branch: 2
+}
+
+function scopeForStatusArea(area: MobileGitStagingArea): DiffReviewScope {
+ return area === 'staged' ? 'staged' : 'unstaged'
+}
+
+export function createMobileDiffReviewFileKey(
+ scope: DiffReviewScope,
+ area: MobileGitStagingArea | 'branch',
+ filePath: string,
+ oldPath?: string
+): string {
+ return [scope, area, oldPath ?? '', filePath].join('\0')
+}
+
+function statusEntryIdentity(entry: MobileGitStatusEntry, scope: DiffReviewScope): string {
+ return buildMobileDiffIdentity([
+ scope,
+ entry.area,
+ entry.status,
+ entry.oldPath ?? '',
+ entry.path,
+ String(entry.added ?? ''),
+ String(entry.removed ?? ''),
+ entry.conflictStatus ?? ''
+ ])
+}
+
+function branchEntryIdentity(
+ entry: MobileGitBranchChangeEntry,
+ branchHeadOid: string | null | undefined,
+ branchMergeBase: string | null | undefined
+): string {
+ return buildMobileDiffIdentity([
+ 'branch',
+ branchMergeBase ?? '',
+ branchHeadOid ?? '',
+ entry.status,
+ entry.oldPath ?? '',
+ entry.path,
+ String(entry.added ?? ''),
+ String(entry.removed ?? '')
+ ])
+}
+
+function isGeneratedOrLockFile(filePath: string): boolean {
+ const normalized = filePath.toLowerCase()
+ return (
+ normalized.endsWith('package-lock.json') ||
+ normalized.endsWith('pnpm-lock.yaml') ||
+ normalized.endsWith('yarn.lock') ||
+ normalized.endsWith('bun.lockb') ||
+ normalized.endsWith('.lock') ||
+ normalized.includes('/dist/') ||
+ normalized.includes('/build/') ||
+ normalized.includes('/coverage/') ||
+ normalized.endsWith('.generated.ts') ||
+ normalized.endsWith('.generated.tsx')
+ )
+}
+
+export function mobileDiffReviewCommentMatchesItem(
+ comment: DiffComment,
+ item: Pick
+): boolean {
+ if (comment.source === 'markdown' || comment.filePath !== item.filePath) {
+ return false
+ }
+ if (comment.scope !== undefined && comment.scope !== item.scope) {
+ return false
+ }
+ if (comment.oldPath !== undefined && comment.oldPath !== item.oldPath) {
+ return false
+ }
+ return true
+}
+
+function queueNoteCounts(
+ item: Pick,
+ comments: readonly DiffComment[]
+): { noteCount: number; unsentNoteCount: number; staleNoteCount: number } {
+ let noteCount = 0
+ let unsentNoteCount = 0
+ let staleNoteCount = 0
+ for (const comment of comments) {
+ if (!mobileDiffReviewCommentMatchesItem(comment, item)) {
+ continue
+ }
+ noteCount += 1
+ if (comment.sentAt === undefined) {
+ unsentNoteCount += 1
+ }
+ if (comment.diffIdentity !== undefined && comment.diffIdentity !== item.diffIdentity) {
+ staleNoteCount += 1
+ }
+ }
+ return { noteCount, unsentNoteCount, staleNoteCount }
+}
+
+function statusEntryToQueueItem(
+ entry: MobileGitStatusEntry,
+ comments: readonly DiffComment[],
+ reviewState: MobileDiffReviewState
+): MobileDiffReviewQueueItem {
+ const scope = scopeForStatusArea(entry.area)
+ const key = createMobileDiffReviewFileKey(scope, entry.area, entry.path, entry.oldPath)
+ const diffIdentity = statusEntryIdentity(entry, scope)
+ const reviewFileState = reviewState.files[key]
+ const counts = queueNoteCounts(
+ { filePath: entry.path, oldPath: entry.oldPath, scope, diffIdentity },
+ comments
+ )
+ return {
+ key,
+ scope,
+ area: entry.area,
+ filePath: entry.path,
+ oldPath: entry.oldPath,
+ status: entry.status,
+ title: entry.path,
+ subtitle: scope === 'staged' ? 'Staged' : 'Unstaged',
+ added: entry.added,
+ removed: entry.removed,
+ canStage: isMobileGitStageableEntry(entry),
+ canUnstage: entry.area === 'staged',
+ canDiscard: isMobileGitDiscardableEntry(entry) && entry.area !== 'staged',
+ isGeneratedOrLockFile: isGeneratedOrLockFile(entry.path),
+ diffIdentity,
+ ...counts,
+ reviewedAt: reviewFileState?.reviewedAt,
+ isReviewed: isMobileDiffReviewFileReviewed(reviewFileState, diffIdentity),
+ changedSinceReview: didMobileDiffReviewFileChangeSinceReview(reviewFileState, diffIdentity)
+ }
+}
+
+function branchEntryToQueueItem(
+ entry: MobileGitBranchChangeEntry,
+ input: BuildMobileDiffReviewQueueInput
+): MobileDiffReviewQueueItem {
+ const scope: DiffReviewScope = 'branch'
+ const key = createMobileDiffReviewFileKey(scope, 'branch', entry.path, entry.oldPath)
+ const diffIdentity = branchEntryIdentity(entry, input.branchHeadOid, input.branchMergeBase)
+ const reviewFileState = input.reviewState.files[key]
+ const counts = queueNoteCounts(
+ { filePath: entry.path, oldPath: entry.oldPath, scope, diffIdentity },
+ input.comments
+ )
+ return {
+ key,
+ scope,
+ area: 'branch',
+ filePath: entry.path,
+ oldPath: entry.oldPath,
+ status: entry.status,
+ title: entry.path,
+ subtitle: 'Committed on branch',
+ added: entry.added,
+ removed: entry.removed,
+ canStage: false,
+ canUnstage: false,
+ canDiscard: false,
+ isGeneratedOrLockFile: isGeneratedOrLockFile(entry.path),
+ diffIdentity,
+ ...counts,
+ reviewedAt: reviewFileState?.reviewedAt,
+ isReviewed: isMobileDiffReviewFileReviewed(reviewFileState, diffIdentity),
+ changedSinceReview: didMobileDiffReviewFileChangeSinceReview(reviewFileState, diffIdentity)
+ }
+}
+
+function compareQueueItems(
+ first: MobileDiffReviewQueueItem,
+ second: MobileDiffReviewQueueItem
+): number {
+ return (
+ SCOPE_SORT_ORDER[first.scope] - SCOPE_SORT_ORDER[second.scope] ||
+ Number(first.isGeneratedOrLockFile) - Number(second.isGeneratedOrLockFile) ||
+ first.filePath.localeCompare(second.filePath, undefined, { numeric: true })
+ )
+}
+
+export function buildMobileDiffReviewQueue(
+ input: BuildMobileDiffReviewQueueInput
+): MobileDiffReviewQueueItem[] {
+ return [
+ ...input.statusEntries.map((entry) =>
+ statusEntryToQueueItem(entry, input.comments, input.reviewState)
+ ),
+ ...input.branchEntries.map((entry) => branchEntryToQueueItem(entry, input))
+ ].sort(compareQueueItems)
+}
+
+export function filterMobileDiffReviewQueue(
+ queue: readonly MobileDiffReviewQueueItem[],
+ filter: MobileDiffReviewQueueFilter
+): MobileDiffReviewQueueItem[] {
+ switch (filter) {
+ case 'unreviewed':
+ return queue.filter((item) => !item.isReviewed)
+ case 'notes':
+ return queue.filter((item) => item.noteCount > 0)
+ case 'unstaged':
+ case 'staged':
+ case 'branch':
+ return queue.filter((item) => item.scope === filter)
+ case 'all':
+ return [...queue]
+ }
+}
diff --git a/mobile/src/session/mobile-diff-review-rpc.ts b/mobile/src/session/mobile-diff-review-rpc.ts
new file mode 100644
index 00000000000..90c3b0c8f58
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-rpc.ts
@@ -0,0 +1,237 @@
+import type {
+ MobileGitBranchChangeEntry,
+ MobileGitBranchCompareResult,
+ MobileGitBranchCompareSummary
+} from '../source-control/mobile-branch-compare'
+import type {
+ MobileGitFileStatus,
+ MobileGitStagingArea,
+ MobileGitStatusEntry,
+ MobileGitStatusResult
+} from '../source-control/mobile-git-status'
+
+export type MobileReviewGitDiffResult =
+ | {
+ kind: 'text'
+ originalContent: string
+ modifiedContent: string
+ }
+ | { kind: 'binary' }
+ | { kind: 'too-large'; byteLength?: number }
+
+export type MobileReviewWorktreeMetadata = {
+ diffComments: unknown
+ mobileDiffReview: unknown
+}
+
+export type MobileReviewTerminalTab = {
+ id: string
+ title: string
+ terminal: string
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function readString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined
+}
+
+function readNumber(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
+}
+
+function readFileStatus(value: unknown): MobileGitFileStatus | null {
+ return value === 'modified' ||
+ value === 'added' ||
+ value === 'deleted' ||
+ value === 'renamed' ||
+ value === 'untracked' ||
+ value === 'copied'
+ ? value
+ : null
+}
+
+function readStagingArea(value: unknown): MobileGitStagingArea | null {
+ return value === 'staged' || value === 'unstaged' || value === 'untracked' ? value : null
+}
+
+function readConflictOperation(value: unknown): MobileGitStatusResult['conflictOperation'] {
+ return value === 'merge' || value === 'rebase' || value === 'cherry-pick' || value === 'unknown'
+ ? value
+ : 'unknown'
+}
+
+function readStatusEntry(value: unknown): MobileGitStatusEntry | null {
+ if (!isRecord(value)) {
+ return null
+ }
+ const path = readString(value.path)
+ const status = readFileStatus(value.status)
+ const area = readStagingArea(value.area)
+ if (!path || !status || !area) {
+ return null
+ }
+ return {
+ path,
+ status,
+ area,
+ oldPath: readString(value.oldPath),
+ conflictKind: undefined,
+ conflictStatus:
+ value.conflictStatus === 'unresolved' || value.conflictStatus === 'resolved_locally'
+ ? value.conflictStatus
+ : undefined,
+ conflictStatusSource:
+ value.conflictStatusSource === 'git' || value.conflictStatusSource === 'session'
+ ? value.conflictStatusSource
+ : undefined,
+ added: readNumber(value.added),
+ removed: readNumber(value.removed)
+ }
+}
+
+export function readMobileGitStatusResult(value: unknown): MobileGitStatusResult | null {
+ if (!isRecord(value) || !Array.isArray(value.entries)) {
+ return null
+ }
+ return {
+ entries: value.entries.flatMap((entry): MobileGitStatusEntry[] => {
+ const parsed = readStatusEntry(entry)
+ return parsed ? [parsed] : []
+ }),
+ conflictOperation: readConflictOperation(value.conflictOperation),
+ branch: readString(value.branch),
+ head: readString(value.head)
+ }
+}
+
+function readBranchStatus(value: unknown): MobileGitBranchCompareSummary['status'] {
+ return value === 'ready' ||
+ value === 'invalid-base' ||
+ value === 'unborn-head' ||
+ value === 'no-merge-base' ||
+ value === 'loading' ||
+ value === 'error'
+ ? value
+ : 'error'
+}
+
+function readBranchEntry(value: unknown): MobileGitBranchChangeEntry | null {
+ if (!isRecord(value)) {
+ return null
+ }
+ const path = readString(value.path)
+ const status = readFileStatus(value.status)
+ if (!path || !status || status === 'untracked') {
+ return null
+ }
+ return {
+ path,
+ status,
+ oldPath: readString(value.oldPath),
+ added: readNumber(value.added),
+ removed: readNumber(value.removed)
+ }
+}
+
+export function readMobileBranchCompareResult(value: unknown): MobileGitBranchCompareResult | null {
+ if (!isRecord(value) || !isRecord(value.summary) || !Array.isArray(value.entries)) {
+ return null
+ }
+ const baseRef = readString(value.summary.baseRef)
+ const compareRef = readString(value.summary.compareRef)
+ const changedFiles = readNumber(value.summary.changedFiles)
+ if (!baseRef || !compareRef || changedFiles === undefined) {
+ return null
+ }
+ return {
+ summary: {
+ baseRef,
+ baseOid: readString(value.summary.baseOid) ?? null,
+ compareRef,
+ headOid: readString(value.summary.headOid) ?? null,
+ mergeBase: readString(value.summary.mergeBase) ?? null,
+ changedFiles,
+ commitsAhead: readNumber(value.summary.commitsAhead),
+ status: readBranchStatus(value.summary.status),
+ errorMessage: readString(value.summary.errorMessage)
+ },
+ entries: value.entries.flatMap((entry): MobileGitBranchChangeEntry[] => {
+ const parsed = readBranchEntry(entry)
+ return parsed ? [parsed] : []
+ })
+ }
+}
+
+export function readMobileReviewWorktreeMetadata(value: unknown): MobileReviewWorktreeMetadata {
+ if (!isRecord(value) || !isRecord(value.worktree)) {
+ return { diffComments: undefined, mobileDiffReview: undefined }
+ }
+ return {
+ diffComments: value.worktree.diffComments,
+ mobileDiffReview: value.worktree.mobileDiffReview
+ }
+}
+
+export function readMobileReviewGitDiffResult(value: unknown): MobileReviewGitDiffResult | null {
+ if (!isRecord(value)) {
+ return null
+ }
+ if (
+ value.kind === 'text' &&
+ typeof value.originalContent === 'string' &&
+ typeof value.modifiedContent === 'string'
+ ) {
+ return {
+ kind: 'text',
+ originalContent: value.originalContent,
+ modifiedContent: value.modifiedContent
+ }
+ }
+ if (value.kind === 'binary') {
+ return { kind: 'binary' }
+ }
+ if (value.kind === 'too-large') {
+ return { kind: 'too-large', byteLength: readNumber(value.byteLength) }
+ }
+ return null
+}
+
+export function readMobileReviewTerminalTabs(value: unknown): MobileReviewTerminalTab[] {
+ if (!isRecord(value) || !Array.isArray(value.tabs)) {
+ return []
+ }
+ return value.tabs.flatMap((candidate): MobileReviewTerminalTab[] => {
+ if (!isRecord(candidate) || candidate.type !== 'terminal') {
+ return []
+ }
+ const id = readString(candidate.id)
+ const terminal = readString(candidate.terminal)
+ if (!id || !terminal) {
+ return []
+ }
+ return [
+ {
+ id,
+ terminal,
+ title: readString(candidate.title) ?? 'Terminal'
+ }
+ ]
+ })
+}
+
+export function readMobileReviewCreatedTerminal(value: unknown): MobileReviewTerminalTab | null {
+ if (!isRecord(value) || !isRecord(value.tab)) {
+ return null
+ }
+ return readMobileReviewTerminalTabs({ tabs: [value.tab] })[0] ?? null
+}
+
+export function readMobileReviewTerminalSendAccepted(value: unknown): boolean {
+ if (!isRecord(value) || !isRecord(value.send)) {
+ return true
+ }
+ return value.send.accepted !== false
+}
diff --git a/mobile/src/session/mobile-diff-review-screen-model.test.ts b/mobile/src/session/mobile-diff-review-screen-model.test.ts
new file mode 100644
index 00000000000..d4d22d97323
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-screen-model.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
+import { nextReviewIndexAfterMarkReviewed } from './mobile-diff-review-screen-model'
+
+function item(filePath: string): MobileDiffReviewQueueItem {
+ return {
+ key: `unstaged\0unstaged\0\0${filePath}`,
+ scope: 'unstaged',
+ area: 'unstaged',
+ filePath,
+ status: 'modified',
+ title: filePath,
+ subtitle: 'Unstaged',
+ canStage: true,
+ canUnstage: false,
+ canDiscard: true,
+ isGeneratedOrLockFile: false,
+ diffIdentity: `diff:${filePath}`,
+ noteCount: 0,
+ unsentNoteCount: 0,
+ staleNoteCount: 0,
+ isReviewed: false,
+ changedSinceReview: false
+ }
+}
+
+describe('mobile diff review screen model', () => {
+ it('keeps the next unreviewed file selected after the current file leaves the filter', () => {
+ const queue = [item('a.ts'), item('b.ts'), item('c.ts')]
+
+ expect(
+ nextReviewIndexAfterMarkReviewed({
+ currentIndex: 0,
+ currentItemKey: queue[0].key,
+ filter: 'unreviewed',
+ filteredQueue: queue
+ })
+ ).toBe(0)
+ })
+
+ it('keeps direct next-file indexing for non-removing filters', () => {
+ const queue = [item('a.ts'), item('b.ts'), item('c.ts')]
+
+ expect(
+ nextReviewIndexAfterMarkReviewed({
+ currentIndex: 0,
+ currentItemKey: queue[0].key,
+ filter: 'all',
+ filteredQueue: queue
+ })
+ ).toBe(1)
+ })
+})
diff --git a/mobile/src/session/mobile-diff-review-screen-model.ts b/mobile/src/session/mobile-diff-review-screen-model.ts
new file mode 100644
index 00000000000..3dcaed35716
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-screen-model.ts
@@ -0,0 +1,119 @@
+import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/types'
+import type { MobileGitBranchCompareResult } from '../source-control/mobile-branch-compare'
+import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
+import type { MobileDiffLine } from './mobile-diff-lines'
+import type { MobileDiffHunk } from './mobile-diff-hunks'
+import type {
+ MobileDiffReviewQueueFilter,
+ MobileDiffReviewQueueItem
+} from './mobile-diff-review-queue'
+import type { MobileDiffReviewFileDescriptor } from './mobile-diff-review-state'
+import type { MobileHighlightedDiffLine } from './mobile-file-syntax'
+import type { MobileReviewTerminalTab } from './mobile-diff-review-rpc'
+
+export type ReviewScreenState =
+ | { kind: 'loading' }
+ | {
+ kind: 'ready'
+ status: MobileGitStatusResult
+ branchCompare: MobileGitBranchCompareResult | null
+ branchError?: string
+ comments: DiffComment[]
+ reviewState: MobileDiffReviewState
+ }
+ | { kind: 'unavailable'; message: string }
+ | { kind: 'error'; message: string }
+
+export type ReviewDiffLine = MobileHighlightedDiffLine
+
+export type ReviewDiffState =
+ | { kind: 'idle' }
+ | { kind: 'loading'; itemKey: string }
+ | {
+ kind: 'ready'
+ itemKey: string
+ lines: ReviewDiffLine[]
+ hunks: MobileDiffHunk[]
+ truncated: boolean
+ }
+ | { kind: 'binary'; itemKey: string }
+ | { kind: 'too-large'; itemKey: string; byteLength?: number }
+ | { kind: 'deleted'; itemKey: string }
+ | { kind: 'error'; itemKey: string; message: string }
+
+export type ComposerState =
+ | { mode: 'create'; lineNumber: number }
+ | { mode: 'edit'; comment: DiffComment }
+
+export type SendSheetState =
+ | { kind: 'loading' }
+ | { kind: 'ready'; terminals: MobileReviewTerminalTab[] }
+ | { kind: 'error'; message: string; terminals: MobileReviewTerminalTab[] }
+
+export type GitMutationMethod = 'git.stage' | 'git.unstage' | 'git.discard'
+
+export const REVIEW_FILTERS: MobileDiffReviewQueueFilter[] = [
+ 'all',
+ 'unreviewed',
+ 'notes',
+ 'unstaged',
+ 'staged',
+ 'branch'
+]
+
+export function firstReviewParam(value: string | string[] | undefined): string {
+ return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
+}
+
+export function normalizeReviewFilterParam(value: string): MobileDiffReviewQueueFilter {
+ return REVIEW_FILTERS.includes(value as MobileDiffReviewQueueFilter)
+ ? (value as MobileDiffReviewQueueFilter)
+ : 'all'
+}
+
+export function reviewDescriptorFromItem(
+ item: MobileDiffReviewQueueItem
+): MobileDiffReviewFileDescriptor {
+ return {
+ key: item.key,
+ filePath: item.filePath,
+ oldPath: item.oldPath,
+ scope: item.scope,
+ diffIdentity: item.diffIdentity
+ }
+}
+
+export function nextReviewIndexAfterMarkReviewed({
+ currentIndex,
+ currentItemKey,
+ filter,
+ filteredQueue
+}: {
+ currentIndex: number
+ currentItemKey: string
+ filter: MobileDiffReviewQueueFilter
+ filteredQueue: readonly MobileDiffReviewQueueItem[]
+}): number | null {
+ const nextIndex = filteredQueue.findIndex(
+ (item, index) => index > currentIndex && item.key !== currentItemKey && !item.isReviewed
+ )
+ const wrappedIndex = filteredQueue.findIndex(
+ (item) => item.key !== currentItemKey && !item.isReviewed
+ )
+ const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null
+ if (targetIndex === null) {
+ return null
+ }
+ return filter === 'unreviewed' && targetIndex > currentIndex ? targetIndex - 1 : targetIndex
+}
+
+export function mobileReviewScopeLabel(item: MobileDiffReviewQueueItem): string {
+ if (item.scope === 'branch') {
+ return 'Branch'
+ }
+ return item.scope === 'staged' ? 'Staged' : 'Unstaged'
+}
+
+export function mobileReviewCountLabel(count: number, singular: string, plural: string): string {
+ return `${count} ${count === 1 ? singular : plural}`
+}
diff --git a/mobile/src/session/mobile-diff-review-state.test.ts b/mobile/src/session/mobile-diff-review-state.test.ts
new file mode 100644
index 00000000000..cd76f8f44f1
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-state.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildMobileDiffIdentity,
+ clearMobileDiffReviewFileReviewed,
+ completeMobileDiffReviewState,
+ createMobileDiffReviewState,
+ isMobileDiffReviewFileReviewed,
+ markMobileDiffReviewFileReviewed,
+ mergeMobileDiffReviewState,
+ normalizeMobileDiffReviewState
+} from './mobile-diff-review-state'
+
+const descriptor = {
+ key: 'unstaged\0unstaged\0\0src/app.ts',
+ filePath: 'src/app.ts',
+ scope: 'unstaged',
+ diffIdentity: 'd1'
+} as const
+
+describe('mobile diff review state', () => {
+ it('normalizes persisted review metadata', () => {
+ expect(
+ normalizeMobileDiffReviewState({
+ version: 1,
+ updatedAt: 9,
+ files: {
+ [descriptor.key]: {
+ key: descriptor.key,
+ filePath: 'src/app.ts',
+ scope: 'unstaged',
+ reviewedAt: 10,
+ reviewDiffIdentity: 'd1'
+ },
+ broken: { filePath: '', scope: 'staged' }
+ }
+ })
+ ).toEqual({
+ version: 1,
+ updatedAt: 9,
+ completedAt: undefined,
+ files: {
+ [descriptor.key]: {
+ key: descriptor.key,
+ filePath: 'src/app.ts',
+ oldPath: undefined,
+ scope: 'unstaged',
+ lastOpenedAt: undefined,
+ lastSeenDiffIdentity: undefined,
+ reviewedAt: 10,
+ reviewDiffIdentity: 'd1'
+ }
+ }
+ })
+ })
+
+ it('marks files reviewed against the current diff identity', () => {
+ const state = markMobileDiffReviewFileReviewed(createMobileDiffReviewState(1), descriptor, 5)
+
+ expect(isMobileDiffReviewFileReviewed(state.files[descriptor.key], 'd1')).toBe(true)
+ expect(isMobileDiffReviewFileReviewed(state.files[descriptor.key], 'd2')).toBe(false)
+ })
+
+ it('invalidates reviewed state when refreshed identity changes', () => {
+ const reviewed = markMobileDiffReviewFileReviewed(createMobileDiffReviewState(1), descriptor, 5)
+
+ const merged = mergeMobileDiffReviewState(reviewed, [{ ...descriptor, diffIdentity: 'd2' }], 8)
+
+ expect(merged.files[descriptor.key]?.reviewedAt).toBeUndefined()
+ expect(merged.files[descriptor.key]?.reviewDiffIdentity).toBeUndefined()
+ })
+
+ it('drops completion when a refreshed identity invalidates a reviewed file', () => {
+ const reviewed = markMobileDiffReviewFileReviewed(createMobileDiffReviewState(1), descriptor, 5)
+ const completed = completeMobileDiffReviewState(reviewed, 6)
+
+ const merged = mergeMobileDiffReviewState(completed, [{ ...descriptor, diffIdentity: 'd2' }], 8)
+
+ expect(merged.completedAt).toBeUndefined()
+ })
+
+ it('keeps completion when refreshed identities are unchanged', () => {
+ const reviewed = markMobileDiffReviewFileReviewed(createMobileDiffReviewState(1), descriptor, 5)
+ const completed = completeMobileDiffReviewState(reviewed, 6)
+
+ const merged = mergeMobileDiffReviewState(completed, [descriptor], 8)
+
+ expect(merged.completedAt).toBe(6)
+ expect(merged.files[descriptor.key]?.reviewedAt).toBe(5)
+ })
+
+ it('clears reviewed state for manual unreview', () => {
+ const reviewed = markMobileDiffReviewFileReviewed(createMobileDiffReviewState(1), descriptor, 5)
+
+ const unreviewed = clearMobileDiffReviewFileReviewed(reviewed, descriptor.key, 8)
+
+ expect(unreviewed.files[descriptor.key]?.reviewedAt).toBeUndefined()
+ expect(unreviewed.files[descriptor.key]?.reviewDiffIdentity).toBeUndefined()
+ expect(unreviewed.updatedAt).toBe(8)
+ })
+
+ it('builds stable content identities from ordered parts', () => {
+ expect(buildMobileDiffIdentity(['a', 'b'])).toBe(buildMobileDiffIdentity(['a', 'b']))
+ expect(buildMobileDiffIdentity(['a', 'b'])).not.toBe(buildMobileDiffIdentity(['ab']))
+ })
+})
diff --git a/mobile/src/session/mobile-diff-review-state.ts b/mobile/src/session/mobile-diff-review-state.ts
new file mode 100644
index 00000000000..a9bab23471b
--- /dev/null
+++ b/mobile/src/session/mobile-diff-review-state.ts
@@ -0,0 +1,213 @@
+import type {
+ DiffReviewScope,
+ MobileDiffReviewFileState,
+ MobileDiffReviewState
+} from '../../../src/shared/types'
+
+export type MobileDiffReviewFileDescriptor = {
+ key: string
+ filePath: string
+ oldPath?: string
+ scope: DiffReviewScope
+ diffIdentity: string
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function normalizeScope(value: unknown): DiffReviewScope | null {
+ return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : null
+}
+
+function normalizeFileState(key: string, value: unknown): MobileDiffReviewFileState | null {
+ if (!isRecord(value)) {
+ return null
+ }
+ const filePath = typeof value.filePath === 'string' ? value.filePath : ''
+ const scope = normalizeScope(value.scope)
+ if (!filePath || !scope) {
+ return null
+ }
+ return {
+ key: typeof value.key === 'string' && value.key ? value.key : key,
+ filePath,
+ oldPath: typeof value.oldPath === 'string' ? value.oldPath : undefined,
+ scope,
+ lastOpenedAt: typeof value.lastOpenedAt === 'number' ? value.lastOpenedAt : undefined,
+ lastSeenDiffIdentity:
+ typeof value.lastSeenDiffIdentity === 'string' ? value.lastSeenDiffIdentity : undefined,
+ reviewedAt: typeof value.reviewedAt === 'number' ? value.reviewedAt : undefined,
+ reviewDiffIdentity:
+ typeof value.reviewDiffIdentity === 'string' ? value.reviewDiffIdentity : undefined
+ }
+}
+
+export function normalizeMobileDiffReviewState(value: unknown): MobileDiffReviewState {
+ if (!isRecord(value) || !isRecord(value.files)) {
+ return { version: 1, files: {} }
+ }
+ const files: Record = {}
+ for (const [key, candidate] of Object.entries(value.files)) {
+ const state = normalizeFileState(key, candidate)
+ if (state) {
+ files[state.key] = state
+ }
+ }
+ return {
+ version: 1,
+ updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
+ completedAt: typeof value.completedAt === 'number' ? value.completedAt : undefined,
+ files
+ }
+}
+
+export function createMobileDiffReviewState(now: number): MobileDiffReviewState {
+ return { version: 1, updatedAt: now, files: {} }
+}
+
+export function mergeMobileDiffReviewState(
+ state: MobileDiffReviewState,
+ descriptors: readonly MobileDiffReviewFileDescriptor[],
+ now: number
+): MobileDiffReviewState {
+ const files: Record = { ...state.files }
+ let invalidatedReview = false
+ for (const descriptor of descriptors) {
+ const previous = files[descriptor.key]
+ const changedSinceReview =
+ previous?.reviewedAt !== undefined &&
+ previous.reviewDiffIdentity !== undefined &&
+ previous.reviewDiffIdentity !== descriptor.diffIdentity
+ if (changedSinceReview) {
+ invalidatedReview = true
+ }
+ files[descriptor.key] = {
+ key: descriptor.key,
+ filePath: descriptor.filePath,
+ oldPath: descriptor.oldPath,
+ scope: descriptor.scope,
+ lastOpenedAt: previous?.lastOpenedAt,
+ lastSeenDiffIdentity: previous?.lastSeenDiffIdentity,
+ reviewedAt: changedSinceReview ? undefined : previous?.reviewedAt,
+ reviewDiffIdentity: changedSinceReview ? undefined : previous?.reviewDiffIdentity
+ }
+ }
+ // Why: a file whose diff changed is no longer reviewed, so a prior "review
+ // complete" marker is stale — match markUnreviewed and drop completedAt.
+ return {
+ ...state,
+ version: 1,
+ updatedAt: now,
+ completedAt: invalidatedReview ? undefined : state.completedAt,
+ files
+ }
+}
+
+export function markMobileDiffReviewFileOpened(
+ state: MobileDiffReviewState,
+ descriptor: MobileDiffReviewFileDescriptor,
+ now: number
+): MobileDiffReviewState {
+ const previous = state.files[descriptor.key]
+ return {
+ ...state,
+ updatedAt: now,
+ files: {
+ ...state.files,
+ [descriptor.key]: {
+ key: descriptor.key,
+ filePath: descriptor.filePath,
+ oldPath: descriptor.oldPath,
+ scope: descriptor.scope,
+ reviewedAt: previous?.reviewedAt,
+ reviewDiffIdentity: previous?.reviewDiffIdentity,
+ lastOpenedAt: now,
+ lastSeenDiffIdentity: descriptor.diffIdentity
+ }
+ }
+ }
+}
+
+export function markMobileDiffReviewFileReviewed(
+ state: MobileDiffReviewState,
+ descriptor: MobileDiffReviewFileDescriptor,
+ now: number
+): MobileDiffReviewState {
+ return {
+ ...state,
+ updatedAt: now,
+ files: {
+ ...state.files,
+ [descriptor.key]: {
+ key: descriptor.key,
+ filePath: descriptor.filePath,
+ oldPath: descriptor.oldPath,
+ scope: descriptor.scope,
+ lastOpenedAt: state.files[descriptor.key]?.lastOpenedAt,
+ lastSeenDiffIdentity: descriptor.diffIdentity,
+ reviewedAt: now,
+ reviewDiffIdentity: descriptor.diffIdentity
+ }
+ }
+ }
+}
+
+export function clearMobileDiffReviewFileReviewed(
+ state: MobileDiffReviewState,
+ key: string,
+ now: number
+): MobileDiffReviewState {
+ const previous = state.files[key]
+ if (!previous) {
+ return state
+ }
+ return {
+ ...state,
+ updatedAt: now,
+ files: {
+ ...state.files,
+ [key]: {
+ ...previous,
+ reviewedAt: undefined,
+ reviewDiffIdentity: undefined
+ }
+ }
+ }
+}
+
+export function completeMobileDiffReviewState(
+ state: MobileDiffReviewState,
+ now: number
+): MobileDiffReviewState {
+ return { ...state, updatedAt: now, completedAt: now }
+}
+
+export function isMobileDiffReviewFileReviewed(
+ fileState: MobileDiffReviewFileState | undefined,
+ diffIdentity: string
+): boolean {
+ return fileState?.reviewedAt !== undefined && fileState.reviewDiffIdentity === diffIdentity
+}
+
+export function didMobileDiffReviewFileChangeSinceReview(
+ fileState: MobileDiffReviewFileState | undefined,
+ diffIdentity: string
+): boolean {
+ return (
+ fileState?.reviewedAt !== undefined &&
+ fileState.reviewDiffIdentity !== undefined &&
+ fileState.reviewDiffIdentity !== diffIdentity
+ )
+}
+
+export function buildMobileDiffIdentity(parts: readonly string[]): string {
+ let hash = 2166136261
+ for (const part of parts) {
+ hash = Math.imul(hash ^ part.length, 16777619)
+ for (let index = 0; index < part.length; index += 1) {
+ hash = Math.imul(hash ^ part.charCodeAt(index), 16777619)
+ }
+ }
+ return `d${(hash >>> 0).toString(36)}`
+}
diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts
new file mode 100644
index 00000000000..3a94348aab4
--- /dev/null
+++ b/mobile/src/session/mobile-image-attachment.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { attachMobileImageToTerminal } from './mobile-image-attachment'
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('attachMobileImageToTerminal', () => {
+ it('uploads the picked image and pastes its bracketed path into the terminal', async () => {
+ // startImageUpload (method_not_found) falls back to single-frame saveImageAsTempFile.
+ const client = clientWithResponses([
+ {
+ id: 'start',
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ },
+ ok('save', '/tmp/orca-attach.png'),
+ ok('send', { ok: true })
+ ])
+
+ const sent = await attachMobileImageToTerminal('library', {
+ client,
+ terminal: 'term-1',
+ deviceToken: 'device-9',
+ getConnectionId: async () => 'conn-7',
+ pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA' })
+ })
+
+ expect(sent).toBe(true)
+ const sendCall = client.calls.find((c) => c.method === 'terminal.send')
+ expect(sendCall?.params).toEqual({
+ terminal: 'term-1',
+ text: '\x1b[200~/tmp/orca-attach.png\x1b[201~',
+ enter: false,
+ client: { id: 'device-9', type: 'mobile' }
+ })
+ })
+
+ it('passes the active worktree connectionId to the upload', async () => {
+ const client = clientWithResponses([
+ {
+ id: 'start',
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ },
+ ok('save', '/tmp/x.png'),
+ ok('send', { ok: true })
+ ])
+
+ await attachMobileImageToTerminal('files', {
+ client,
+ terminal: 'term-1',
+ deviceToken: null,
+ getConnectionId: async () => 'conn-ssh',
+ pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' })
+ })
+
+ const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile')
+ expect(saveCall?.params).toMatchObject({ connectionId: 'conn-ssh' })
+ })
+
+ it('does nothing and returns false when the picker is cancelled', async () => {
+ const client = clientWithResponses([])
+
+ const sent = await attachMobileImageToTerminal('library', {
+ client,
+ terminal: 'term-1',
+ deviceToken: null,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue(null)
+ })
+
+ expect(sent).toBe(false)
+ expect(client.calls).toEqual([])
+ })
+
+ it('omits the client field when there is no device token', async () => {
+ const client = clientWithResponses([
+ {
+ id: 'start',
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ },
+ ok('save', '/tmp/y.png'),
+ ok('send', { ok: true })
+ ])
+
+ await attachMobileImageToTerminal('library', {
+ client,
+ terminal: 'term-2',
+ deviceToken: null,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC' })
+ })
+
+ const sendCall = client.calls.find((c) => c.method === 'terminal.send')
+ expect(sendCall?.params).not.toHaveProperty('client')
+ })
+})
diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts
new file mode 100644
index 00000000000..507b58eb671
--- /dev/null
+++ b/mobile/src/session/mobile-image-attachment.ts
@@ -0,0 +1,55 @@
+import type { RpcClient } from '../transport/rpc-client'
+import {
+ buildMobileImagePastePayload,
+ saveMobileClipboardImageAsTempFile
+} from './mobile-clipboard-image'
+import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
+
+export type AttachMobileImageDeps = {
+ readonly client: Pick
+ readonly terminal: string
+ readonly deviceToken: string | null
+ readonly getConnectionId: () => Promise
+ // Injected so this module stays free of expo/react-native imports (and unit-testable).
+ readonly pickImage: (source: MobileImageSource) => Promise
+ // Fired once the user has picked an image and the host upload is about to
+ // start — lets the UI show a sending spinner only for the transfer, not the
+ // (potentially long) time the picker is open.
+ readonly onUploadStart?: () => void
+}
+
+// Uploads a picked image to the host and pastes the resulting file path into the
+// active terminal — the same bracketed-path payload desktop image paste sends, so
+// TUIs (Claude Code, etc.) attach it exactly as a desktop paste. Returns false
+// when the user cancelled the picker.
+export async function attachMobileImageToTerminal(
+ source: MobileImageSource,
+ {
+ client,
+ terminal,
+ deviceToken,
+ getConnectionId,
+ pickImage,
+ onUploadStart
+ }: AttachMobileImageDeps
+): Promise {
+ const picked = await pickImage(source)
+ if (!picked) {
+ return false
+ }
+ onUploadStart?.()
+ const connectionId = await getConnectionId()
+ const imagePath = await saveMobileClipboardImageAsTempFile(client, picked.base64, {
+ connectionId
+ })
+ // Why: a generated image path is terminal image injection, so it's always
+ // bracketed (matching desktop paste) regardless of terminal mode.
+ const payload = buildMobileImagePastePayload(imagePath)
+ await client.sendRequest('terminal.send', {
+ terminal,
+ text: payload,
+ enter: false,
+ ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {})
+ })
+ return true
+}
diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts
new file mode 100644
index 00000000000..3e35a7bed61
--- /dev/null
+++ b/mobile/src/session/mobile-image-source-picker.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('expo-image-picker', () => ({
+ requestMediaLibraryPermissionsAsync: vi.fn(),
+ launchImageLibraryAsync: vi.fn()
+}))
+vi.mock('expo-document-picker', () => ({
+ getDocumentAsync: vi.fn()
+}))
+
+import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
+
+const granted = { granted: true } as Awaited<
+ ReturnType
+>
+const denied = { granted: false } as typeof granted
+
+describe('pickMobileImage', () => {
+ it('returns base64 from the photo library', async () => {
+ const result = await pickMobileImage('library', {
+ requestLibraryPermission: vi.fn().mockResolvedValue(granted),
+ launchLibrary: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
+ })
+ })
+
+ expect(result).toEqual({ base64: 'AAAA' })
+ })
+
+ it('throws when photo library permission is denied', async () => {
+ await expect(
+ pickMobileImage('library', {
+ requestLibraryPermission: vi.fn().mockResolvedValue(denied),
+ launchLibrary: vi.fn()
+ })
+ ).rejects.toBeInstanceOf(ImageLibraryPermissionError)
+ })
+
+ it('returns null when the library picker is cancelled', async () => {
+ const result = await pickMobileImage('library', {
+ requestLibraryPermission: vi.fn().mockResolvedValue(granted),
+ launchLibrary: vi.fn().mockResolvedValue({ canceled: true, assets: null })
+ })
+
+ expect(result).toBeNull()
+ })
+
+ it('reads a picked file URI into base64 for the files source', async () => {
+ const bytes = new Uint8Array([1, 2, 3, 4])
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
+
+ const result = await pickMobileImage('files', {
+ launchFiles: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///doc.png' }]
+ })
+ })
+
+ expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64') })
+ fetchSpy.mockRestore()
+ })
+
+ it('returns null when the files picker is cancelled', async () => {
+ const result = await pickMobileImage('files', {
+ launchFiles: vi.fn().mockResolvedValue({ canceled: true, assets: null })
+ })
+
+ expect(result).toBeNull()
+ })
+})
diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts
new file mode 100644
index 00000000000..21231b7ad36
--- /dev/null
+++ b/mobile/src/session/mobile-image-source-picker.ts
@@ -0,0 +1,84 @@
+import { Buffer } from 'buffer'
+import * as DocumentPicker from 'expo-document-picker'
+import * as ImagePicker from 'expo-image-picker'
+
+export type MobileImageSource = 'library' | 'files'
+
+export type PickedMobileImage = {
+ // Raw base64 (no data: prefix); fed straight into the existing upload pipeline.
+ readonly base64: string
+}
+
+export class ImageLibraryPermissionError extends Error {
+ constructor() {
+ super('Photo library permission denied')
+ this.name = 'ImageLibraryPermissionError'
+ }
+}
+
+// Why: expo-document-picker returns a file URI, not base64. Read it through
+// fetch + Buffer so we match the base64 contract the upload pipeline expects
+// without pulling in expo-file-system.
+async function readUriAsBase64(uri: string): Promise {
+ const response = await fetch(uri)
+ const bytes = new Uint8Array(await response.arrayBuffer())
+ return Buffer.from(bytes).toString('base64')
+}
+
+async function pickFromLibrary(
+ requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
+ launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
+): Promise {
+ const permission = await requestPermission()
+ // Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
+ if (!permission.granted) {
+ throw new ImageLibraryPermissionError()
+ }
+ const result = await launch({
+ mediaTypes: ['images'],
+ base64: true,
+ allowsMultipleSelection: false,
+ quality: 1
+ })
+ if (result.canceled) {
+ return null
+ }
+ const asset = result.assets[0]
+ const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
+ if (!base64) {
+ return null
+ }
+ return { base64 }
+}
+
+async function pickFromFiles(
+ launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
+): Promise {
+ const result = await launch({
+ type: 'image/*',
+ multiple: false,
+ copyToCacheDirectory: true
+ })
+ if (result.canceled) {
+ return null
+ }
+ const asset = result.assets[0]
+ if (!asset?.uri) {
+ return null
+ }
+ return { base64: await readUriAsBase64(asset.uri) }
+}
+
+export async function pickMobileImage(
+ source: MobileImageSource,
+ deps?: {
+ readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
+ readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
+ readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
+ }
+): Promise {
+ if (source === 'library') {
+ return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
+ }
+ return pickFromFiles(deps?.launchFiles)
+}
diff --git a/mobile/src/session/mobile-markdown-disk-fallback.test.ts b/mobile/src/session/mobile-markdown-disk-fallback.test.ts
new file mode 100644
index 00000000000..04de6c7f14f
--- /dev/null
+++ b/mobile/src/session/mobile-markdown-disk-fallback.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+import type { RpcFailure } from '../transport/types'
+import {
+ buildMarkdownDiskFallbackDoc,
+ shouldReadMarkdownFromDiskAfterReadTabFailure
+} from './mobile-markdown-disk-fallback'
+
+function failure(code: string, message: string): RpcFailure {
+ return {
+ id: 'request-1',
+ ok: false,
+ error: { code, message },
+ _meta: { runtimeId: 'runtime-1' }
+ }
+}
+
+describe('shouldReadMarkdownFromDiskAfterReadTabFailure', () => {
+ it('allows disk reads for current renderer unavailable runtime errors', () => {
+ expect(
+ shouldReadMarkdownFromDiskAfterReadTabFailure(
+ failure('runtime_error', 'renderer_unavailable')
+ )
+ ).toBe(true)
+ })
+
+ it('allows disk reads if renderer unavailable becomes a passthrough code', () => {
+ expect(
+ shouldReadMarkdownFromDiskAfterReadTabFailure(
+ failure('renderer_unavailable', 'renderer_unavailable')
+ )
+ ).toBe(true)
+ })
+
+ it('does not hide unrelated markdown read failures behind a disk read', () => {
+ expect(
+ shouldReadMarkdownFromDiskAfterReadTabFailure(failure('runtime_error', 'tab_not_found'))
+ ).toBe(false)
+ expect(
+ shouldReadMarkdownFromDiskAfterReadTabFailure(failure('invalid_argument', 'bad tab'))
+ ).toBe(false)
+ })
+})
+
+describe('buildMarkdownDiskFallbackDoc', () => {
+ it('builds a read-only markdown document from disk content', () => {
+ expect(
+ buildMarkdownDiskFallbackDoc({
+ content: '# Notes',
+ truncated: false,
+ tabIsDirty: false
+ })
+ ).toEqual({
+ status: 'ready',
+ content: '# Notes',
+ localContent: '# Notes',
+ baseVersion: '',
+ isDirty: false,
+ editable: false,
+ stale: false,
+ readOnlyReason: 'Editing needs Orca desktop running.'
+ })
+ })
+
+ it('marks disk content stale when the desktop tab has unsaved changes', () => {
+ expect(
+ buildMarkdownDiskFallbackDoc({
+ content: '# Notes',
+ truncated: false,
+ tabIsDirty: true
+ })
+ ).toMatchObject({
+ editable: false,
+ stale: true,
+ readOnlyReason: 'Desktop has unsaved changes. Showing disk content.'
+ })
+ })
+
+ it('warns when the disk read is truncated', () => {
+ expect(
+ buildMarkdownDiskFallbackDoc({
+ content: '# Partial',
+ truncated: true,
+ tabIsDirty: true
+ })
+ ).toMatchObject({
+ editable: false,
+ stale: true,
+ readOnlyReason: 'File too large for mobile preview'
+ })
+ })
+})
diff --git a/mobile/src/session/mobile-markdown-disk-fallback.ts b/mobile/src/session/mobile-markdown-disk-fallback.ts
new file mode 100644
index 00000000000..9216d526ca2
--- /dev/null
+++ b/mobile/src/session/mobile-markdown-disk-fallback.ts
@@ -0,0 +1,32 @@
+import type { RpcFailure } from '../transport/types'
+
+const RENDERER_UNAVAILABLE = 'renderer_unavailable'
+
+export function shouldReadMarkdownFromDiskAfterReadTabFailure(response: RpcFailure): boolean {
+ return (
+ response.error.code === RENDERER_UNAVAILABLE ||
+ (response.error.code === 'runtime_error' && response.error.message === RENDERER_UNAVAILABLE)
+ )
+}
+
+export function buildMarkdownDiskFallbackDoc(args: {
+ content: string
+ truncated: boolean
+ tabIsDirty: boolean
+}) {
+ const readOnlyReason = args.truncated
+ ? 'File too large for mobile preview'
+ : args.tabIsDirty
+ ? 'Desktop has unsaved changes. Showing disk content.'
+ : 'Editing needs Orca desktop running.'
+ return {
+ status: 'ready' as const,
+ content: args.content,
+ localContent: args.content,
+ baseVersion: '',
+ isDirty: false,
+ editable: false,
+ stale: args.tabIsDirty,
+ readOnlyReason
+ }
+}
diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts
new file mode 100644
index 00000000000..e5a8d4329b7
--- /dev/null
+++ b/mobile/src/session/mobile-session-route-helpers.ts
@@ -0,0 +1,35 @@
+import type { TerminalModes } from '../terminal/TerminalWebView'
+import type { ConnectionState } from '../transport/types'
+
+export const MOBILE_SESSION_STATUS_LABELS: Record = {
+ connecting: 'Connecting',
+ handshaking: 'Securing',
+ connected: 'Connected',
+ disconnected: 'Disconnected',
+ reconnecting: 'Reconnecting',
+ 'auth-failed': 'Auth failed'
+}
+
+export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
+export const TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND = 120
+export const TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS = 16
+export const TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES = 32
+export const TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS = 250
+
+export function isFileExistsErrorMessage(message: string): boolean {
+ const normalized = message.toLowerCase()
+ return normalized.includes('eexist') || normalized.includes('already exists')
+}
+
+export function getRepoIdFromMobileWorktreeId(id: string): string {
+ // Why: mobile cannot import desktop shared modules in its standalone tsc run,
+ // but the runtime worktree id wire format is still `${repoId}::${path}`.
+ const separatorIdx = id.indexOf('::')
+ return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
+}
+
+export function isGestureMouseTrackingMode(
+ mode: TerminalModes['mouseTrackingMode'] | undefined
+): boolean {
+ return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
+}
diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts
index cff24aeb0df..a93080e3863 100644
--- a/mobile/src/session/mobile-session-startup-source.test.ts
+++ b/mobile/src/session/mobile-session-startup-source.test.ts
@@ -28,4 +28,18 @@ describe('mobile session startup', () => {
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
+
+ it('keeps dynamic agent rows above fixed New Tab actions', () => {
+ const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')
+
+ expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan(
+ newTabActions.indexOf("label: 'Terminal'")
+ )
+ expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan(
+ newTabActions.indexOf("label: 'Browser'")
+ )
+ expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan(
+ newTabActions.indexOf("label: 'Markdown Note'")
+ )
+ })
})
diff --git a/mobile/src/session/tab-strip-scroll.test.ts b/mobile/src/session/tab-strip-scroll.test.ts
new file mode 100644
index 00000000000..fab517a3155
--- /dev/null
+++ b/mobile/src/session/tab-strip-scroll.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import { resolveTabStripScrollOffset } from './tab-strip-scroll'
+
+describe('resolveTabStripScrollOffset', () => {
+ it('keeps the offset when the active tab is already fully visible', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 140,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 800,
+ currentOffset: 100
+ })
+ ).toBe(100)
+ })
+
+ it('scrolls left to reveal a tab off the left edge', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 50,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 800,
+ currentOffset: 200,
+ margin: 12
+ })
+ ).toBe(38)
+ })
+
+ it('scrolls right to reveal a tab off the right edge', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 640,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 900,
+ currentOffset: 0,
+ margin: 12
+ })
+ ).toBe(420)
+ })
+
+ it('clamps the offset to the content bounds', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 880,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 900,
+ currentOffset: 0
+ })
+ ).toBe(540)
+ })
+
+ it('returns the current offset when the viewport has not been measured', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 100,
+ tabWidth: 128,
+ viewportWidth: 0,
+ contentWidth: 0,
+ currentOffset: 0
+ })
+ ).toBe(0)
+ })
+})
diff --git a/mobile/src/session/tab-strip-scroll.ts b/mobile/src/session/tab-strip-scroll.ts
new file mode 100644
index 00000000000..275b9ed5f57
--- /dev/null
+++ b/mobile/src/session/tab-strip-scroll.ts
@@ -0,0 +1,40 @@
+export type TabStripScrollInput = {
+ tabX: number
+ tabWidth: number
+ viewportWidth: number
+ contentWidth: number
+ currentOffset: number
+ margin?: number
+}
+
+/**
+ * Keep active-tab reveal deterministic across async RN layout events without
+ * nudging the strip when the tab is already visible.
+ */
+export function resolveTabStripScrollOffset({
+ tabX,
+ tabWidth,
+ viewportWidth,
+ contentWidth,
+ currentOffset,
+ margin = 12
+}: TabStripScrollInput): number {
+ const maxOffset = Math.max(0, contentWidth - viewportWidth)
+ if (viewportWidth <= 0) {
+ return currentOffset
+ }
+
+ const visibleStart = currentOffset
+ const visibleEnd = currentOffset + viewportWidth
+ const tabStart = tabX
+ const tabEnd = tabX + tabWidth
+
+ let nextOffset = currentOffset
+ if (tabStart < visibleStart + margin) {
+ nextOffset = tabStart - margin
+ } else if (tabEnd > visibleEnd - margin) {
+ nextOffset = tabEnd + margin - viewportWidth
+ }
+
+ return Math.min(Math.max(0, nextOffset), maxOffset)
+}
diff --git a/mobile/src/session/use-mobile-diff-review-comment-actions.ts b/mobile/src/session/use-mobile-diff-review-comment-actions.ts
new file mode 100644
index 00000000000..252eaf0843e
--- /dev/null
+++ b/mobile/src/session/use-mobile-diff-review-comment-actions.ts
@@ -0,0 +1,241 @@
+import { useCallback, type Dispatch, type SetStateAction } from 'react'
+import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/types'
+import { triggerError, triggerSuccess } from '../platform/haptics'
+import type { ConnectionState } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { addMobileDiffComment, removeMobileDiffComments } from './mobile-diff-comments'
+import { updateMobileDiffComment } from './mobile-diff-comment-edit'
+import {
+ clearMobileDiffReviewFileReviewed,
+ completeMobileDiffReviewState,
+ markMobileDiffReviewFileReviewed
+} from './mobile-diff-review-state'
+import type {
+ MobileDiffReviewQueueFilter,
+ MobileDiffReviewQueueItem
+} from './mobile-diff-review-queue'
+import type { ComposerState, ReviewScreenState } from './mobile-diff-review-screen-model'
+import {
+ nextReviewIndexAfterMarkReviewed,
+ reviewDescriptorFromItem
+} from './mobile-diff-review-screen-model'
+
+type CommentActionsInput = {
+ client: RpcClient | null
+ connState: ConnectionState
+ worktreeId: string
+ screenState: ReviewScreenState
+ currentItem: MobileDiffReviewQueueItem | null
+ queue: MobileDiffReviewQueueItem[]
+ filteredQueue: MobileDiffReviewQueueItem[]
+ filter: MobileDiffReviewQueueFilter
+ currentIndex: number
+ composer: ComposerState | null
+ composerBody: string
+ setScreenState: Dispatch>
+ setCurrentIndex: Dispatch>
+ setComposer: Dispatch>
+ setComposerBody: Dispatch>
+ setActionError: Dispatch>
+ setShowCompletion: Dispatch>
+}
+
+export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
+ const {
+ client,
+ connState,
+ worktreeId,
+ screenState,
+ currentItem,
+ queue,
+ filteredQueue,
+ filter,
+ currentIndex,
+ composer,
+ composerBody,
+ setScreenState,
+ setCurrentIndex,
+ setComposer,
+ setComposerBody,
+ setActionError,
+ setShowCompletion
+ } = input
+
+ const persistMetadata = useCallback(
+ async (comments: readonly DiffComment[], reviewState: MobileDiffReviewState) => {
+ if (!client || connState !== 'connected') {
+ throw new Error('Waiting for desktop...')
+ }
+ const response = await client.sendRequest('worktree.set', {
+ worktree: `id:${worktreeId}`,
+ diffComments: comments,
+ mobileDiffReview: reviewState
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to save review state')
+ }
+ },
+ [client, connState, worktreeId]
+ )
+
+ const updateReadyState = useCallback(
+ (updater: (state: Extract) => ReviewScreenState) => {
+ setScreenState((prev) => (prev.kind === 'ready' ? updater(prev) : prev))
+ },
+ [setScreenState]
+ )
+
+ const saveCommentsAndReviewState = useCallback(
+ async (comments: DiffComment[], reviewState: MobileDiffReviewState) => {
+ const previous = screenState
+ updateReadyState((state) => ({ ...state, comments, reviewState }))
+ try {
+ await persistMetadata(comments, reviewState)
+ triggerSuccess()
+ } catch (err) {
+ if (previous.kind === 'ready') {
+ setScreenState(previous)
+ }
+ triggerError()
+ setActionError(err instanceof Error ? err.message : 'Failed to save review')
+ throw err
+ }
+ },
+ [persistMetadata, screenState, setActionError, setScreenState, updateReadyState]
+ )
+
+ const openComposer = useCallback(
+ (lineNumber: number) => {
+ setComposer({ mode: 'create', lineNumber })
+ setComposerBody('')
+ },
+ [setComposer, setComposerBody]
+ )
+
+ const openEditComposer = useCallback(
+ (comment: DiffComment) => {
+ setComposer({ mode: 'edit', comment })
+ setComposerBody(comment.body)
+ },
+ [setComposer, setComposerBody]
+ )
+
+ const closeComposer = useCallback(() => {
+ setComposer(null)
+ setComposerBody('')
+ }, [setComposer, setComposerBody])
+
+ const saveComposer = useCallback(async () => {
+ if (!composer || !currentItem || screenState.kind !== 'ready') {
+ return
+ }
+ const now = Date.now()
+ const result =
+ composer.mode === 'edit'
+ ? updateMobileDiffComment(screenState.comments, {
+ id: composer.comment.id,
+ body: composerBody,
+ updatedAt: now
+ })
+ : addMobileDiffComment(screenState.comments, {
+ id: `mobile-${now}-${Math.random().toString(36).slice(2)}`,
+ worktreeId,
+ filePath: currentItem.filePath,
+ oldPath: currentItem.oldPath,
+ lineNumber: composer.lineNumber,
+ body: composerBody,
+ createdAt: now,
+ scope: currentItem.scope,
+ diffIdentity: currentItem.diffIdentity
+ })
+ if (!result.comment) {
+ return
+ }
+ await saveCommentsAndReviewState(result.comments, screenState.reviewState)
+ closeComposer()
+ }, [
+ closeComposer,
+ composer,
+ composerBody,
+ currentItem,
+ saveCommentsAndReviewState,
+ screenState,
+ worktreeId
+ ])
+
+ const deleteComment = useCallback(async () => {
+ if (!composer || composer.mode !== 'edit' || screenState.kind !== 'ready') {
+ return
+ }
+ const nextComments = removeMobileDiffComments(
+ screenState.comments,
+ new Set([composer.comment.id])
+ )
+ await saveCommentsAndReviewState(nextComments, screenState.reviewState)
+ closeComposer()
+ }, [closeComposer, composer, saveCommentsAndReviewState, screenState])
+
+ const markReviewed = useCallback(async () => {
+ if (!currentItem || screenState.kind !== 'ready') {
+ return
+ }
+ const now = Date.now()
+ let nextReviewState = markMobileDiffReviewFileReviewed(
+ screenState.reviewState,
+ reviewDescriptorFromItem(currentItem),
+ now
+ )
+ if (queue.every((item) => item.key === currentItem.key || item.isReviewed)) {
+ nextReviewState = completeMobileDiffReviewState(nextReviewState, now)
+ }
+ await saveCommentsAndReviewState(screenState.comments, nextReviewState)
+ const nextIndex = nextReviewIndexAfterMarkReviewed({
+ currentIndex,
+ currentItemKey: currentItem.key,
+ filter,
+ filteredQueue
+ })
+ if (nextIndex !== null) {
+ setCurrentIndex(nextIndex)
+ } else {
+ setShowCompletion(true)
+ }
+ }, [
+ currentIndex,
+ currentItem,
+ filter,
+ filteredQueue,
+ queue,
+ saveCommentsAndReviewState,
+ screenState,
+ setCurrentIndex,
+ setShowCompletion
+ ])
+
+ const markUnreviewed = useCallback(async () => {
+ if (!currentItem || screenState.kind !== 'ready') {
+ return
+ }
+ const now = Date.now()
+ const nextReviewState = clearMobileDiffReviewFileReviewed(
+ screenState.reviewState,
+ currentItem.key,
+ now
+ )
+ await saveCommentsAndReviewState(screenState.comments, {
+ ...nextReviewState,
+ completedAt: undefined
+ })
+ }, [currentItem, saveCommentsAndReviewState, screenState])
+
+ return {
+ closeComposer,
+ deleteComment,
+ markReviewed,
+ markUnreviewed,
+ openComposer,
+ openEditComposer,
+ saveCommentsAndReviewState,
+ saveComposer
+ }
+}
diff --git a/mobile/src/session/use-mobile-diff-review-controller.ts b/mobile/src/session/use-mobile-diff-review-controller.ts
new file mode 100644
index 00000000000..8a78045eda2
--- /dev/null
+++ b/mobile/src/session/use-mobile-diff-review-controller.ts
@@ -0,0 +1,266 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { FlatList } from 'react-native'
+import type { DiffComment } from '../../../src/shared/types'
+import type { ConnectionState } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { getWorktreeLabel } from './worktree-label'
+import { getUnsentMobileDiffComments } from './mobile-diff-comment-edit'
+import {
+ buildMobileDiffReviewQueue,
+ filterMobileDiffReviewQueue,
+ mobileDiffReviewCommentMatchesItem,
+ type MobileDiffReviewQueueFilter,
+ type MobileDiffReviewQueueItem
+} from './mobile-diff-review-queue'
+import {
+ loadMobileDiffReviewDiff,
+ loadMobileDiffReviewSnapshot
+} from './mobile-diff-review-loaders'
+import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare'
+import type {
+ ComposerState,
+ ReviewDiffLine,
+ ReviewDiffState,
+ ReviewScreenState,
+ SendSheetState
+} from './mobile-diff-review-screen-model'
+import { useMobileDiffReviewInteractions } from './use-mobile-diff-review-interactions'
+
+type ControllerInput = {
+ client: RpcClient | null
+ connState: ConnectionState
+ hostId: string
+ worktreeId: string
+ name: string
+ initialFilter: MobileDiffReviewQueueFilter
+ onOpenSession: () => void
+ onReconnect: (hostId: string) => void | Promise
+}
+
+export function useMobileDiffReviewController(input: ControllerInput) {
+ const { client, connState, hostId, worktreeId, name, initialFilter, onOpenSession, onReconnect } =
+ input
+ const listRef = useRef | null>(null)
+ const loadGenerationRef = useRef(0)
+ const [screenState, setScreenState] = useState({ kind: 'loading' })
+ const [diffState, setDiffState] = useState({ kind: 'idle' })
+ const [filter, setFilter] = useState(initialFilter)
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [activeHunkIndex, setActiveHunkIndex] = useState(null)
+ const [composer, setComposer] = useState(null)
+ const [composerBody, setComposerBody] = useState('')
+ const [actionError, setActionError] = useState(null)
+ const [busyAction, setBusyAction] = useState(null)
+ const [discardTarget, setDiscardTarget] = useState(null)
+ const [showOverflow, setShowOverflow] = useState(false)
+ const [sendSheet, setSendSheet] = useState(null)
+ const [showCompletion, setShowCompletion] = useState(false)
+ const worktreeLabel = getWorktreeLabel(name, worktreeId)
+
+ const loadReviewData = useCallback(async () => {
+ const generation = loadGenerationRef.current + 1
+ loadGenerationRef.current = generation
+ const isCurrent = () => generation === loadGenerationRef.current
+ if (!worktreeId) {
+ setScreenState({ kind: 'error', message: 'Missing worktree' })
+ return
+ }
+ if (!client || connState !== 'connected') {
+ setScreenState({ kind: 'error', message: 'Waiting for desktop...' })
+ return
+ }
+ setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
+ try {
+ const nextState = await loadMobileDiffReviewSnapshot(client, worktreeId)
+ if (!isCurrent()) {
+ return
+ }
+ setScreenState(nextState)
+ setActionError(nextState.kind === 'ready' ? (nextState.branchError ?? null) : null)
+ } catch (err) {
+ if (isCurrent()) {
+ setScreenState({
+ kind: 'error',
+ message: err instanceof Error ? err.message : 'Unable to load review'
+ })
+ }
+ }
+ }, [client, connState, worktreeId])
+
+ useEffect(() => {
+ void loadReviewData()
+ }, [loadReviewData])
+
+ const queue = useMemo(() => {
+ if (screenState.kind !== 'ready') {
+ return []
+ }
+ const branchEntries =
+ screenState.branchCompare && canOpenMobileBranchCompareDiff(screenState.branchCompare.summary)
+ ? screenState.branchCompare.entries
+ : []
+ return buildMobileDiffReviewQueue({
+ worktreeId,
+ statusEntries: screenState.status.entries,
+ branchEntries,
+ branchHeadOid: screenState.branchCompare?.summary.headOid,
+ branchMergeBase: screenState.branchCompare?.summary.mergeBase,
+ comments: screenState.comments,
+ reviewState: screenState.reviewState
+ })
+ }, [screenState, worktreeId])
+
+ const filteredQueue = useMemo(() => filterMobileDiffReviewQueue(queue, filter), [filter, queue])
+ const currentItem = filteredQueue[currentIndex] ?? null
+ const reviewedCount = queue.filter((item) => item.isReviewed).length
+ const unsentComments =
+ screenState.kind === 'ready' ? getUnsentMobileDiffComments(screenState.comments) : []
+ const reviewedUnstagedCount = queue.filter(
+ (item) => item.scope === 'unstaged' && item.isReviewed && item.canStage
+ ).length
+
+ useEffect(() => {
+ if (filteredQueue.length === 0) {
+ setCurrentIndex(0)
+ return
+ }
+ if (currentIndex >= filteredQueue.length) {
+ setCurrentIndex(filteredQueue.length - 1)
+ }
+ }, [currentIndex, filteredQueue.length])
+
+ useEffect(() => {
+ setActiveHunkIndex(null)
+ if (!currentItem || screenState.kind !== 'ready') {
+ setDiffState({ kind: 'idle' })
+ return
+ }
+ if (!client || connState !== 'connected') {
+ setDiffState({ kind: 'error', itemKey: currentItem.key, message: 'Waiting for desktop...' })
+ return
+ }
+ let stale = false
+ setDiffState({ kind: 'loading', itemKey: currentItem.key })
+ void loadMobileDiffReviewDiff({
+ client,
+ worktreeId,
+ item: currentItem,
+ branchCompare: screenState.branchCompare
+ })
+ .then((nextState) => {
+ if (!stale) {
+ setDiffState(nextState)
+ }
+ })
+ .catch((err: unknown) => {
+ if (!stale) {
+ setDiffState({
+ kind: 'error',
+ itemKey: currentItem.key,
+ message: err instanceof Error ? err.message : 'Unable to load diff'
+ })
+ }
+ })
+ return () => {
+ stale = true
+ }
+ }, [client, connState, currentItem, screenState, worktreeId])
+
+ const commentsForCurrentItem = useMemo(() => {
+ if (!currentItem || screenState.kind !== 'ready') {
+ return []
+ }
+ return screenState.comments.filter((comment) =>
+ mobileDiffReviewCommentMatchesItem(comment, currentItem)
+ )
+ }, [currentItem, screenState])
+
+ const staleCommentIds = useMemo(
+ () =>
+ new Set(
+ commentsForCurrentItem
+ .filter(
+ (comment) =>
+ currentItem &&
+ comment.diffIdentity !== undefined &&
+ comment.diffIdentity !== currentItem.diffIdentity
+ )
+ .map((comment) => comment.id)
+ ),
+ [commentsForCurrentItem, currentItem]
+ )
+
+ const commentsByLine = useMemo(() => {
+ const map = new Map()
+ for (const comment of commentsForCurrentItem) {
+ const list = map.get(comment.lineNumber) ?? []
+ list.push(comment)
+ map.set(comment.lineNumber, list)
+ }
+ return map
+ }, [commentsForCurrentItem])
+
+ const interactions = useMobileDiffReviewInteractions({
+ client,
+ connState,
+ hostId,
+ worktreeId,
+ screenState,
+ diffState,
+ currentItem,
+ queue,
+ filteredQueue,
+ filter,
+ currentIndex,
+ activeHunkIndex,
+ composer,
+ composerBody,
+ listRef,
+ setScreenState,
+ setFilter,
+ setCurrentIndex,
+ setActiveHunkIndex,
+ setComposer,
+ setComposerBody,
+ setActionError,
+ setBusyAction,
+ setSendSheet,
+ setShowCompletion,
+ loadReviewData,
+ onOpenSession,
+ onReconnect
+ })
+
+ return {
+ ...interactions,
+ actionError,
+ activeHunkIndex,
+ busyAction,
+ commentsByLine,
+ composer,
+ composerBody,
+ currentIndex,
+ currentItem,
+ diffState,
+ discardTarget,
+ fileNotes: commentsByLine.get(0) ?? [],
+ filter,
+ filteredQueue,
+ listRef,
+ queue,
+ reviewedCount,
+ reviewedUnstagedCount,
+ screenState,
+ sendSheet,
+ setComposerBody,
+ setDiscardTarget,
+ setSendSheet,
+ setShowCompletion,
+ setShowOverflow,
+ showCompletion,
+ showOverflow,
+ staleCommentIds,
+ unsentComments,
+ worktreeLabel
+ }
+}
diff --git a/mobile/src/session/use-mobile-diff-review-git-actions.ts b/mobile/src/session/use-mobile-diff-review-git-actions.ts
new file mode 100644
index 00000000000..9ddcda0487d
--- /dev/null
+++ b/mobile/src/session/use-mobile-diff-review-git-actions.ts
@@ -0,0 +1,88 @@
+import { useCallback, type Dispatch, type SetStateAction } from 'react'
+import type { ConnectionState } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { triggerError, triggerSuccess } from '../platform/haptics'
+import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
+import type { GitMutationMethod } from './mobile-diff-review-screen-model'
+import { mobileReviewCountLabel } from './mobile-diff-review-screen-model'
+
+type GitActionsInput = {
+ client: RpcClient | null
+ connState: ConnectionState
+ worktreeId: string
+ queue: MobileDiffReviewQueueItem[]
+ setActionError: Dispatch>
+ setBusyAction: Dispatch>
+ loadReviewData: () => Promise
+}
+
+export function useMobileDiffReviewGitActions(input: GitActionsInput) {
+ const { client, connState, worktreeId, queue, setActionError, setBusyAction, loadReviewData } =
+ input
+
+ const runGitMutation = useCallback(
+ async (method: GitMutationMethod, item: MobileDiffReviewQueueItem) => {
+ if (!client || connState !== 'connected') {
+ setActionError('Waiting for desktop...')
+ return
+ }
+ setBusyAction(`${method}:${item.filePath}`)
+ setActionError(null)
+ try {
+ const response = await client.sendRequest(method, {
+ worktree: `id:${worktreeId}`,
+ filePath: item.filePath
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Source control action failed')
+ }
+ triggerSuccess()
+ await loadReviewData()
+ } catch (err) {
+ triggerError()
+ setActionError(err instanceof Error ? err.message : 'Source control action failed')
+ } finally {
+ setBusyAction(null)
+ }
+ },
+ [client, connState, loadReviewData, setActionError, setBusyAction, worktreeId]
+ )
+
+ const stageReviewedFiles = useCallback(async () => {
+ if (!client || connState !== 'connected') {
+ setActionError('Waiting for desktop...')
+ return
+ }
+ const files = queue.filter(
+ (item) => item.scope === 'unstaged' && item.isReviewed && item.canStage
+ )
+ if (files.length === 0) {
+ return
+ }
+ setBusyAction('stage-reviewed')
+ setActionError(null)
+ let staged = 0
+ let failed = 0
+ for (const item of files) {
+ const response = await client.sendRequest('git.stage', {
+ worktree: `id:${worktreeId}`,
+ filePath: item.filePath
+ })
+ if (response.ok) {
+ staged += 1
+ } else {
+ failed += 1
+ }
+ }
+ setBusyAction(null)
+ triggerSuccess()
+ setActionError(
+ failed > 0
+ ? `${staged} staged, ${failed} failed`
+ : `${mobileReviewCountLabel(staged, 'reviewed file', 'reviewed files')} staged`
+ )
+ await loadReviewData()
+ }, [client, connState, loadReviewData, queue, setActionError, setBusyAction, worktreeId])
+
+ return { runGitMutation, stageReviewedFiles }
+}
diff --git a/mobile/src/session/use-mobile-diff-review-interactions.ts b/mobile/src/session/use-mobile-diff-review-interactions.ts
new file mode 100644
index 00000000000..079874933e2
--- /dev/null
+++ b/mobile/src/session/use-mobile-diff-review-interactions.ts
@@ -0,0 +1,213 @@
+import type { Dispatch, RefObject, SetStateAction } from 'react'
+import type { FlatList } from 'react-native'
+import type { ConnectionState } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { triggerSelection } from '../platform/haptics'
+import { findNextMobileDiffHunkIndex, findPreviousMobileDiffHunkIndex } from './mobile-diff-hunks'
+import type {
+ MobileDiffReviewQueueFilter,
+ MobileDiffReviewQueueItem
+} from './mobile-diff-review-queue'
+import type {
+ ComposerState,
+ ReviewDiffLine,
+ ReviewDiffState,
+ ReviewScreenState,
+ SendSheetState
+} from './mobile-diff-review-screen-model'
+import { useMobileDiffReviewCommentActions } from './use-mobile-diff-review-comment-actions'
+import { useMobileDiffReviewGitActions } from './use-mobile-diff-review-git-actions'
+import { useMobileDiffReviewSendActions } from './use-mobile-diff-review-send-actions'
+
+type InteractionInput = {
+ client: RpcClient | null
+ connState: ConnectionState
+ hostId: string
+ worktreeId: string
+ screenState: ReviewScreenState
+ diffState: ReviewDiffState
+ currentItem: MobileDiffReviewQueueItem | null
+ queue: MobileDiffReviewQueueItem[]
+ filteredQueue: MobileDiffReviewQueueItem[]
+ filter: MobileDiffReviewQueueFilter
+ currentIndex: number
+ activeHunkIndex: number | null
+ composer: ComposerState | null
+ composerBody: string
+ listRef: RefObject | null>
+ setScreenState: Dispatch>
+ setFilter: Dispatch>
+ setCurrentIndex: Dispatch>
+ setActiveHunkIndex: Dispatch>
+ setComposer: Dispatch>
+ setComposerBody: Dispatch>
+ setActionError: Dispatch>
+ setBusyAction: Dispatch>
+ setSendSheet: Dispatch>
+ setShowCompletion: Dispatch>
+ loadReviewData: () => Promise
+ onOpenSession: () => void
+ onReconnect: (hostId: string) => void | Promise
+}
+
+export function useMobileDiffReviewInteractions(input: InteractionInput) {
+ const {
+ client,
+ connState,
+ hostId,
+ worktreeId,
+ screenState,
+ diffState,
+ currentItem,
+ queue,
+ filteredQueue,
+ filter,
+ currentIndex,
+ activeHunkIndex,
+ composer,
+ composerBody,
+ listRef,
+ setScreenState,
+ setFilter,
+ setCurrentIndex,
+ setActiveHunkIndex,
+ setComposer,
+ setComposerBody,
+ setActionError,
+ setBusyAction,
+ setSendSheet,
+ setShowCompletion,
+ loadReviewData,
+ onOpenSession,
+ onReconnect
+ } = input
+
+ const {
+ closeComposer,
+ deleteComment,
+ markReviewed,
+ markUnreviewed,
+ openComposer,
+ openEditComposer,
+ saveCommentsAndReviewState,
+ saveComposer
+ } = useMobileDiffReviewCommentActions({
+ client,
+ connState,
+ worktreeId,
+ screenState,
+ currentItem,
+ queue,
+ filteredQueue,
+ filter,
+ currentIndex,
+ composer,
+ composerBody,
+ setScreenState,
+ setCurrentIndex,
+ setComposer,
+ setComposerBody,
+ setActionError,
+ setShowCompletion
+ })
+
+ const { runGitMutation, stageReviewedFiles } = useMobileDiffReviewGitActions({
+ client,
+ connState,
+ worktreeId,
+ queue,
+ setActionError,
+ setBusyAction,
+ loadReviewData
+ })
+
+ const { clearSentNotes, copyNotes, createTerminalAndSend, openSendSheet, sendPromptToTerminal } =
+ useMobileDiffReviewSendActions({
+ client,
+ connState,
+ worktreeId,
+ screenState,
+ setActionError,
+ setSendSheet,
+ saveCommentsAndReviewState
+ })
+
+ return {
+ clearSentNotes,
+ closeComposer,
+ copyNotes,
+ createTerminalAndSend,
+ deleteComment,
+ jumpHunk: (direction: 'next' | 'previous') => {
+ if (diffState.kind !== 'ready') {
+ return
+ }
+ const currentLineIndex =
+ activeHunkIndex === null ? -1 : (diffState.hunks[activeHunkIndex]?.startIndex ?? -1)
+ const nextIndex =
+ direction === 'next'
+ ? findNextMobileDiffHunkIndex(diffState.hunks, currentLineIndex)
+ : findPreviousMobileDiffHunkIndex(diffState.hunks, currentLineIndex)
+ const target = nextIndex === null ? null : diffState.hunks[nextIndex]
+ if (!target || nextIndex === null) {
+ return
+ }
+ setActiveHunkIndex(nextIndex)
+ listRef.current?.scrollToIndex({
+ index: target.startIndex,
+ animated: true,
+ viewPosition: 0.16
+ })
+ triggerSelection()
+ },
+ markReviewed,
+ markUnreviewed,
+ moveFile: (direction: 'next' | 'previous') => {
+ if (filteredQueue.length === 0) {
+ return
+ }
+ setCurrentIndex((index) =>
+ direction === 'next'
+ ? index + 1 >= filteredQueue.length
+ ? 0
+ : index + 1
+ : index - 1 < 0
+ ? filteredQueue.length - 1
+ : index - 1
+ )
+ },
+ openComposer,
+ openEditComposer,
+ openInSession: async () => {
+ if (!client || !currentItem || currentItem.scope === 'branch') {
+ return
+ }
+ const response = await client.sendRequest('files.openDiff', {
+ worktree: `id:${worktreeId}`,
+ relativePath: currentItem.filePath,
+ staged: currentItem.scope === 'staged'
+ })
+ if (!response.ok) {
+ setActionError(response.error?.message || 'Unable to open in session')
+ return
+ }
+ onOpenSession()
+ },
+ openSendSheet,
+ retryAction: () => {
+ if (connState !== 'connected' && hostId) {
+ void onReconnect(hostId)
+ return
+ }
+ void loadReviewData()
+ },
+ runGitMutation,
+ saveComposer,
+ selectFilter: (nextFilter: MobileDiffReviewQueueFilter) => {
+ setFilter(nextFilter)
+ setCurrentIndex(0)
+ },
+ sendPromptToTerminal,
+ stageReviewedFiles
+ }
+}
diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts
new file mode 100644
index 00000000000..5300100a476
--- /dev/null
+++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts
@@ -0,0 +1,146 @@
+import { useCallback, type Dispatch, type SetStateAction } from 'react'
+import * as Clipboard from 'expo-clipboard'
+import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/types'
+import type { ConnectionState } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { triggerSuccess } from '../platform/haptics'
+import { formatDiffComments, formatMobileDiffReviewPrompt } from './mobile-diff-comments'
+import { clearSentMobileDiffComments, markMobileDiffCommentsSent } from './mobile-diff-comment-edit'
+import {
+ readMobileReviewCreatedTerminal,
+ readMobileReviewTerminalSendAccepted,
+ readMobileReviewTerminalTabs
+} from './mobile-diff-review-rpc'
+import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model'
+
+type SendActionsInput = {
+ client: RpcClient | null
+ connState: ConnectionState
+ worktreeId: string
+ screenState: ReviewScreenState
+ setActionError: Dispatch>
+ setSendSheet: Dispatch>
+ saveCommentsAndReviewState: (
+ comments: DiffComment[],
+ reviewState: MobileDiffReviewState
+ ) => Promise
+}
+
+export function useMobileDiffReviewSendActions(input: SendActionsInput) {
+ const {
+ client,
+ connState,
+ worktreeId,
+ screenState,
+ setActionError,
+ setSendSheet,
+ saveCommentsAndReviewState
+ } = input
+
+ const copyNotes = useCallback(async () => {
+ if (screenState.kind !== 'ready' || screenState.comments.length === 0) {
+ return
+ }
+ await Clipboard.setStringAsync(formatDiffComments(screenState.comments))
+ triggerSuccess()
+ setActionError('Review notes copied')
+ }, [screenState, setActionError])
+
+ const clearSentNotes = useCallback(async () => {
+ if (screenState.kind !== 'ready') {
+ return
+ }
+ const nextComments = clearSentMobileDiffComments(screenState.comments)
+ await saveCommentsAndReviewState(nextComments, screenState.reviewState)
+ }, [saveCommentsAndReviewState, screenState])
+
+ const markNotesSent = useCallback(
+ async (comments: readonly DiffComment[]) => {
+ if (screenState.kind !== 'ready') {
+ return
+ }
+ const next = markMobileDiffCommentsSent(
+ screenState.comments,
+ new Set(comments.map((comment) => comment.id)),
+ Date.now()
+ )
+ await saveCommentsAndReviewState(next, screenState.reviewState)
+ },
+ [saveCommentsAndReviewState, screenState]
+ )
+
+ const sendPromptToTerminal = useCallback(
+ async (terminal: string, comments: readonly DiffComment[]) => {
+ if (!client || connState !== 'connected') {
+ throw new Error('Waiting for desktop...')
+ }
+ const response = await client.sendRequest('terminal.send', {
+ terminal,
+ text: formatMobileDiffReviewPrompt(comments),
+ enter: true
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to send notes')
+ }
+ if (!readMobileReviewTerminalSendAccepted(response.result)) {
+ throw new Error('Terminal input is locked')
+ }
+ await markNotesSent(comments)
+ triggerSuccess()
+ setActionError('Review notes sent')
+ setSendSheet(null)
+ },
+ [client, connState, markNotesSent, setActionError, setSendSheet]
+ )
+
+ const createTerminalAndSend = useCallback(
+ async (comments: readonly DiffComment[]) => {
+ if (!client || connState !== 'connected') {
+ throw new Error('Waiting for desktop...')
+ }
+ const response = await client.sendRequest('session.tabs.createTerminal', {
+ worktree: `id:${worktreeId}`
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to create terminal')
+ }
+ const created = readMobileReviewCreatedTerminal(response.result)
+ if (!created) {
+ throw new Error('Created terminal response was invalid')
+ }
+ await sendPromptToTerminal(created.terminal, comments)
+ },
+ [client, connState, sendPromptToTerminal, worktreeId]
+ )
+
+ const openSendSheet = useCallback(async () => {
+ if (!client || connState !== 'connected') {
+ setActionError('Waiting for desktop...')
+ return
+ }
+ setSendSheet({ kind: 'loading' })
+ try {
+ const response = await client.sendRequest('session.tabs.list', {
+ worktree: `id:${worktreeId}`
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Unable to load agent sessions')
+ }
+ setSendSheet({ kind: 'ready', terminals: readMobileReviewTerminalTabs(response.result) })
+ } catch (err) {
+ setSendSheet({
+ kind: 'error',
+ message: err instanceof Error ? err.message : 'Unable to load agent sessions',
+ terminals: []
+ })
+ }
+ }, [client, connState, setActionError, setSendSheet, worktreeId])
+
+ return {
+ clearSentNotes,
+ copyNotes,
+ createTerminalAndSend,
+ openSendSheet,
+ sendPromptToTerminal
+ }
+}
diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts
new file mode 100644
index 00000000000..3894928f4f5
--- /dev/null
+++ b/mobile/src/session/use-mobile-image-attachment.ts
@@ -0,0 +1,103 @@
+import { useCallback, useState } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import { attachMobileImageToTerminal } from './mobile-image-attachment'
+import {
+ ImageLibraryPermissionError,
+ pickMobileImage,
+ type MobileImageSource
+} from './mobile-image-source-picker'
+
+type CurrentRef = {
+ readonly current: T
+}
+
+type ShowToast = (message: string, durationMs?: number) => void
+
+type UseMobileImageAttachmentArgs = {
+ readonly client: RpcClient | null
+ readonly activeHandle: string | null
+ readonly canSend: boolean
+ readonly connState: ConnectionState
+ readonly deviceTokenRef: CurrentRef
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly showToast: ShowToast
+ readonly onSuccess: () => void
+ readonly onError: () => void
+}
+
+type MobileImageAttachment = {
+ readonly attachImage: (source: MobileImageSource) => Promise
+ // True only while the picked image is uploading to the host (not while the
+ // picker is open) — drives the send spinner so the 3-5s transfer isn't a no-op.
+ readonly isAttaching: boolean
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+export function useMobileImageAttachment({
+ client,
+ activeHandle,
+ canSend,
+ connState,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ showToast,
+ onSuccess,
+ onError
+}: UseMobileImageAttachmentArgs): MobileImageAttachment {
+ const [isAttaching, setIsAttaching] = useState(false)
+ const attachImage = useCallback(
+ async (source: MobileImageSource): Promise => {
+ if (!client || !activeHandle || !canSend) {
+ return
+ }
+ try {
+ const sent = await attachMobileImageToTerminal(source, {
+ client,
+ terminal: activeHandle,
+ deviceToken: deviceTokenRef.current,
+ getConnectionId: getActiveWorktreeConnectionId,
+ pickImage: pickMobileImage,
+ onUploadStart: () => setIsAttaching(true)
+ })
+ // Cancelled picker: no error, no toast.
+ if (sent) {
+ onSuccess()
+ }
+ } catch (error) {
+ onError()
+ if (connState !== 'connected') {
+ showToast('Attach failed (disconnected)', 1500)
+ return
+ }
+ if (error instanceof ImageLibraryPermissionError) {
+ showToast('Photo permission denied', 1500)
+ return
+ }
+ if (getErrorMessage(error) === 'Clipboard image is too large') {
+ showToast('Image too large to attach', 1500)
+ return
+ }
+ showToast('Attach failed', 1500)
+ } finally {
+ setIsAttaching(false)
+ }
+ },
+ [
+ activeHandle,
+ canSend,
+ client,
+ connState,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ onError,
+ onSuccess,
+ showToast
+ ]
+ )
+
+ return { attachImage, isAttaching }
+}
diff --git a/mobile/src/session/worktree-label.ts b/mobile/src/session/worktree-label.ts
new file mode 100644
index 00000000000..c5e67ac5aab
--- /dev/null
+++ b/mobile/src/session/worktree-label.ts
@@ -0,0 +1,12 @@
+// Why: worktree ids encode `repo::path`; screens that only receive the id
+// (deep links, route params without a name) still need a human label.
+export function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
+ if (name?.trim()) {
+ return name.trim()
+ }
+ const pathPart = worktreeId.includes('::')
+ ? worktreeId.slice(worktreeId.indexOf('::') + 2)
+ : worktreeId
+ const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
+ return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
+}
diff --git a/mobile/src/source-control/mobile-branch-base-ref.ts b/mobile/src/source-control/mobile-branch-base-ref.ts
new file mode 100644
index 00000000000..fae287ae30b
--- /dev/null
+++ b/mobile/src/source-control/mobile-branch-base-ref.ts
@@ -0,0 +1,71 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { isMobileGitUnavailable } from './mobile-git-status'
+
+type RuntimeRepoSummary = {
+ id: string
+ worktreeBaseRef?: string | null
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function getRepoIdFromMobileWorktreeId(id: string): string {
+ const separatorIdx = id.indexOf('::')
+ return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
+}
+
+function readRepoSummaries(value: unknown): RuntimeRepoSummary[] {
+ if (!isRecord(value) || !Array.isArray(value.repos)) {
+ return []
+ }
+ return value.repos.flatMap((candidate): RuntimeRepoSummary[] => {
+ if (!isRecord(candidate) || typeof candidate.id !== 'string') {
+ return []
+ }
+ return [
+ {
+ id: candidate.id,
+ worktreeBaseRef:
+ typeof candidate.worktreeBaseRef === 'string' ? candidate.worktreeBaseRef : null
+ }
+ ]
+ })
+}
+
+function readDefaultBaseRef(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return null
+ }
+ return typeof value.defaultBaseRef === 'string' ? value.defaultBaseRef.trim() || null : null
+}
+
+export async function resolveMobileBranchCompareBaseRef(
+ client: RpcClient,
+ worktreeId: string
+): Promise {
+ const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
+ if (!repoId) {
+ return null
+ }
+
+ let repoBaseRef: string | null = null
+ const repoResponse = await client.sendRequest('repo.list')
+ if (repoResponse.ok) {
+ const repo = readRepoSummaries(repoResponse.result).find((candidate) => candidate.id === repoId)
+ repoBaseRef = repo?.worktreeBaseRef?.trim() || null
+ }
+
+ if (repoBaseRef) {
+ return repoBaseRef
+ }
+
+ const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` })
+ if (!defaultResponse.ok) {
+ if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) {
+ return null
+ }
+ throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base')
+ }
+ return readDefaultBaseRef(defaultResponse.result)
+}
diff --git a/mobile/src/source-control/mobile-commit-message-ai.test.ts b/mobile/src/source-control/mobile-commit-message-ai.test.ts
new file mode 100644
index 00000000000..f87fb3600a7
--- /dev/null
+++ b/mobile/src/source-control/mobile-commit-message-ai.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { cancelMobileCommitMessage, requestMobileCommitMessage } from './mobile-commit-message-ai'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail('unexpected')
+ })
+ }
+}
+
+describe('requestMobileCommitMessage', () => {
+ it('returns the generated message on success', async () => {
+ const client = clientWith([ok({ success: true, message: 'feat: do the thing' })])
+ await expect(requestMobileCommitMessage(client, 'wt-1')).resolves.toEqual({
+ success: true,
+ message: 'feat: do the thing'
+ })
+ expect(client.calls[0]).toEqual({
+ method: 'git.generateCommitMessage',
+ params: { worktree: 'id:wt-1' }
+ })
+ })
+
+ it('maps a host failure result to { success:false }', async () => {
+ const client = clientWith([ok({ success: false, error: 'no model configured' })])
+ await expect(requestMobileCommitMessage(client, 'wt-1')).resolves.toEqual({
+ success: false,
+ error: 'no model configured'
+ })
+ })
+
+ it('coerces a malformed failure payload to a non-empty error string', async () => {
+ const client = clientWith([ok({ success: false })])
+ const result = await requestMobileCommitMessage(client, 'wt-1')
+ expect(result.success).toBe(false)
+ expect(result).toMatchObject({ success: false, error: 'No commit message generated' })
+ })
+
+ it('preserves the canceled flag', async () => {
+ const client = clientWith([ok({ success: false, error: 'canceled', canceled: true })])
+ await expect(requestMobileCommitMessage(client, 'wt-1')).resolves.toEqual({
+ success: false,
+ error: 'canceled',
+ canceled: true
+ })
+ })
+
+ it('maps an RPC transport failure to { success:false }', async () => {
+ const client = clientWith([fail('disconnected')])
+ await expect(requestMobileCommitMessage(client, 'wt-1')).resolves.toEqual({
+ success: false,
+ error: 'disconnected'
+ })
+ })
+
+ it('treats an empty message as failure', async () => {
+ const client = clientWith([ok({ success: true, message: '' })])
+ const result = await requestMobileCommitMessage(client, 'wt-1')
+ expect(result.success).toBe(false)
+ })
+})
+
+describe('cancelMobileCommitMessage', () => {
+ it('calls the cancel RPC', async () => {
+ const client = clientWith([ok({})])
+ await cancelMobileCommitMessage(client, 'wt-1')
+ expect(client.calls[0]).toEqual({
+ method: 'git.cancelGenerateCommitMessage',
+ params: { worktree: 'id:wt-1' }
+ })
+ })
+})
diff --git a/mobile/src/source-control/mobile-commit-message-ai.ts b/mobile/src/source-control/mobile-commit-message-ai.ts
new file mode 100644
index 00000000000..6f85ebba442
--- /dev/null
+++ b/mobile/src/source-control/mobile-commit-message-ai.ts
@@ -0,0 +1,48 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+
+// Mirrors the host GenerateCommitMessageResult (src/main/text-generation/
+// commit-message-text-generation.ts) — a single resolved result, not a stream.
+export type MobileGenerateCommitMessageResult =
+ | { success: true; message: string }
+ | { success: false; error: string; canceled?: boolean }
+
+// Normalizes the git.generateCommitMessage RPC into a discriminated result the
+// UI can switch on. RPC transport failures and malformed payloads collapse to
+// { success:false } so the caller never has to special-case them.
+export async function requestMobileCommitMessage(
+ client: Pick,
+ worktreeId: string
+): Promise {
+ const response = await client.sendRequest('git.generateCommitMessage', {
+ worktree: `id:${worktreeId}`
+ })
+ if (!response.ok) {
+ return { success: false, error: response.error?.message || 'Failed to generate commit message' }
+ }
+ const result = (response as RpcSuccess).result as MobileGenerateCommitMessageResult | undefined
+ if (!result || typeof result !== 'object') {
+ return { success: false, error: 'Failed to generate commit message' }
+ }
+ if (result.success === true && typeof result.message === 'string' && result.message.length > 0) {
+ return { success: true, message: result.message }
+ }
+ // Why: a malformed `{ success:false }` payload could leave error undefined,
+ // breaking the result contract — always coerce to a non-empty string.
+ const hostError =
+ result.success === false && typeof result.error === 'string' && result.error.length > 0
+ ? result.error
+ : 'No commit message generated'
+ return {
+ success: false,
+ error: hostError,
+ ...(result.success === false && result.canceled ? { canceled: true } : {})
+ }
+}
+
+export async function cancelMobileCommitMessage(
+ client: Pick,
+ worktreeId: string
+): Promise {
+ await client.sendRequest('git.cancelGenerateCommitMessage', { worktree: `id:${worktreeId}` })
+}
diff --git a/mobile/src/source-control/mobile-git-history.test.ts b/mobile/src/source-control/mobile-git-history.test.ts
new file mode 100644
index 00000000000..7357f76aeff
--- /dev/null
+++ b/mobile/src/source-control/mobile-git-history.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import type { GitHistoryItem, GitHistoryResult } from '../../../src/shared/git-history-types'
+import { formatCommitTime, mapMobileCommitRows, toMobileCommitRow } from './mobile-git-history'
+
+const NOW = 1_000_000_000_000
+
+function item(overrides: Partial = {}): GitHistoryItem {
+ return {
+ id: 'a'.repeat(40),
+ parentIds: ['b'.repeat(40)],
+ subject: 'feat: thing',
+ message: 'feat: thing\n\nbody',
+ author: 'Jane',
+ timestamp: NOW / 1000 - 3600,
+ ...overrides
+ }
+}
+
+describe('formatCommitTime', () => {
+ it('formats across thresholds', () => {
+ const s = NOW / 1000
+ expect(formatCommitTime(s - 30, NOW)).toBe('just now')
+ expect(formatCommitTime(s - 5 * 60, NOW)).toBe('5m')
+ expect(formatCommitTime(s - 3 * 3600, NOW)).toBe('3h')
+ expect(formatCommitTime(s - 2 * 86400, NOW)).toBe('2d')
+ expect(formatCommitTime(s - 60 * 86400, NOW)).toBe('2mo')
+ expect(formatCommitTime(s - 800 * 86400, NOW)).toBe('2y')
+ })
+
+ it('returns empty for missing timestamp', () => {
+ expect(formatCommitTime(undefined, NOW)).toBe('')
+ })
+
+ it('formats a real epoch-0 timestamp instead of dropping it', () => {
+ // 0 is a valid (very old) timestamp, not "missing".
+ expect(formatCommitTime(0, NOW)).not.toBe('')
+ })
+})
+
+describe('toMobileCommitRow', () => {
+ it('maps a history item to a row', () => {
+ const row = toMobileCommitRow(item(), NOW)
+ expect(row).toEqual({
+ id: 'a'.repeat(40),
+ shortId: 'aaaaaaa',
+ subject: 'feat: thing',
+ author: 'Jane',
+ parentId: 'b'.repeat(40),
+ relativeTime: '1h'
+ })
+ })
+
+ it('prefers displayId and falls back for empty subject / no parent', () => {
+ const row = toMobileCommitRow(item({ displayId: 'abc1234', subject: '', parentIds: [] }), NOW)
+ expect(row.shortId).toBe('abc1234')
+ expect(row.subject).toBe('(no commit message)')
+ expect(row.parentId).toBeNull()
+ })
+})
+
+describe('mapMobileCommitRows', () => {
+ it('maps all items', () => {
+ const result = { items: [item(), item({ id: 'c'.repeat(40) })] } as GitHistoryResult
+ expect(mapMobileCommitRows(result, NOW)).toHaveLength(2)
+ })
+})
diff --git a/mobile/src/source-control/mobile-git-history.ts b/mobile/src/source-control/mobile-git-history.ts
new file mode 100644
index 00000000000..416b761d214
--- /dev/null
+++ b/mobile/src/source-control/mobile-git-history.ts
@@ -0,0 +1,71 @@
+import type { GitHistoryItem, GitHistoryResult } from '../../../src/shared/git-history-types'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+
+export type MobileCommitRow = {
+ id: string
+ shortId: string
+ subject: string
+ author: string
+ parentId: string | null
+ relativeTime: string
+}
+
+// Short relative time for a commit list (just now / Xm / Xh / Xd / Xmo / Xy).
+export function formatCommitTime(timestampSeconds: number | undefined, nowMs: number): string {
+ // Nullish — not falsy — so a real epoch-0 timestamp still formats.
+ if (timestampSeconds == null) {
+ return ''
+ }
+ const delta = nowMs - timestampSeconds * 1000
+ if (delta < 60_000) {
+ return 'just now'
+ }
+ const minutes = Math.floor(delta / 60_000)
+ if (minutes < 60) {
+ return `${minutes}m`
+ }
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) {
+ return `${hours}h`
+ }
+ const days = Math.floor(hours / 24)
+ if (days < 30) {
+ return `${days}d`
+ }
+ const months = Math.floor(days / 30)
+ if (months < 12) {
+ return `${months}mo`
+ }
+ return `${Math.floor(months / 12)}y`
+}
+
+export function toMobileCommitRow(item: GitHistoryItem, nowMs: number): MobileCommitRow {
+ return {
+ id: item.id,
+ shortId: item.displayId ?? item.id.slice(0, 7),
+ subject: item.subject || '(no commit message)',
+ author: item.author ?? '',
+ parentId: item.parentIds[0] ?? null,
+ relativeTime: formatCommitTime(item.timestamp, nowMs)
+ }
+}
+
+export function mapMobileCommitRows(result: GitHistoryResult, nowMs: number): MobileCommitRow[] {
+ return result.items.map((item) => toMobileCommitRow(item, nowMs))
+}
+
+export async function fetchMobileGitHistory(
+ client: Pick,
+ worktreeId: string,
+ limit = 50
+): Promise {
+ const response = await client.sendRequest('git.history', {
+ worktree: `id:${worktreeId}`,
+ limit
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Failed to load commit history')
+ }
+ return (response as RpcSuccess).result as GitHistoryResult
+}
diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts
new file mode 100644
index 00000000000..3761091a9ca
--- /dev/null
+++ b/mobile/src/source-control/mobile-pr-create.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import {
+ buildMobilePrCreateParams,
+ createMobilePr,
+ mobileRepoSelectorFromWorktreeId,
+ resolveMobilePrPrefill
+} from './mobile-pr-create'
+
+function ok(result: unknown): RpcSuccess {
+ return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
+}
+function fail(message: string): RpcFailure {
+ return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
+}
+function clientWith(responses: RpcResponse[]): Pick & {
+ calls: Array<{ method: string; params: unknown }>
+} {
+ const calls: Array<{ method: string; params: unknown }> = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ return responses.shift() ?? fail('unexpected')
+ })
+ }
+}
+
+describe('mobileRepoSelectorFromWorktreeId', () => {
+ it('extracts the repo id before the :: separator', () => {
+ expect(mobileRepoSelectorFromWorktreeId('repo-1::/tmp/wt')).toBe('id:repo-1')
+ expect(mobileRepoSelectorFromWorktreeId('repo-1')).toBe('id:repo-1')
+ })
+})
+
+describe('buildMobilePrCreateParams', () => {
+ it('trims fields and drops empty optionals', () => {
+ expect(
+ buildMobilePrCreateParams('repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: ' Add feature ',
+ body: ' ',
+ draft: false
+ })
+ ).toEqual({
+ repo: 'id:repo-1',
+ worktree: 'id:repo-1::/tmp/wt',
+ provider: 'github',
+ base: 'main',
+ title: 'Add feature',
+ draft: false
+ })
+ })
+
+ it('keeps a non-empty body and head', () => {
+ const params = buildMobilePrCreateParams('repo-1::/tmp/wt', {
+ provider: 'gitlab',
+ base: 'main',
+ head: 'feature/x',
+ title: 'T',
+ body: 'Body text',
+ draft: true
+ })
+ expect(params).toMatchObject({ head: 'feature/x', body: 'Body text', draft: true })
+ })
+})
+
+describe('createMobilePr', () => {
+ it('returns the url on success', async () => {
+ const client = clientWith([ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })])
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
+ expect(client.calls[0].method).toBe('hostedReview.create')
+ })
+
+ it('maps a host failure result to { ok:false }', async () => {
+ const client = clientWith([ok({ ok: false, code: 'needs_push', error: 'Push first' })])
+ await expect(
+ createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ ).resolves.toEqual({ ok: false, error: 'Push first' })
+ })
+
+ it('maps an RPC transport failure to { ok:false }', async () => {
+ const client = clientWith([fail('disconnected')])
+ const result = await createMobilePr(client, 'repo-1::/tmp/wt', {
+ provider: 'github',
+ base: 'main',
+ title: 'T',
+ body: '',
+ draft: false
+ })
+ expect(result).toEqual({ ok: false, error: 'disconnected' })
+ })
+})
+
+describe('resolveMobilePrPrefill', () => {
+ const baseArgs = {
+ branch: 'feature/x',
+ title: 'feature/x',
+ hasUncommittedChanges: false,
+ hasUpstream: true,
+ ahead: 1,
+ behind: 0
+ }
+
+ it('derives provider/base/title/body from eligibility (non-GitHub honored)', async () => {
+ const client = clientWith([
+ ok({
+ provider: 'gitlab',
+ canCreate: true,
+ review: null,
+ blockedReason: null,
+ nextAction: null,
+ defaultBaseRef: 'develop',
+ title: 'Add feature',
+ body: 'Body'
+ })
+ ])
+ await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
+ provider: 'gitlab',
+ base: 'develop',
+ title: 'Add feature',
+ body: 'Body'
+ })
+ })
+
+ it('falls back to github/main when eligibility is unavailable', async () => {
+ const client = clientWith([fail('nope')])
+ await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
+ provider: 'github',
+ base: 'main',
+ title: 'feature/x',
+ body: ''
+ })
+ })
+
+ it('falls back without calling the RPC when there is no branch', async () => {
+ const client = clientWith([])
+ const result = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', {
+ ...baseArgs,
+ branch: undefined
+ })
+ expect(result.provider).toBe('github')
+ expect(client.calls).toEqual([])
+ })
+})
diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts
new file mode 100644
index 00000000000..cada7dad398
--- /dev/null
+++ b/mobile/src/source-control/mobile-pr-create.ts
@@ -0,0 +1,153 @@
+import type {
+ CreateHostedReviewResult,
+ HostedReviewCreationEligibility,
+ HostedReviewProvider
+} from '../../../src/shared/hosted-review'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+
+// The mobile worktree id is `${repoId}::${path}`; the repo selector the host
+// hosted-review RPCs expect is `id:${repoId}`.
+export function mobileRepoSelectorFromWorktreeId(worktreeId: string): string {
+ const separatorIdx = worktreeId.indexOf('::')
+ const repoId = separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
+ return `id:${repoId}`
+}
+
+export type MobilePrEligibilityInput = {
+ branch: string
+ base?: string | null
+ hasUncommittedChanges: boolean
+ hasUpstream: boolean
+ ahead: number
+ behind: number
+ linkedGitHubPR?: number | null
+ linkedGitLabMR?: number | null
+}
+
+export async function fetchMobilePrEligibility(
+ client: Pick,
+ worktreeId: string,
+ input: MobilePrEligibilityInput
+): Promise {
+ const response = await client.sendRequest('hostedReview.getCreationEligibility', {
+ repo: mobileRepoSelectorFromWorktreeId(worktreeId),
+ worktree: `id:${worktreeId}`,
+ branch: input.branch,
+ base: input.base ?? null,
+ hasUncommittedChanges: input.hasUncommittedChanges,
+ hasUpstream: input.hasUpstream,
+ ahead: input.ahead,
+ behind: input.behind,
+ linkedGitHubPR: input.linkedGitHubPR ?? null,
+ linkedGitLabMR: input.linkedGitLabMR ?? null
+ })
+ if (!response.ok) {
+ return null
+ }
+ return (response as RpcSuccess).result as HostedReviewCreationEligibility
+}
+
+export type MobilePrPrefill = {
+ provider: HostedReviewProvider
+ base: string
+ title: string
+ body: string
+}
+
+// Fetches hosted-review eligibility and derives the PR compose prefill from it
+// — so non-GitHub repos (e.g. GitLab) get the right provider/base instead of a
+// hardcoded one. Falls back to a github/main default (with the branch label as
+// title) when branch/eligibility is unavailable.
+export async function resolveMobilePrPrefill(
+ client: Pick,
+ worktreeId: string,
+ args: {
+ branch: string | undefined
+ title: string
+ hasUncommittedChanges: boolean
+ hasUpstream: boolean
+ ahead: number
+ behind: number
+ }
+): Promise {
+ const fallback: MobilePrPrefill = {
+ provider: 'github',
+ base: 'main',
+ title: args.title,
+ body: ''
+ }
+ if (!args.branch) {
+ return fallback
+ }
+ try {
+ const eligibility = await fetchMobilePrEligibility(client, worktreeId, {
+ branch: args.branch,
+ hasUncommittedChanges: args.hasUncommittedChanges,
+ hasUpstream: args.hasUpstream,
+ ahead: args.ahead,
+ behind: args.behind
+ })
+ if (!eligibility) {
+ return fallback
+ }
+ return {
+ provider: eligibility.provider,
+ base: eligibility.defaultBaseRef || 'main',
+ title: eligibility.title || args.title,
+ body: eligibility.body || ''
+ }
+ } catch {
+ return fallback
+ }
+}
+
+export type MobilePrCreateInput = {
+ provider: HostedReviewProvider
+ base: string
+ head?: string
+ title: string
+ body: string
+ draft: boolean
+}
+
+// Builds the hostedReview.create params, trimming title/body and dropping empty
+// optional fields so the host's required-string validation passes cleanly.
+export function buildMobilePrCreateParams(
+ worktreeId: string,
+ input: MobilePrCreateInput
+): Record {
+ return {
+ repo: mobileRepoSelectorFromWorktreeId(worktreeId),
+ worktree: `id:${worktreeId}`,
+ provider: input.provider,
+ base: input.base,
+ ...(input.head && input.head.length > 0 ? { head: input.head } : {}),
+ title: input.title.trim(),
+ ...(input.body.trim().length > 0 ? { body: input.body.trim() } : {}),
+ draft: input.draft
+ }
+}
+
+export type MobilePrCreateOutcome =
+ | { ok: true; url: string; number: number }
+ | { ok: false; error: string }
+
+export async function createMobilePr(
+ client: Pick,
+ worktreeId: string,
+ input: MobilePrCreateInput
+): Promise {
+ const response = await client.sendRequest(
+ 'hostedReview.create',
+ buildMobilePrCreateParams(worktreeId, input)
+ )
+ if (!response.ok) {
+ return { ok: false, error: response.error?.message || 'Failed to create pull request' }
+ }
+ const result = (response as RpcSuccess).result as CreateHostedReviewResult
+ if (result.ok) {
+ return { ok: true, url: result.url, number: result.number }
+ }
+ return { ok: false, error: result.error || 'Failed to create pull request' }
+}
diff --git a/mobile/src/source-control/mobile-source-control-actions.test.ts b/mobile/src/source-control/mobile-source-control-actions.test.ts
new file mode 100644
index 00000000000..5ed3deb2370
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-actions.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { MobileGitUpstreamStatus } from './mobile-git-status'
+import {
+ buildMobileSourceControlActions,
+ type MobileSourceControlActionArgs
+} from './mobile-source-control-actions'
+
+function noopHandlers(): MobileSourceControlActionArgs['handlers'] {
+ return {
+ commit: vi.fn(),
+ commitPush: vi.fn(),
+ commitSync: vi.fn(),
+ push: vi.fn(),
+ pull: vi.fn(),
+ sync: vi.fn(),
+ fetch: vi.fn(),
+ publish: vi.fn(),
+ fastForward: vi.fn(),
+ rebase: vi.fn(),
+ createPr: vi.fn(),
+ pushAndCreatePr: vi.fn(),
+ checkout: vi.fn(),
+ history: vi.fn()
+ }
+}
+
+function args(
+ overrides: Partial = {}
+): MobileSourceControlActionArgs {
+ return {
+ commitMessage: 'msg',
+ stagedCount: 1,
+ upstream: { hasUpstream: true, ahead: 0, behind: 0 } as MobileGitUpstreamStatus,
+ upstreamKnown: true,
+ busyAction: null,
+ openingPath: null,
+ openingBranchPath: null,
+ prAvailable: true,
+ handlers: noopHandlers(),
+ ...overrides
+ }
+}
+
+function action(actions: ReturnType, label: string) {
+ return actions.find((a) => a.label.startsWith(label))
+}
+
+describe('buildMobileSourceControlActions', () => {
+ it('includes the new parity actions', () => {
+ const actions = buildMobileSourceControlActions(args())
+ const labels = actions.map((a) => a.label)
+ expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true)
+ expect(labels).toContain('Rebase onto base')
+ expect(labels).toContain('Switch branch')
+ expect(labels).toContain('History')
+ expect(labels).toContain('Create PR')
+ })
+
+ it('enables Create PR only when a PR provider is available', () => {
+ expect(
+ action(buildMobileSourceControlActions(args({ prAvailable: true })), 'Create PR')?.disabled
+ ).toBe(false)
+ expect(
+ action(buildMobileSourceControlActions(args({ prAvailable: false })), 'Create PR')?.disabled
+ ).toBe(true)
+ })
+
+ it('disables fast-forward when ahead of upstream (would lose local commits)', () => {
+ const actions = buildMobileSourceControlActions(
+ args({ upstream: { hasUpstream: true, ahead: 2, behind: 3 } as MobileGitUpstreamStatus })
+ )
+ expect(action(actions, 'Fast-forward')?.disabled).toBe(true)
+ })
+
+ it('enables fast-forward when behind and not ahead', () => {
+ const actions = buildMobileSourceControlActions(
+ args({ upstream: { hasUpstream: true, ahead: 0, behind: 3 } as MobileGitUpstreamStatus })
+ )
+ expect(action(actions, 'Fast-forward')?.disabled).toBe(false)
+ })
+
+ it('blocks commit when no staged files', () => {
+ const actions = buildMobileSourceControlActions(args({ stagedCount: 0 }))
+ const commit = action(actions, 'Commit')
+ expect(commit?.disabled).toBe(true)
+ expect(commit?.hint).toBe('Stage at least one file')
+ })
+
+ it('wires handlers to their actions', () => {
+ const handlers = noopHandlers()
+ const actions = buildMobileSourceControlActions(args({ handlers }))
+ action(actions, 'Switch branch')?.onPress()
+ action(actions, 'History')?.onPress()
+ expect(handlers.checkout).toHaveBeenCalled()
+ expect(handlers.history).toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/source-control/mobile-source-control-actions.ts b/mobile/src/source-control/mobile-source-control-actions.ts
new file mode 100644
index 00000000000..0e504c5a2b1
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-actions.ts
@@ -0,0 +1,226 @@
+import type { MobileGitUpstreamStatus } from './mobile-git-status'
+
+// Icon identifier resolved to a lucide component by the screen. Kept as a string
+// here so this module stays free of the native lucide import and unit-testable.
+export type MobileSourceControlActionIcon =
+ | 'commit'
+ | 'push'
+ | 'pull'
+ | 'sync'
+ | 'fetch'
+ | 'publish'
+ | 'rebase'
+ | 'pr'
+ | 'branch'
+ | 'history'
+
+export type MobileSourceControlAction = {
+ label: string
+ iconKey: MobileSourceControlActionIcon
+ disabled?: boolean
+ hint?: string
+ loading?: boolean
+ skipAutoClose?: boolean
+ onPress: () => void
+}
+
+export type MobileSourceControlActionArgs = {
+ commitMessage: string
+ stagedCount: number
+ upstream: MobileGitUpstreamStatus | null
+ upstreamKnown: boolean
+ busyAction: string | null
+ openingPath: string | null
+ openingBranchPath: string | null
+ prAvailable: boolean
+ handlers: {
+ commit: () => void
+ commitPush: () => void
+ commitSync: () => void
+ push: () => void
+ pull: () => void
+ sync: () => void
+ fetch: () => void
+ publish: () => void
+ fastForward: () => void
+ rebase: () => void
+ createPr: () => void
+ pushAndCreatePr: () => void
+ checkout: () => void
+ history: () => void
+ }
+}
+
+// Builds the source-control bottom-sheet action list. Pure (no hooks) so it can
+// be unit-tested and keeps the screen file lean. Enable/disable rules mirror the
+// desktop primary-action gating.
+export function buildMobileSourceControlActions(
+ args: MobileSourceControlActionArgs
+): MobileSourceControlAction[] {
+ const { commitMessage, stagedCount, upstream, upstreamKnown, handlers } = args
+ const hasMessage = commitMessage.trim().length > 0
+ const hasStaged = stagedCount > 0
+ const hasUpstream = upstream?.hasUpstream === true
+ const ahead = upstream?.ahead ?? 0
+ const behind = upstream?.behind ?? 0
+ const busy =
+ args.busyAction !== null || args.openingPath !== null || args.openingBranchPath !== null
+ const commitHint = !hasStaged
+ ? 'Stage at least one file'
+ : !hasMessage
+ ? 'Enter a commit message'
+ : undefined
+ const remoteHint = !upstreamKnown
+ ? 'Checking branch status...'
+ : hasUpstream
+ ? undefined
+ : 'Publish Branch first'
+ const prHint = !upstreamKnown
+ ? 'Checking branch status...'
+ : !args.prAvailable
+ ? 'Pull requests are not available for this repo'
+ : undefined
+
+ return [
+ {
+ label: 'Commit',
+ iconKey: 'commit',
+ disabled: busy || !!commitHint,
+ hint: commitHint,
+ loading: args.busyAction === 'commit',
+ skipAutoClose: true,
+ onPress: handlers.commit
+ },
+ {
+ label: 'Commit & Push',
+ iconKey: 'push',
+ disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream,
+ hint: commitHint ?? remoteHint,
+ loading: args.busyAction === 'commit-push',
+ skipAutoClose: true,
+ onPress: handlers.commitPush
+ },
+ {
+ label: 'Commit & Sync',
+ iconKey: 'sync',
+ disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream || behind === 0,
+ hint:
+ commitHint ??
+ (!upstreamKnown || !hasUpstream
+ ? remoteHint
+ : behind === 0
+ ? 'Nothing to pull'
+ : undefined),
+ loading: args.busyAction === 'commit-sync',
+ skipAutoClose: true,
+ onPress: handlers.commitSync
+ },
+ {
+ label: ahead > 0 ? `Push (${ahead})` : 'Push',
+ iconKey: 'push',
+ disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0,
+ hint: !hasUpstream ? remoteHint : ahead === 0 ? 'Nothing to push' : undefined,
+ loading: args.busyAction === 'push',
+ skipAutoClose: true,
+ onPress: handlers.push
+ },
+ {
+ label: 'Create PR',
+ iconKey: 'pr',
+ disabled: busy || !args.prAvailable,
+ hint: prHint,
+ loading: args.busyAction === 'create-pr',
+ skipAutoClose: true,
+ onPress: handlers.createPr
+ },
+ {
+ label: 'Push & Create PR',
+ iconKey: 'pr',
+ disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0 || !args.prAvailable,
+ hint: prHint ?? (!hasUpstream ? remoteHint : undefined),
+ loading: args.busyAction === 'push-create-pr',
+ skipAutoClose: true,
+ onPress: handlers.pushAndCreatePr
+ },
+ {
+ label: behind > 0 ? `Pull (${behind})` : 'Pull',
+ iconKey: 'pull',
+ disabled: busy || !upstreamKnown || !hasUpstream || behind === 0,
+ hint: !hasUpstream ? remoteHint : behind === 0 ? 'Nothing to pull' : undefined,
+ loading: args.busyAction === 'pull',
+ skipAutoClose: true,
+ onPress: handlers.pull
+ },
+ {
+ label: ahead > 0 || behind > 0 ? `Sync (↓${behind} ↑${ahead})` : 'Sync',
+ iconKey: 'sync',
+ disabled: busy || !upstreamKnown || !hasUpstream || (ahead === 0 && behind === 0),
+ hint:
+ !upstreamKnown || !hasUpstream
+ ? remoteHint
+ : ahead === 0 && behind === 0
+ ? 'Branch is up to date'
+ : undefined,
+ loading: args.busyAction === 'sync',
+ skipAutoClose: true,
+ onPress: handlers.sync
+ },
+ {
+ label: 'Fetch',
+ iconKey: 'fetch',
+ disabled: busy,
+ loading: args.busyAction === 'fetch',
+ skipAutoClose: true,
+ onPress: handlers.fetch
+ },
+ {
+ label: 'Publish Branch',
+ iconKey: 'publish',
+ disabled: busy || !upstreamKnown || hasUpstream,
+ hint: !upstreamKnown
+ ? 'Checking branch status...'
+ : hasUpstream
+ ? 'Branch is already published'
+ : undefined,
+ loading: args.busyAction === 'publish',
+ skipAutoClose: true,
+ onPress: handlers.publish
+ },
+ {
+ label: behind > 0 ? `Fast-forward (${behind})` : 'Fast-forward',
+ iconKey: 'pull',
+ disabled: busy || !upstreamKnown || !hasUpstream || behind === 0 || ahead > 0,
+ hint: !hasUpstream
+ ? remoteHint
+ : behind === 0
+ ? 'Nothing to fast-forward'
+ : ahead > 0
+ ? 'Local commits would be lost; pull instead'
+ : undefined,
+ loading: args.busyAction === 'fast-forward',
+ skipAutoClose: true,
+ onPress: handlers.fastForward
+ },
+ {
+ label: 'Rebase onto base',
+ iconKey: 'branch',
+ disabled: busy,
+ loading: args.busyAction === 'rebase',
+ skipAutoClose: true,
+ onPress: handlers.rebase
+ },
+ {
+ label: 'Switch branch',
+ iconKey: 'branch',
+ disabled: busy,
+ skipAutoClose: true,
+ onPress: handlers.checkout
+ },
+ {
+ label: 'History',
+ iconKey: 'history',
+ disabled: busy,
+ onPress: handlers.history
+ }
+ ]
+}
diff --git a/mobile/src/source-control/mobile-source-control-review-entry.tsx b/mobile/src/source-control/mobile-source-control-review-entry.tsx
new file mode 100644
index 00000000000..6d823d2c09e
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-review-entry.tsx
@@ -0,0 +1,89 @@
+import { useCallback } from 'react'
+import { useRouter } from 'expo-router'
+import { FileText } from 'lucide-react-native'
+import { Pressable, StyleSheet, Text } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+type MobileSourceControlReviewEntryProps = {
+ readonly count: number
+ readonly disabled: boolean
+ readonly hostId: string
+ readonly worktreeId: string
+ readonly worktreeName: string
+}
+
+export function MobileSourceControlReviewEntry({
+ count,
+ disabled,
+ hostId,
+ worktreeId,
+ worktreeName
+}: MobileSourceControlReviewEntryProps) {
+ const router = useRouter()
+ const canOpenReview = count > 0 && !disabled
+
+ const openReviewChanges = useCallback(() => {
+ if (!canOpenReview) {
+ return
+ }
+ const params = new URLSearchParams()
+ params.set('scope', 'all')
+ params.set('origin', 'source-control')
+ if (worktreeName) {
+ params.set('name', worktreeName)
+ }
+ const query = params.toString()
+ router.push(
+ `/h/${encodeURIComponent(hostId)}/review/${encodeURIComponent(worktreeId)}?${query}`
+ )
+ }, [canOpenReview, hostId, router, worktreeId, worktreeName])
+
+ return (
+ [
+ styles.button,
+ !canOpenReview && styles.disabled,
+ pressed && canOpenReview && styles.pressed
+ ]}
+ onPress={openReviewChanges}
+ disabled={!canOpenReview}
+ accessibilityRole="button"
+ accessibilityLabel="Review changes"
+ >
+
+ Review Changes
+ {count}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ button: {
+ minHeight: 42,
+ borderRadius: radii.button,
+ backgroundColor: colors.textPrimary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.xs,
+ marginTop: spacing.md,
+ paddingHorizontal: spacing.md
+ },
+ disabled: {
+ opacity: 0.45
+ },
+ pressed: {
+ opacity: 0.78
+ },
+ text: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ count: {
+ marginLeft: spacing.xs,
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ }
+})
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
new file mode 100644
index 00000000000..11b197408ed
--- /dev/null
+++ b/mobile/src/storage/preferences.test.ts
@@ -0,0 +1,50 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { loadTerminalAutocompleteEnabled, saveTerminalAutocompleteEnabled } from './preferences'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(),
+ setItem: vi.fn()
+ }
+}))
+
+describe('terminal autocomplete preference', () => {
+ beforeEach(() => {
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset()
+ })
+
+ it('defaults to disabled when unset', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
+
+ await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
+ expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled')
+ })
+
+ it('loads enabled only from the persisted true value', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('true')
+
+ await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(true)
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
+
+ await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
+ })
+
+ it('falls back to disabled when storage cannot be read', async () => {
+ vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
+
+ await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
+ })
+
+ it('persists the selected value', async () => {
+ await saveTerminalAutocompleteEnabled(true)
+
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled', 'true')
+
+ await saveTerminalAutocompleteEnabled(false)
+
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled', 'false')
+ })
+})
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 4bb837b307e..a7439d950a4 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -1,7 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
-const PREFS_PREFIX = 'orca:prefs:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
@@ -25,24 +24,54 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY)
+ if (raw === null) {
+ return DEFAULT_TEXT_SCALE
+ }
+ const parsed = Number(raw)
+ return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed)
+ ? parsed
+ : DEFAULT_TEXT_SCALE
+ } catch {
+ return DEFAULT_TEXT_SCALE
+ }
}
-const DEFAULT_PREFS: HostPreferences = {
- sortMode: 'recent',
- filterMode: 'all',
- groupMode: 'repo',
- collapsedGroups: [],
- selectedRepos: []
+export async function saveTerminalTextScale(scale: number): Promise {
+ await AsyncStorage.setItem(TEXT_SCALE_KEY, String(scale))
+}
+
+const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled'
+
+// Why: terminal command inputs default to autocorrect/suggestions OFF so the
+// keyboard never mangles commands, flags, or paths. Users who want phone-style
+// typing opt in via Settings → Terminal; the choice persists locally per device.
+export async function loadTerminalAutocompleteEnabled(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY)
+ return raw === 'true'
+ } catch {
+ return false
+ }
+}
+
+export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise {
+ await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
}
-const SORT_MODES = new Set(['smart', 'recent', 'name', 'repo'])
-const FILTER_MODES = new Set(['all', 'active'])
-const GROUP_MODES = new Set(['none', 'workspaceStatus', 'repo', 'prStatus'])
function stringArray(value: unknown): string[] {
return Array.isArray(value)
@@ -50,10 +79,6 @@ function stringArray(value: unknown): string[] {
: []
}
-function allowedString(value: unknown, allowed: Set, fallback: string): string {
- return typeof value === 'string' && allowed.has(value) ? value : fallback
-}
-
export async function loadPinnedIds(hostId: string): Promise> {
try {
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
@@ -69,31 +94,3 @@ export async function loadPinnedIds(hostId: string): Promise> {
export async function savePinnedIds(hostId: string, ids: Set): Promise {
await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids]))
}
-
-export async function loadPreferences(hostId: string): Promise {
- try {
- const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId)
- if (!raw) {
- return DEFAULT_PREFS
- }
- const parsed = JSON.parse(raw) as Partial
- return {
- sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode),
- filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode),
- groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode),
- collapsedGroups: stringArray(parsed.collapsedGroups),
- selectedRepos: stringArray(parsed.selectedRepos)
- }
- } catch {
- return DEFAULT_PREFS
- }
-}
-
-export async function savePreferences(
- hostId: string,
- prefs: Partial
-): Promise {
- const current = await loadPreferences(hostId)
- const merged = { ...current, ...prefs }
- await AsyncStorage.setItem(PREFS_PREFIX + hostId, JSON.stringify(merged))
-}
diff --git a/mobile/src/tasks/mobile-agent-catalog.test.ts b/mobile/src/tasks/mobile-agent-catalog.test.ts
index 479cf3f6f88..78ce1e6aa26 100644
--- a/mobile/src/tasks/mobile-agent-catalog.test.ts
+++ b/mobile/src/tasks/mobile-agent-catalog.test.ts
@@ -38,4 +38,10 @@ describe('mobile agent catalog', () => {
new Set(parseDesktopConfiguredAgents())
)
})
+
+ it('uses the bundled Claude icon path for Claude Agent Teams', () => {
+ expect(MOBILE_AGENT_CATALOG.find((agent) => agent.id === 'claude-agent-teams')).toEqual(
+ expect.not.objectContaining({ faviconDomain: expect.any(String) })
+ )
+ })
})
diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts
index 79108a8e80a..d2470e0113f 100644
--- a/mobile/src/tasks/mobile-tui-agents.ts
+++ b/mobile/src/tasks/mobile-tui-agents.ts
@@ -34,6 +34,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'qwen-code',
'rovo',
'hermes',
+ 'devin',
'openclaw'
] as const satisfies readonly TuiAgent[]
@@ -68,11 +69,11 @@ export const MOBILE_TUI_AGENT_LABELS: Record = {
'qwen-code': 'Qwen Code',
rovo: 'Rovo Dev',
hermes: 'Hermes',
+ devin: 'Devin',
openclaw: 'OpenClaw'
}
export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = {
- 'claude-agent-teams': 'anthropic.com',
openclaude: 'openclaude.gitlawb.com',
grok: 'x.ai',
copilot: 'github.com',
@@ -98,6 +99,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial>
'qwen-code': 'qwenlm.github.io',
rovo: 'atlassian.com',
hermes: 'nousresearch.com',
+ devin: 'devin.ai',
openclaw: 'openclaw.ai'
}
@@ -132,6 +134,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = {
'qwen-code': 'qwen-code',
rovo: 'rovo',
hermes: 'hermes',
+ devin: 'devin',
openclaw: 'openclaw'
}
diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx
index 6d67e00c613..816ea1dde49 100644
--- a/mobile/src/terminal/TerminalWebView.tsx
+++ b/mobile/src/terminal/TerminalWebView.tsx
@@ -4,6 +4,7 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
+import { XTERM_HTML } from './terminal-webview-html'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
@@ -32,6 +33,11 @@ export type TerminalSelectionEvents = {
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
+ // Tap landed on a detected file path; RN resolves + opens it.
+ onFileTap?: (pathText: string, line: number | null, column: number | null) => void
+ // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
+ // here so the app persists it and keeps Settings + other panes in sync.
+ onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
@@ -53,6 +59,9 @@ export type TerminalWebViewHandle = {
type Props = {
style?: StyleProp
terminalTheme?: MobileTerminalTheme
+ // Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width
+ // scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
+ textScale?: number
onWebReady?: () => void
} & TerminalSelectionEvents
@@ -65,7 +74,9 @@ type TerminalMessage =
rows: number
initialData?: string
terminalTheme?: MobileTerminalTheme
+ fontScale?: number
}
+ | { type: 'set-font-scale'; id?: number; fontScale: number }
| { type: 'resize'; id?: number; cols: number; rows: number }
| { type: 'clear'; id?: number }
| { type: 'measure'; id?: number; containerHeight?: number }
@@ -77,1837 +88,11 @@ type TerminalMessage =
const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000
const MAX_PENDING_WEB_WRITE_MESSAGES = 4096
-const DEFAULT_TERMINAL_THEME: MobileTerminalTheme['theme'] = {
- background: colors.terminalBg,
- foreground: '#c0caf5',
- cursor: '#c0caf5',
- cursorAccent: colors.terminalBg,
- selectionBackground: '#33467c',
- selectionForeground: '#c0caf5',
- black: '#15161e',
- red: '#f7768e',
- green: '#9ece6a',
- yellow: '#e0af68',
- blue: '#7aa2f7',
- magenta: '#bb9af7',
- cyan: '#7dcfff',
- white: '#a9b1d6',
- brightBlack: '#414868',
- brightRed: '#f7768e',
- brightGreen: '#9ece6a',
- brightYellow: '#e0af68',
- brightBlue: '#7aa2f7',
- brightMagenta: '#bb9af7',
- brightCyan: '#7dcfff',
- brightWhite: '#c0caf5'
-}
-
-// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
-// positioning designed for the desktop's terminal dimensions (~150+ cols).
-// We initialize xterm at the desktop's exact cols/rows so those escape codes
-// render correctly, then use a measured CSS transform: scale() to fit the
-// canvas into the phone viewport. The scale is computed after xterm opens
-// by measuring the rendered surface width, not hardcoded, so it adapts to
-// any terminal column count (80, 150, 200+). All touch gestures (scroll,
-// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
-// behavior, so they work correctly with the CSS scale transform.
-const XTERM_HTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`
-
export const TerminalWebView = forwardRef(function TerminalWebView(
{
style,
terminalTheme,
+ textScale = 1,
onWebReady,
onSelectionMode,
onSelectionCopy,
@@ -1916,7 +101,9 @@ export const TerminalWebView = forwardRef(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
- onTerminalTap
+ onTerminalTap,
+ onFileTap,
+ onTextScaleChange
},
ref
) {
@@ -2063,6 +250,13 @@ export const TerminalWebView = forwardRef(function
}
} else if (msg.type === 'terminal-tap') {
onTerminalTap?.()
+ } else if (msg.type === 'terminal-file-tap') {
+ const pathText = typeof msg.pathText === 'string' ? msg.pathText : ''
+ if (pathText.length > 0) {
+ const line = typeof msg.line === 'number' ? msg.line : null
+ const column = typeof msg.column === 'number' ? msg.column : null
+ onFileTap?.(pathText, line, column)
+ }
} else if (msg.type === 'keyboard-avoidance-metrics') {
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
const rows = typeof msg.rows === 'number' ? msg.rows : 0
@@ -2081,6 +275,11 @@ export const TerminalWebView = forwardRef(function
) {
onHaptic?.(kind)
}
+ } else if (msg.type === 'font-scale-changed') {
+ const scale = typeof msg.fontScale === 'number' ? msg.fontScale : 0
+ if (scale > 0) {
+ onTextScaleChange?.(scale)
+ }
} else if (msg.type === 'mobile-clip-cancel-by-pinch') {
// eslint-disable-next-line no-console
console.warn('[mobile-clip] selection cancelled by pinch')
@@ -2096,7 +295,9 @@ export const TerminalWebView = forwardRef(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
- onTerminalTap
+ onTerminalTap,
+ onFileTap,
+ onTextScaleChange
]
)
@@ -2111,6 +312,12 @@ export const TerminalWebView = forwardRef(function
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
+ // Why: live-apply text-size changes to an already-mounted terminal (the pane
+ // stays alive while the user visits Settings), so no terminal reload is needed.
+ useEffect(() => {
+ postMessage({ type: 'set-font-scale', fontScale: textScale })
+ }, [postMessage, textScale])
+
useImperativeHandle(
ref,
() => ({
@@ -2134,7 +341,7 @@ export const TerminalWebView = forwardRef(function
readyPromiseRef.current = new Promise((resolve) => {
readyResolveRef.current = resolve
})
- postMessage({ type: 'init', cols, rows, initialData, terminalTheme })
+ postMessage({ type: 'init', cols, rows, initialData, terminalTheme, fontScale: textScale })
},
resize(cols: number, rows: number) {
postMessage({ type: 'resize', cols, rows })
@@ -2206,7 +413,7 @@ export const TerminalWebView = forwardRef(function
})
}
}),
- [postMessage, sendToWebView, terminalTheme]
+ [postMessage, sendToWebView, terminalTheme, textScale]
)
return (
diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts
index a5cfeb904fb..d0f803e8e2a 100644
--- a/mobile/src/terminal/terminal-accessory-layout.test.ts
+++ b/mobile/src/terminal/terminal-accessory-layout.test.ts
@@ -4,10 +4,11 @@ import {
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
+ getDefaultTerminalAccessoryLayout,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout,
normalizeTerminalAccessoryLayoutPreference,
- resetTerminalAccessoryBuiltInIds,
+ reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from './terminal-accessory-layout'
@@ -45,6 +46,13 @@ describe('terminal accessory layout', () => {
)
})
+ it('default layout shows every built-in in canonical order', () => {
+ expect(getDefaultTerminalAccessoryLayout()).toEqual({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
+ })
+ })
+
it('normalizes invalid storage to defaults', () => {
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
getDefaultTerminalAccessoryBuiltInIds()
@@ -55,31 +63,112 @@ describe('terminal accessory layout', () => {
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
+ expect(
+ normalizeTerminalAccessoryLayoutPreference({
+ version: 2,
+ visibleBuiltInIds: ['escape']
+ }).visibleBuiltInIds
+ ).toEqual(getDefaultTerminalAccessoryBuiltInIds())
})
it('returns defaults for corrupt or unreadable storage', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce('{')
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
- createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
+ createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
- createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
+ createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
})
- it('ignores removed ids and de-dupes visible ids', () => {
+ it('preserves a custom v2 order and its visible subset', () => {
+ const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].reverse()
+
+ expect(
+ normalizeTerminalAccessoryLayoutPreference({
+ version: 2,
+ orderedBuiltInIds: reversed,
+ visibleBuiltInIds: ['tab', 'escape']
+ })
+ ).toEqual({
+ version: 2,
+ orderedBuiltInIds: reversed,
+ visibleBuiltInIds: ['tab', 'escape']
+ })
+ })
+
+ it('ignores removed ids and de-dupes ids in v2 storage', () => {
+ const current = ['escape', 'tab', 'enter']
+
+ expect(
+ normalizeTerminalAccessoryLayoutPreference(
+ {
+ version: 2,
+ orderedBuiltInIds: ['tab', 'removed', 'tab', 'escape', 'enter'],
+ visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab']
+ },
+ current
+ )
+ ).toEqual({
+ version: 2,
+ orderedBuiltInIds: ['tab', 'escape', 'enter'],
+ visibleBuiltInIds: ['tab', 'escape']
+ })
+ })
+
+ it('inserts new built-ins next to their canonical neighbors in a custom order', () => {
+ const current = ['escape', 'tab', 'space', 'enter']
+
+ expect(
+ normalizeTerminalAccessoryLayoutPreference(
+ {
+ version: 2,
+ orderedBuiltInIds: ['enter', 'tab', 'escape'],
+ visibleBuiltInIds: ['enter', 'escape']
+ },
+ current
+ )
+ ).toEqual({
+ version: 2,
+ // Why asserted: 'space' follows its canonical predecessor 'tab' even
+ // though the user moved 'tab' into the middle of the bar.
+ orderedBuiltInIds: ['enter', 'tab', 'space', 'escape'],
+ visibleBuiltInIds: ['enter', 'space', 'escape']
+ })
+ })
+
+ it('puts a new built-in with no surviving predecessor at the front', () => {
+ const current = ['escape', 'tab', 'enter']
+
+ expect(
+ normalizeTerminalAccessoryLayoutPreference(
+ {
+ version: 2,
+ orderedBuiltInIds: ['enter', 'tab'],
+ visibleBuiltInIds: ['enter']
+ },
+ current
+ ).orderedBuiltInIds
+ ).toEqual(['escape', 'enter', 'tab'])
+ })
+
+ it('migrates v1 layouts to canonical order', () => {
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 1,
- visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
+ visibleBuiltInIds: ['tab', 'escape'],
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
- }).visibleBuiltInIds
- ).toEqual(['escape', 'tab'])
+ })
+ ).toEqual({
+ version: 2,
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['escape', 'tab']
+ })
})
- it('appends new defaults only when absent from known ids', () => {
+ it('appends new defaults only when absent from v1 known ids', () => {
const current = ['escape', 'tab', 'enter']
expect(
@@ -129,11 +218,14 @@ describe('terminal accessory layout', () => {
).toEqual(['space'])
})
- it('keeps Space hidden after that choice is persisted with current known ids', () => {
+ it('keeps hidden built-ins hidden across v2 round-trips', () => {
const visibleBuiltInIds = getDefaultTerminalAccessoryBuiltInIds().filter((id) => id !== 'space')
- const persisted = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
+ const persisted = createTerminalAccessoryLayoutPreference({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds
+ })
- expect(persisted.knownBuiltInIds).toContain('space')
+ expect(persisted.orderedBuiltInIds).toContain('space')
expect(normalizeTerminalAccessoryLayoutPreference(persisted).visibleBuiltInIds).not.toContain(
'space'
)
@@ -145,43 +237,100 @@ describe('terminal accessory layout', () => {
expect(
normalizeTerminalAccessoryLayoutPreference(
{
- version: 1,
- visibleBuiltInIds: [],
- knownBuiltInIds: current
+ version: 2,
+ orderedBuiltInIds: current,
+ visibleBuiltInIds: []
},
current
).visibleBuiltInIds
).toEqual([])
})
- it('toggle and reset helpers preserve built-in order', () => {
- expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
- 'escape',
- 'tab'
- ])
+ it('toggles visibility while preserving the custom order', () => {
+ const layout = { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] }
+
+ expect(setTerminalAccessoryBuiltInVisible(layout, 'escape', true, ['escape', 'tab'])).toEqual({
+ orderedBuiltInIds: ['tab', 'escape'],
+ visibleBuiltInIds: ['tab', 'escape']
+ })
expect(
- setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
- ).toEqual(['tab'])
- expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
+ setTerminalAccessoryBuiltInVisible(
+ { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab', 'escape'] },
+ 'tab',
+ false,
+ ['escape', 'tab']
+ ).visibleBuiltInIds
+ ).toEqual(['escape'])
+ expect(setTerminalAccessoryBuiltInVisible(layout, 'unknown', true, ['escape', 'tab'])).toEqual({
+ orderedBuiltInIds: ['tab', 'escape'],
+ visibleBuiltInIds: ['tab']
+ })
})
- it('saves visible ids with current known built-in ids', async () => {
+ it('reorders built-ins and keeps the visible subset in the new order', () => {
+ const layout = {
+ orderedBuiltInIds: ['escape', 'tab', 'enter'],
+ visibleBuiltInIds: ['escape', 'enter']
+ }
+
+ expect(
+ reorderTerminalAccessoryBuiltInIds(
+ layout,
+ ['enter', 'escape', 'tab'],
+ ['escape', 'tab', 'enter']
+ )
+ ).toEqual({
+ orderedBuiltInIds: ['enter', 'escape', 'tab'],
+ visibleBuiltInIds: ['enter', 'escape']
+ })
+
+ // Why asserted: a stale drag result missing an id must not drop that key.
+ expect(
+ reorderTerminalAccessoryBuiltInIds(layout, ['enter', 'escape'], ['escape', 'tab', 'enter'])
+ .orderedBuiltInIds
+ ).toEqual(['enter', 'escape', 'tab'])
+ })
+
+ it('keeps visible terminal keys in the order of their ids', () => {
+ expect(getVisibleTerminalAccessoryKeys(['enter', 'escape']).map((key) => key.id)).toEqual([
+ 'enter',
+ 'escape'
+ ])
+ })
+
+ it('saves the sanitized v2 preference', async () => {
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
- await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
+ await saveTerminalAccessoryLayout({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['tab', 'tab', 'missing']
+ })
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
- JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
+ JSON.stringify(
+ createTerminalAccessoryLayoutPreference({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['tab']
+ })
+ )
)
})
it('rejects write failures without mutating helper output', async () => {
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope'))
- await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope')
- expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([
- 'escape'
- ])
+ await expect(
+ saveTerminalAccessoryLayout({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['escape']
+ })
+ ).rejects.toThrow('nope')
+ expect(
+ createTerminalAccessoryLayoutPreference({
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['escape']
+ }).visibleBuiltInIds
+ ).toEqual(['escape'])
})
})
diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts
index 42c45ef0764..cee7a74173e 100644
--- a/mobile/src/terminal/terminal-accessory-layout.ts
+++ b/mobile/src/terminal/terminal-accessory-layout.ts
@@ -4,10 +4,13 @@ import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-a
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
-export type TerminalAccessoryLayoutPreference = {
- version: 1
+export type TerminalAccessoryLayout = {
+ orderedBuiltInIds: string[]
visibleBuiltInIds: string[]
- knownBuiltInIds: string[]
+}
+
+export type TerminalAccessoryLayoutPreference = TerminalAccessoryLayout & {
+ version: 2
}
function builtInIds(): string[] {
@@ -16,9 +19,9 @@ function builtInIds(): string[] {
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
return {
- version: 1,
- visibleBuiltInIds: [...ids],
- knownBuiltInIds: [...ids]
+ version: 2,
+ orderedBuiltInIds: [...ids],
+ visibleBuiltInIds: [...ids]
}
}
@@ -42,15 +45,44 @@ function dedupeKnownIds(ids: string[], builtInSet: Set): string[] {
return out
}
-function orderBuiltInIds(ids: Set, currentBuiltInIds: string[]): string[] {
- // Why: migrated terminal bars should match the Settings -> Terminal order.
- return currentBuiltInIds.filter((id) => ids.has(id))
+// Why: built-ins added after the user saved a custom order should land next
+// to their canonical neighbors, not dangle at the end of the bar.
+function insertMissingBuiltInIds(
+ ordered: string[],
+ currentBuiltInIds: string[]
+): { ordered: string[]; inserted: string[] } {
+ const present = new Set(ordered)
+ const out = [...ordered]
+ const inserted: string[] = []
+ for (let i = 0; i < currentBuiltInIds.length; i++) {
+ const id = currentBuiltInIds[i]!
+ if (present.has(id)) {
+ continue
+ }
+ let insertAt = 0
+ for (let j = i - 1; j >= 0; j--) {
+ const at = out.indexOf(currentBuiltInIds[j]!)
+ if (at !== -1) {
+ insertAt = at + 1
+ break
+ }
+ }
+ out.splice(insertAt, 0, id)
+ present.add(id)
+ inserted.push(id)
+ }
+ return { ordered: out, inserted }
}
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
}
+export function getDefaultTerminalAccessoryLayout(): TerminalAccessoryLayout {
+ const ids = builtInIds()
+ return { orderedBuiltInIds: ids, visibleBuiltInIds: [...ids] }
+}
+
export function normalizeTerminalAccessoryLayoutPreference(
value: unknown,
currentBuiltInIds = builtInIds()
@@ -62,66 +94,112 @@ export function normalizeTerminalAccessoryLayoutPreference(
const candidate = value as {
version?: unknown
+ orderedBuiltInIds?: unknown
visibleBuiltInIds?: unknown
knownBuiltInIds?: unknown
}
- const visibleInput = stringArray(candidate.visibleBuiltInIds)
- const knownInput = stringArray(candidate.knownBuiltInIds)
- if (candidate.version !== 1 || !visibleInput || !knownInput) {
- return fallback
- }
-
const builtInSet = new Set(currentBuiltInIds)
- const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
- const visibleBuiltInSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
- for (const id of currentBuiltInIds) {
- if (!knownInputSet.has(id)) {
- visibleBuiltInSet.add(id)
+ if (candidate.version === 2) {
+ const orderedInput = stringArray(candidate.orderedBuiltInIds)
+ const visibleInput = stringArray(candidate.visibleBuiltInIds)
+ if (!orderedInput || !visibleInput) {
+ return fallback
+ }
+ const { ordered, inserted } = insertMissingBuiltInIds(
+ dedupeKnownIds(orderedInput, builtInSet),
+ currentBuiltInIds
+ )
+ const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
+ for (const id of inserted) {
+ visibleSet.add(id)
+ }
+ return {
+ version: 2,
+ orderedBuiltInIds: ordered,
+ visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
- return {
- version: 1,
- visibleBuiltInIds: orderBuiltInIds(visibleBuiltInSet, currentBuiltInIds),
- knownBuiltInIds: [...currentBuiltInIds]
+ if (candidate.version === 1) {
+ const visibleInput = stringArray(candidate.visibleBuiltInIds)
+ const knownInput = stringArray(candidate.knownBuiltInIds)
+ if (!visibleInput || !knownInput) {
+ return fallback
+ }
+ const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
+ const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
+ for (const id of currentBuiltInIds) {
+ if (!knownInputSet.has(id)) {
+ visibleSet.add(id)
+ }
+ }
+ // Why: v1 layouts never had a custom order, so migrate to canonical order.
+ return {
+ version: 2,
+ orderedBuiltInIds: [...currentBuiltInIds],
+ visibleBuiltInIds: currentBuiltInIds.filter((id) => visibleSet.has(id))
+ }
}
+
+ return fallback
}
export function createTerminalAccessoryLayoutPreference(
- visibleBuiltInIds: string[],
+ layout: TerminalAccessoryLayout,
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayoutPreference {
+ const builtInSet = new Set(currentBuiltInIds)
+ const { ordered } = insertMissingBuiltInIds(
+ dedupeKnownIds(layout.orderedBuiltInIds, builtInSet),
+ currentBuiltInIds
+ )
+ const visibleSet = new Set(dedupeKnownIds(layout.visibleBuiltInIds, builtInSet))
return {
- version: 1,
- visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
- knownBuiltInIds: [...currentBuiltInIds]
+ version: 2,
+ orderedBuiltInIds: ordered,
+ visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
export function setTerminalAccessoryBuiltInVisible(
- visibleBuiltInIds: string[],
+ layout: TerminalAccessoryLayout,
id: string,
visible: boolean,
currentBuiltInIds = builtInIds()
-): string[] {
- const builtInSet = new Set(currentBuiltInIds)
- if (!builtInSet.has(id)) {
- return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
- .visibleBuiltInIds
+): TerminalAccessoryLayout {
+ const preference = createTerminalAccessoryLayoutPreference(layout, currentBuiltInIds)
+ if (!new Set(currentBuiltInIds).has(id)) {
+ return {
+ orderedBuiltInIds: preference.orderedBuiltInIds,
+ visibleBuiltInIds: preference.visibleBuiltInIds
+ }
}
-
- const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
+ const visibleSet = new Set(preference.visibleBuiltInIds)
if (visible) {
- selected.add(id)
+ visibleSet.add(id)
} else {
- selected.delete(id)
+ visibleSet.delete(id)
+ }
+ return {
+ orderedBuiltInIds: preference.orderedBuiltInIds,
+ visibleBuiltInIds: preference.orderedBuiltInIds.filter((builtInId) => visibleSet.has(builtInId))
}
- return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
}
-export function resetTerminalAccessoryBuiltInIds(): string[] {
- return builtInIds()
+export function reorderTerminalAccessoryBuiltInIds(
+ layout: TerminalAccessoryLayout,
+ orderedBuiltInIds: string[],
+ currentBuiltInIds = builtInIds()
+): TerminalAccessoryLayout {
+ const preference = createTerminalAccessoryLayoutPreference(
+ { orderedBuiltInIds, visibleBuiltInIds: layout.visibleBuiltInIds },
+ currentBuiltInIds
+ )
+ return {
+ orderedBuiltInIds: preference.orderedBuiltInIds,
+ visibleBuiltInIds: preference.visibleBuiltInIds
+ }
}
export function getVisibleTerminalAccessoryKeys(
@@ -146,7 +224,7 @@ export async function loadTerminalAccessoryLayout(): Promise {
- const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
+export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise {
+ const preference = createTerminalAccessoryLayoutPreference(layout)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
new file mode 100644
index 00000000000..71b24de406a
--- /dev/null
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -0,0 +1,72 @@
+// Plain-JS file-path-under-tap detection, injected verbatim into the terminal
+// WebView's xterm script (XTERM_HTML). It is interpolated with ${...}, so the
+// regex backslashes here are single (the real runtime form) — not the doubled
+// form a backtick template literal would otherwise require.
+//
+// This mirrors the unit-tested mobile/src/terminal/terminal-path-tap.ts; keep
+// the two in sync. The TS module is the source of truth for the algorithm and
+// has the regression tests; this string only exists because the WebView can't
+// import RN modules.
+//
+// Matches both slash-bearing paths AND bare filenames with an extension
+// (README.md, src/index.ts:5) — like desktop, we propose candidates and let the
+// host's files.resolveTerminalPath existence check reject non-files. Agents
+// often print a bare filename (the markdown link target is consumed, leaving
+// only the label text), so requiring a slash would miss the common case.
+export const TERMINAL_PATH_TAP_JS = String.raw`
+ var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
+ var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
+ var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
+
+ function parsePathLineCol(value) {
+ var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
+ if (!m) return null;
+ var pathText = m[1];
+ var last = pathText.charAt(pathText.length - 1);
+ if (!pathText || last === '/' || last === '\\') return null;
+ var line = m[2] ? parseInt(m[2], 10) : null;
+ var column = m[3] ? parseInt(m[3], 10) : null;
+ if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
+ return { pathText: pathText, line: line, column: column };
+ }
+
+ function matchFilePathAtColumn(lineText, col) {
+ FILE_PATH_RE.lastIndex = 0;
+ var match;
+ while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
+ var raw = match[0];
+ if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
+ var start = 0, end = raw.length;
+ while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
+ while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
+ if (start >= end) continue;
+ var spanStart = match.index + start;
+ var spanEnd = match.index + end;
+ if (col < spanStart || col > spanEnd) continue;
+ var parsed = parsePathLineCol(raw.slice(start, end));
+ if (parsed) return parsed;
+ }
+ return null;
+ }
+
+ // Emits terminal-file-tap when the tap lands on a path candidate, else
+ // terminal-tap. The host resolves + existence-checks the candidate, so a
+ // false positive (a non-file word) just opens nothing. Relies on
+ // viewportToCell/getLineText/notify from the host script scope.
+ function notifyTapOrFilePath(originX, originY) {
+ var tapCell = viewportToCell(originX, originY);
+ var tappedPath = tapCell
+ ? matchFilePathAtColumn(getLineText(tapCell.row), tapCell.col)
+ : null;
+ if (tappedPath) {
+ notify({
+ type: 'terminal-file-tap',
+ pathText: tappedPath.pathText,
+ line: tappedPath.line,
+ column: tappedPath.column
+ });
+ } else {
+ notify({ type: 'terminal-tap' });
+ }
+ }
+`
diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts
new file mode 100644
index 00000000000..e31971b6ec6
--- /dev/null
+++ b/mobile/src/terminal/terminal-path-tap.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
+
+// Returns the column of the first occurrence of `needle` in `line` (+offset).
+function colOf(line: string, needle: string, offset = 0): number {
+ return line.indexOf(needle) + offset
+}
+
+describe('parsePathWithOptionalLineColumn', () => {
+ it('splits trailing :line:col suffixes', () => {
+ expect(parsePathWithOptionalLineColumn('src/a.ts')).toEqual({
+ pathText: 'src/a.ts',
+ line: null,
+ column: null
+ })
+ expect(parsePathWithOptionalLineColumn('src/a.ts:42')).toEqual({
+ pathText: 'src/a.ts',
+ line: 42,
+ column: null
+ })
+ expect(parsePathWithOptionalLineColumn('src/a.ts:42:7')).toEqual({
+ pathText: 'src/a.ts',
+ line: 42,
+ column: 7
+ })
+ })
+
+ it('rejects directory-only and zero line/col', () => {
+ expect(parsePathWithOptionalLineColumn('src/')).toBeNull()
+ expect(parsePathWithOptionalLineColumn('src/a.ts:0')).toBeNull()
+ })
+})
+
+describe('matchFilePathAtColumn', () => {
+ it('matches an absolute path under the tap', () => {
+ const line = 'created /tmp/out/report.html for you'
+ const result = matchFilePathAtColumn(line, colOf(line, 'report'))
+ expect(result?.pathText).toBe('/tmp/out/report.html')
+ })
+
+ it('matches a relative path and parses line:col', () => {
+ const line = 'see src/components/Button.tsx:12:7 here'
+ const result = matchFilePathAtColumn(line, colOf(line, 'Button'))
+ expect(result).toEqual({ pathText: 'src/components/Button.tsx', line: 12, column: 7 })
+ })
+
+ it('matches a tilde path', () => {
+ const line = 'wrote ~/Documents/notes.md'
+ const result = matchFilePathAtColumn(line, colOf(line, 'notes'))
+ expect(result?.pathText).toBe('~/Documents/notes.md')
+ })
+
+ it('yields the tight whitespace-bounded segment under the tap', () => {
+ // On a path whose dir name has a space, tapping the file segment yields the
+ // openable sub-path after the space (still resolves against the worktree).
+ const line = '/Users/me/My Project/readme.md done'
+ const result = matchFilePathAtColumn(line, colOf(line, 'readme'))
+ expect(result?.pathText).toBe('Project/readme.md')
+ })
+
+ it('trims surrounding punctuation', () => {
+ const line = 'open (src/a.ts) now'
+ const result = matchFilePathAtColumn(line, colOf(line, 'a.ts'))
+ expect(result?.pathText).toBe('src/a.ts')
+ })
+
+ it('returns null when the tap is not on a path', () => {
+ const line = 'just some prose with no path here'
+ expect(matchFilePathAtColumn(line, colOf(line, 'prose'))).toBeNull()
+ })
+
+ it('returns null when the tap is left of the path span', () => {
+ const line = 'prefix /tmp/x.ts'
+ expect(matchFilePathAtColumn(line, 0)).toBeNull()
+ })
+
+ it('matches a bare filename with an extension (no slash)', () => {
+ // Why: agents commonly print a bare filename (e.g. a markdown link whose
+ // target was consumed). The host existence-check rejects non-files.
+ const line = '• Here you go: README.md'
+ const result = matchFilePathAtColumn(line, colOf(line, 'README'))
+ expect(result?.pathText).toBe('README.md')
+ })
+
+ it('does not match a plain word without an extension', () => {
+ const line = '• Here you go: README.md'
+ expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
+ })
+})
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
new file mode 100644
index 00000000000..7c9d295158b
--- /dev/null
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -0,0 +1,91 @@
+// File-path detection for a single tap in the terminal. Mirrors the desktop
+// link detection (src/renderer/src/lib/terminal-links.ts) but only finds the
+// one path span containing the tapped column — mobile opens a tapped path, it
+// does not render hover links over the whole line.
+
+export type TappedFilePath = {
+ pathText: string
+ line: number | null
+ column: number | null
+}
+
+// Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR
+// a bare filename with an extension (README.md, index.ts), optionally suffixed
+// with :line or :line:col. Like desktop, we propose candidates and let the host
+// existence-check reject non-files — agents often print a bare filename, so
+// requiring a slash would miss the common case. The desktop's spaced-path
+// variants are intentionally not ported: a tap always lands inside one
+// whitespace-bounded segment, so this already covers the real cases.
+const LOCAL_PATH_REGEX =
+ /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
+
+const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
+const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
+
+type Span = { startIndex: number; endIndex: number }
+
+function trimBoundaryPunctuation(
+ value: string,
+ startIndex: number
+): (Span & { text: string }) | null {
+ let start = 0
+ let end = value.length
+ while (start < end && LEADING_TRIM_CHARS.has(value[start])) {
+ start += 1
+ }
+ while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) {
+ end -= 1
+ }
+ if (start >= end) {
+ return null
+ }
+ return {
+ text: value.slice(start, end),
+ startIndex: startIndex + start,
+ endIndex: startIndex + end
+ }
+}
+
+export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null {
+ const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
+ if (!match) {
+ return null
+ }
+ const pathText = match[1]
+ // Reject a directory-only token (trailing separator) for either slash style.
+ if (!pathText || pathText.endsWith('/') || pathText.endsWith('\\')) {
+ return null
+ }
+ const line = match[2] ? Number.parseInt(match[2], 10) : null
+ const column = match[3] ? Number.parseInt(match[3], 10) : null
+ if ((line !== null && line < 1) || (column !== null && column < 1)) {
+ return null
+ }
+ return { pathText, line, column }
+}
+
+// Returns the file-path span (after punctuation trim) that contains `col`, or
+// null when the tap isn't on a path.
+export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
+ LOCAL_PATH_REGEX.lastIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) {
+ if (match[0].length === 0) {
+ LOCAL_PATH_REGEX.lastIndex += 1
+ continue
+ }
+ const trimmed = trimBoundaryPunctuation(match[0], match.index)
+ if (!trimmed) {
+ continue
+ }
+ // Inclusive of the trailing edge so a tap on the last glyph still counts.
+ if (col < trimmed.startIndex || col > trimmed.endIndex) {
+ continue
+ }
+ const parsed = parsePathWithOptionalLineColumn(trimmed.text)
+ if (parsed) {
+ return parsed
+ }
+ }
+ return null
+}
diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts
new file mode 100644
index 00000000000..29e15ea321b
--- /dev/null
+++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+
+import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
+
+describe('normalizeTerminalTextInput', () => {
+ it('converts iOS smart dash replacements back to terminal hyphens', () => {
+ expect(normalizeTerminalTextInput('git checkout – file')).toBe('git checkout -- file')
+ expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file')
+ })
+
+ it('keeps ASCII hyphens unchanged', () => {
+ expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
+ })
+
+ it('preserves longer trailing hyphen runs when iOS re-collapses the controlled value', () => {
+ expect(normalizeTerminalTextInput('—', '--')).toBe('---')
+ expect(normalizeTerminalTextInput('—', '---')).toBe('----')
+ expect(normalizeTerminalTextInput('git checkout —', 'git checkout --')).toBe('git checkout ---')
+ })
+})
diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts
new file mode 100644
index 00000000000..70500421b3b
--- /dev/null
+++ b/mobile/src/terminal/terminal-text-input-normalization.ts
@@ -0,0 +1,18 @@
+// Why: iOS smart punctuation can rewrite two ASCII hyphens into a single
+// Unicode dash before React Native delivers terminal text input.
+const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
+const IOS_SMART_DASH_REPLACEMENT_TEST = /[\u2013\u2014]/
+
+export function normalizeTerminalTextInput(text: string, previousText = ''): string {
+ const normalizedText = text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
+ const previousTrailingHyphens = /-+$/.exec(previousText)?.[0] ?? ''
+ const previousPrefix = previousText.slice(0, previousText.length - previousTrailingHyphens.length)
+ const collapsedPreviousHyphenRun =
+ previousTrailingHyphens.length >= 2 &&
+ IOS_SMART_DASH_REPLACEMENT_TEST.test(text) &&
+ (text === `${previousPrefix}\u2013` || text === `${previousPrefix}\u2014`)
+ if (collapsedPreviousHyphenRun) {
+ return `${previousText}-`
+ }
+ return normalizedText
+}
diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts
index af6cb9f42a5..e7a6d00f328 100644
--- a/mobile/src/terminal/terminal-viewport-refit.test.ts
+++ b/mobile/src/terminal/terminal-viewport-refit.test.ts
@@ -35,9 +35,22 @@ describe('terminal viewport refit', () => {
expect(tabEffect).toContain('scheduleViewportRefit()')
})
+ it('refits the PTY when terminal text scale changes', () => {
+ // Why: mobile text size must change the real PTY grid, not just scale pixels
+ // in the WebView, or wrapped CLI output diverges from what the shell sees.
+ const start = hookSource.indexOf('const prevTextScaleRef = useRef(textScale)')
+ expect(start).toBeGreaterThanOrEqual(0)
+ const textScaleEffect = hookSource.slice(start, start + 600)
+ expect(textScaleEffect).toContain('prevTextScaleRef.current === textScale')
+ expect(textScaleEffect).toContain('viewportMeasuredRef.current = false')
+ expect(textScaleEffect).toContain('scheduleViewportRefit()')
+ expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
+ })
+
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
+ expect(sessionSource).toContain('textScale: terminalTextScale')
})
it('prefers the in-place updateViewport RPC over resubscribe', () => {
diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts
index 6186b879522..ce69a056953 100644
--- a/mobile/src/terminal/terminal-viewport-refit.ts
+++ b/mobile/src/terminal/terminal-viewport-refit.ts
@@ -19,6 +19,9 @@ type TerminalViewportRefitOptions = {
deviceTokenRef: RefObject
initializedHandlesRef: RefObject>
tabStripVisible: boolean
+ // Why: terminal text size (font scale) — changing it changes the cell size, so
+ // the PTY must be re-fitted to a new column count and reflowed.
+ textScale: number
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
}
@@ -40,6 +43,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
deviceTokenRef,
initializedHandlesRef,
tabStripVisible,
+ textScale,
unsubscribeTerminal,
subscribeToTerminal
} = options
@@ -164,6 +168,20 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
+ // Why: the text size changed, so the WebView is re-rendering at a new font/cell
+ // size. Re-measure and resize the PTY so the server reflows to the new column
+ // count. The refit's own 150ms debounce gives the WebView a frame to apply the
+ // new fontSize before we measure the resulting cell metrics.
+ const prevTextScaleRef = useRef(textScale)
+ useEffect(() => {
+ if (prevTextScaleRef.current === textScale) {
+ return
+ }
+ prevTextScaleRef.current = textScale
+ viewportMeasuredRef.current = false
+ scheduleViewportRefit()
+ }, [textScale, viewportMeasuredRef, scheduleViewportRefit])
+
useEffect(() => {
disposedRef.current = false
return () => {
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
new file mode 100644
index 00000000000..4814f443c90
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -0,0 +1,1908 @@
+// xterm.js WebView document + default Tokyonight theme. Extracted from
+// TerminalWebView.tsx to keep that file within the max-lines budget.
+import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
+import { colors } from '../theme/mobile-theme'
+import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
+import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
+
+const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
+ background: colors.terminalBg,
+ foreground: '#c0caf5',
+ cursor: '#c0caf5',
+ cursorAccent: colors.terminalBg,
+ selectionBackground: '#33467c',
+ selectionForeground: '#c0caf5',
+ black: '#15161e',
+ red: '#f7768e',
+ green: '#9ece6a',
+ yellow: '#e0af68',
+ blue: '#7aa2f7',
+ magenta: '#bb9af7',
+ cyan: '#7dcfff',
+ white: '#a9b1d6',
+ brightBlack: '#414868',
+ brightRed: '#f7768e',
+ brightGreen: '#9ece6a',
+ brightYellow: '#e0af68',
+ brightBlue: '#7aa2f7',
+ brightMagenta: '#bb9af7',
+ brightCyan: '#7dcfff',
+ brightWhite: '#c0caf5'
+}
+
+// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
+// positioning designed for the desktop's terminal dimensions (~150+ cols).
+// We initialize xterm at the desktop's exact cols/rows so those escape codes
+// render correctly, then use a measured CSS transform: scale() to fit the
+// canvas into the phone viewport. The scale is computed after xterm opens
+// by measuring the rendered surface width, not hardcoded, so it adapts to
+// any terminal column count (80, 150, 200+). All touch gestures (scroll,
+// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
+// behavior, so they work correctly with the CSS scale transform.
+export const XTERM_HTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
index d1c4398d1ed..0651050dbc0 100644
--- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
+++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
@@ -1,11 +1,19 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
-const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
+// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
+// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
+const source =
+ readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
+ readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8')
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
+const sessionHelperSource = readFileSync(
+ new URL('../session/mobile-session-route-helpers.ts', import.meta.url),
+ 'utf8'
+)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -180,8 +188,11 @@ describe('TerminalWebView scroll routing', () => {
"document.addEventListener('touchend'",
'}, { capture: true, passive: true });'
)
+ // Why: mouse-click synthesis must precede the tap/file-path fallback so a
+ // bound mouse mode wins. The fallback now routes through notifyTapOrFilePath
+ // (which emits terminal-file-tap on a path, else terminal-tap).
expect(touchEndBlock.indexOf('var clickInput = buildMouseClickInput')).toBeLessThan(
- touchEndBlock.indexOf("notify({ type: 'terminal-tap' });")
+ touchEndBlock.indexOf('notifyTapOrFilePath(')
)
expect(touchEndBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });")
expect(touchEndBlock).toContain(
@@ -190,8 +201,10 @@ describe('TerminalWebView scroll routing', () => {
})
it('allows x10 mouse gesture reports through the mobile session gate', () => {
- expect(sessionSource).toContain('function isGestureMouseTrackingMode')
- expect(sessionSource).toContain("return mode === 'x10' || isWheelMouseTrackingMode(mode)")
+ expect(sessionHelperSource).toContain('function isGestureMouseTrackingMode')
+ expect(sessionHelperSource).toContain(
+ "return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'"
+ )
const inputBlockStart = sessionSource.indexOf('const handleTerminalInput = useCallback')
expect(inputBlockStart).toBeGreaterThanOrEqual(0)
diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx
index c6ebbcc5332..f889cba51ff 100644
--- a/mobile/src/transport/client-context.tsx
+++ b/mobile/src/transport/client-context.tsx
@@ -20,6 +20,7 @@ import {
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
+import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
@@ -320,6 +321,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
+ // Why: nudge every live client when the OS signals the link may be back
+ // (foreground, network restored/switched) so sessions recover without an
+ // app restart (issue #5049).
+ useEffect(() => {
+ return subscribeConnectionRevivalTriggers(() => {
+ for (const entry of storeRef.current.values()) {
+ entry.client.notifyForeground()
+ }
+ })
+ }, [])
+
const value = useMemo(
() => ({
acquire,
@@ -387,16 +399,13 @@ export function useHostClient(hostId: string | undefined): {
return
}
setState(next)
- // Why: if the client was null at first acquire (async open), the
- // first state change ('connecting'/'handshaking'/'connected') is our
- // signal to re-read.
- if (clientRef.current == null) {
- const all = ctx.getAllClients()
- const found = all.find((entry) => entry.hostId === hostId)
- if (found) {
- clientRef.current = found.client
- force((n) => n + 1)
- }
+ // Why: the client materialises after an async open, and forceReconnect
+ // swaps in a fresh client object. Re-read on every state change so a
+ // mounted screen never keeps driving a stale (closed) client.
+ const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
+ if (found && found.client !== clientRef.current) {
+ clientRef.current = found.client
+ force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)
diff --git a/mobile/src/transport/connection-revival-triggers.test.ts b/mobile/src/transport/connection-revival-triggers.test.ts
new file mode 100644
index 00000000000..30efcd4f9bd
--- /dev/null
+++ b/mobile/src/transport/connection-revival-triggers.test.ts
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
+
+type AppStateListener = (next: string) => void
+type NetworkSnapshot = { isConnected?: boolean; type?: string }
+type NetworkListener = (state: NetworkSnapshot) => void
+
+let appStateListener: AppStateListener | null = null
+let networkListener: NetworkListener | null = null
+let seededNetworkState: NetworkSnapshot = { isConnected: true, type: 'WIFI' }
+const appStateRemove = vi.fn()
+const networkRemove = vi.fn()
+
+vi.mock('react-native', () => ({
+ AppState: {
+ addEventListener: (_event: string, listener: AppStateListener) => {
+ appStateListener = listener
+ return { remove: appStateRemove }
+ }
+ }
+}))
+
+vi.mock('expo-network', () => ({
+ getNetworkStateAsync: () => Promise.resolve(seededNetworkState),
+ addNetworkStateListener: (listener: NetworkListener) => {
+ networkListener = listener
+ return { remove: networkRemove }
+ }
+}))
+
+// Why: the baseline seed resolves on a microtask; flush it so listener
+// events in the test observe the same ordering as a real subscription.
+async function subscribeAndSeed(nudge: () => void): Promise<() => void> {
+ const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
+ await Promise.resolve()
+ return unsubscribe
+}
+
+describe('subscribeConnectionRevivalTriggers', () => {
+ let nudge: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ appStateListener = null
+ networkListener = null
+ seededNetworkState = { isConnected: true, type: 'WIFI' }
+ nudge = vi.fn()
+ })
+
+ it('nudges when the app returns to the foreground, not on background', async () => {
+ await subscribeAndSeed(nudge)
+ appStateListener?.('background')
+ expect(nudge).not.toHaveBeenCalled()
+ appStateListener?.('active')
+ expect(nudge).toHaveBeenCalledTimes(1)
+ })
+
+ it('nudges when the network comes back online', async () => {
+ await subscribeAndSeed(nudge)
+ networkListener?.({ isConnected: false, type: 'NONE' })
+ expect(nudge).not.toHaveBeenCalled()
+ networkListener?.({ isConnected: true, type: 'WIFI' })
+ expect(nudge).toHaveBeenCalledTimes(1)
+ })
+
+ it('nudges when the app started offline and the first event is the recovery', async () => {
+ seededNetworkState = { isConnected: false, type: 'NONE' }
+ await subscribeAndSeed(nudge)
+ networkListener?.({ isConnected: true, type: 'WIFI' })
+ expect(nudge).toHaveBeenCalledTimes(1)
+ })
+
+ it('nudges on a Wi-Fi to cellular handoff that never reports offline', async () => {
+ await subscribeAndSeed(nudge)
+ networkListener?.({ isConnected: true, type: 'CELLULAR' })
+ expect(nudge).toHaveBeenCalledTimes(1)
+ })
+
+ it('stays quiet when the network state matches the seeded baseline', async () => {
+ await subscribeAndSeed(nudge)
+ networkListener?.({ isConnected: true, type: 'WIFI' })
+ networkListener?.({ isConnected: true, type: 'WIFI' })
+ expect(nudge).not.toHaveBeenCalled()
+ })
+
+ it('ignores a stale seed that resolves after unsubscribe', async () => {
+ seededNetworkState = { isConnected: false, type: 'NONE' }
+ const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
+ unsubscribe()
+ await Promise.resolve()
+ expect(appStateRemove).toHaveBeenCalledTimes(1)
+ expect(networkRemove).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/transport/connection-revival-triggers.ts b/mobile/src/transport/connection-revival-triggers.ts
new file mode 100644
index 00000000000..1391607d4bf
--- /dev/null
+++ b/mobile/src/transport/connection-revival-triggers.ts
@@ -0,0 +1,50 @@
+import { AppState } from 'react-native'
+import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from 'expo-network'
+
+// Why: Android/iOS suspend JS timers and silently kill sockets while the app
+// is backgrounded, and network handoffs (Wi-Fi → cellular) kill the TCP path
+// without an onclose. Both leave clients waiting out long backoff timers or
+// parked at the reconnect give-up cap (issue #5049). Surface every "the link
+// probably just came back" OS signal as a single nudge callback.
+export function subscribeConnectionRevivalTriggers(nudge: () => void): () => void {
+ const appStateSub = AppState.addEventListener('change', (next) => {
+ if (next === 'active') {
+ nudge()
+ }
+ })
+ let lastNetwork: Pick | null = null
+ let disposed = false
+ // Why: the listener only fires on *changes*; without a seeded baseline the
+ // first change after subscribing (app launched offline, network returns)
+ // would be swallowed by the previous == null guard below.
+ void getNetworkStateAsync()
+ .then((state) => {
+ if (!disposed && lastNetwork == null) {
+ lastNetwork = { isConnected: state.isConnected, type: state.type }
+ }
+ })
+ .catch(() => {})
+ const networkSub = addNetworkStateListener((state) => {
+ const previous = lastNetwork
+ lastNetwork = { isConnected: state.isConnected, type: state.type }
+ if (state.isConnected !== true) {
+ return
+ }
+ const cameOnline = previous != null && previous.isConnected !== true
+ // Why: a type change while staying "connected" is the Wi-Fi → cellular
+ // handoff case — the old socket is dead even though we never went offline.
+ const switchedNetworks = previous?.type != null && state.type !== previous.type
+ if (cameOnline || switchedNetworks) {
+ console.log('[net] network changed — nudging clients', {
+ type: state.type,
+ cameOnline
+ })
+ nudge()
+ }
+ })
+ return () => {
+ disposed = true
+ appStateSub.remove()
+ networkSub.remove()
+ }
+}
diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts
index 5db04aeeb97..feb9eb477cb 100644
--- a/mobile/src/transport/host-store.ts
+++ b/mobile/src/transport/host-store.ts
@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
+import { Platform } from 'react-native'
import {
HostProfileSchema,
StoredHostProfileSchema,
@@ -13,6 +14,7 @@ const STORAGE_KEY = 'orca:hosts'
// Use dots as the separator so the key shape stays readable while
// satisfying the validator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
+const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
@@ -26,6 +28,35 @@ function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
+function webTokenKey(hostId: string): string {
+ return `${WEB_TOKEN_KEY_PREFIX}${hostId}`
+}
+
+async function readDeviceToken(hostId: string): Promise {
+ // Why: Expo SecureStore has no working web backend; keep this fallback
+ // web-only so native builds still keep pairing tokens in the keychain.
+ if (Platform.OS === 'web') {
+ return AsyncStorage.getItem(webTokenKey(hostId))
+ }
+ return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
+}
+
+async function writeDeviceToken(hostId: string, token: string): Promise {
+ if (Platform.OS === 'web') {
+ await AsyncStorage.setItem(webTokenKey(hostId), token)
+ return
+ }
+ await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS)
+}
+
+async function deleteDeviceToken(hostId: string): Promise {
+ if (Platform.OS === 'web') {
+ await AsyncStorage.removeItem(webTokenKey(hostId))
+ return
+ }
+ await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
+}
+
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
@@ -81,7 +112,7 @@ async function doLoadHosts(): Promise {
if (!token) {
let fetched: string | null
try {
- fetched = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
+ fetched = await readDeviceToken(stored.data.id)
} catch {
// Why: a transient Keychain failure for one entry (e.g.
// errSecInteractionNotAllowed while the device is briefly locked,
@@ -153,7 +184,7 @@ export async function saveHost(host: HostProfile): Promise {
// the latter would persist forever since removeHost only deletes by hostId
// from current metadata.
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
- await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS)
+ await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
}
@@ -161,7 +192,7 @@ export async function removeHost(hostId: string): Promise {
const hosts = await loadStoredHosts()
const filtered = hosts.filter((h) => h.id !== hostId)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
- await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
+ await deleteDeviceToken(hostId)
tokenCache.delete(hostId)
}
diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts
new file mode 100644
index 00000000000..da73fc0b5fd
--- /dev/null
+++ b/mobile/src/transport/rpc-client-live-recovery.test.ts
@@ -0,0 +1,208 @@
+// Live (real-socket, real-timer) repro harness for issue #5049: Android
+// remote sessions that appear connected but stop responding until the app
+// is reopened. Unlike rpc-client.test.ts (fake timers, mocked e2ee), this
+// runs the REAL rpc-client with real tweetnacl E2EE against an in-process
+// ws server, simulating the Tailscale failure modes behind the report.
+//
+// Opt-in because the quick scenario takes ~15s wall-clock and the full
+// parked-loop scenario ~8 minutes:
+// ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/rpc-client-live-recovery.test.ts
+// ORCA_MOBILE_LIVE_REPRO_FULL=1 ... (adds the 8-minute parked-loop case)
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { randomBytes } from 'node:crypto'
+import type { AddressInfo } from 'node:net'
+import nacl from 'tweetnacl'
+import { WebSocketServer, type WebSocket as ServerSocket } from 'ws'
+import { connect, type RpcClient } from './rpc-client'
+
+// Why: expo-crypto only exists inside a React Native runtime; Node's CSPRNG
+// is equivalent for the harness. Everything else (tweetnacl, the wire
+// protocol) is the real production path.
+vi.mock('expo-crypto', () => ({
+ getRandomBytes: (n: number) => new Uint8Array(randomBytes(n))
+}))
+
+const RUN_LIVE =
+ process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL
+const RUN_FULL = process.env.ORCA_MOBILE_LIVE_REPRO_FULL === '1'
+
+const AUTH_TOKEN = 'repro-device-token'
+
+const serverKeyPair = nacl.box.keyPair()
+const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64')
+
+// When true the server accepts traffic but never replies — simulates a
+// half-open link where TCP looks alive but the path is dead.
+let blackhole = false
+
+function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string {
+ const nonce = nacl.randomBytes(nacl.box.nonceLength)
+ const msg = new TextEncoder().encode(plaintext)
+ const ciphertext = nacl.box.after(msg, nonce, sharedKey)
+ const bundle = new Uint8Array(nonce.length + ciphertext.length)
+ bundle.set(nonce)
+ bundle.set(ciphertext, nonce.length)
+ return Buffer.from(bundle).toString('base64')
+}
+
+function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
+ const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
+ if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
+ return null
+ }
+ const nonce = bundle.slice(0, nacl.box.nonceLength)
+ const plaintext = nacl.box.open.after(bundle.slice(nacl.box.nonceLength), nonce, sharedKey)
+ return plaintext ? new TextDecoder().decode(plaintext) : null
+}
+
+// Why: port 0 lets the OS assign a free port so the opt-in harness can't
+// fail with EADDRINUSE; the full scenario restarts on the captured port
+// because the client keeps reconnecting to its original URL.
+function startServer(port = 0): Promise {
+ const wss = new WebSocketServer({ port })
+ wss.on('connection', (ws: ServerSocket) => {
+ let sharedKey: Uint8Array | null = null
+ let authenticated = false
+ ws.on('message', (data) => {
+ if (blackhole) {
+ return
+ }
+ const msg = typeof data === 'string' ? data : data.toString('utf-8')
+ if (!sharedKey) {
+ const hello = JSON.parse(msg) as { publicKeyB64: string }
+ const clientKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64'))
+ sharedKey = nacl.box.before(clientKey, serverKeyPair.secretKey)
+ ws.send(JSON.stringify({ type: 'e2ee_ready' }))
+ return
+ }
+ const plaintext = e2eeDecrypt(msg, sharedKey)
+ if (!plaintext) {
+ return
+ }
+ const request = JSON.parse(plaintext) as { id?: string; type?: string; deviceToken?: string }
+ if (!authenticated) {
+ if (request.type === 'e2ee_auth' && request.deviceToken === AUTH_TOKEN) {
+ authenticated = true
+ ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
+ }
+ return
+ }
+ ws.send(
+ e2eeEncrypt(JSON.stringify({ id: request.id, ok: true, result: { up: true } }), sharedKey)
+ )
+ })
+ })
+ return new Promise((resolve) => wss.once('listening', () => resolve(wss)))
+}
+
+function serverPort(wss: WebSocketServer): number {
+ return (wss.address() as AddressInfo).port
+}
+
+function stopServer(wss: WebSocketServer): Promise {
+ return new Promise((resolve) => {
+ for (const ws of wss.clients) {
+ ws.terminate()
+ }
+ wss.close(() => resolve())
+ })
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function waitFor(label: string, timeoutMs: number, check: () => boolean): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ if (check()) {
+ return Date.now() - start
+ }
+ await sleep(200)
+ }
+ throw new Error(`timed out after ${timeoutMs / 1000}s waiting for: ${label}`)
+}
+
+describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
+ let client: RpcClient | null = null
+ let wss: WebSocketServer | null = null
+
+ afterEach(async () => {
+ blackhole = false
+ client?.close()
+ client = null
+ if (wss) {
+ await stopServer(wss)
+ wss = null
+ }
+ })
+
+ it(
+ 'reaps a half-open link via the foreground probe and recovers',
+ { timeout: 60_000 },
+ async () => {
+ wss = await startServer()
+ client = connect(`ws://127.0.0.1:${serverPort(wss)}`, AUTH_TOKEN, serverPublicKeyB64)
+ const c = client
+ await waitFor('initial connect', 10_000, () => c.getState() === 'connected')
+ expect((await c.sendRequest('status.get')).ok).toBe(true)
+
+ // Half-open: server keeps TCP up but stops answering, then the app
+ // comes back to the foreground.
+ blackhole = true
+ c.notifyForeground()
+ // Foreground probe budget is 8s; the interval probe alone would take
+ // up to 28s. Allow scheduling slack but stay well under 28s.
+ const detectMs = await waitFor(
+ 'half-open detected',
+ 15_000,
+ () => c.getState() !== 'connected'
+ )
+ expect(detectMs).toBeLessThan(12_000)
+
+ blackhole = false
+ await waitFor('recovered after link healed', 15_000, () => c.getState() === 'connected')
+ expect((await c.sendRequest('status.get')).ok).toBe(true)
+ }
+ )
+
+ it.runIf(RUN_FULL)(
+ 'repro: parked retry loop stays stuck until the foreground nudge',
+ { timeout: 600_000 },
+ async () => {
+ wss = await startServer()
+ const port = serverPort(wss)
+ client = connect(`ws://127.0.0.1:${port}`, AUTH_TOKEN, serverPublicKeyB64)
+ const c = client
+ await waitFor('initial connect', 10_000, () => c.getState() === 'connected')
+
+ await stopServer(wss)
+ wss = null
+ await waitFor('retry cap scheduled (~5 min)', 480_000, () => c.getReconnectAttempt() >= 12)
+ // The attempt counter hits 12 when the final attempt is *scheduled*;
+ // its 60s backoff timer is still pending. Let it fire and fail while
+ // the server is still down so the loop truly parks.
+ await sleep(65_000)
+ expect(c.getState()).toBe('reconnecting')
+
+ wss = await startServer(port)
+ // Pre-fix behavior: even with the server back, a parked loop never
+ // recovers — the user had to restart the app.
+ await sleep(70_000)
+ expect(c.getState()).not.toBe('connected')
+
+ c.notifyForeground()
+ await waitFor('foreground nudge recovered the session', 15_000, () => {
+ return c.getState() === 'connected'
+ })
+ expect((await c.sendRequest('status.get')).ok).toBe(true)
+ }
+ )
+})
+
+// Why: vitest fails a file with zero tests; keep a sentinel for default runs.
+describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => {
+ it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => {
+ expect(true).toBe(true)
+ })
+})
diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts
index 3e01e7e39e9..a45a018a6a0 100644
--- a/mobile/src/transport/rpc-client.test.ts
+++ b/mobile/src/transport/rpc-client.test.ts
@@ -217,6 +217,23 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
+ it('does not resend a stream subscribed from the connected-state listener', () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key', (state) => {
+ if (state === 'connected') {
+ client.subscribe('notifications.subscribe', {}, () => {})
+ }
+ })
+ const socket = mockSockets[0]!
+
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_authenticated"}')
+
+ expect(sentRequests(socket, 'notifications.subscribe')).toHaveLength(1)
+
+ client.close()
+ })
+
it('routes browser screencast binary frames to the browser subscriber', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
@@ -581,6 +598,202 @@ describe('mobile rpc-client connection timeout', () => {
}
})
+ // Repro for issue #5049: Android sessions that appear connected (or stuck
+ // "Reconnecting…") after the app returns to the foreground, recoverable
+ // only by restarting the app. notifyForeground is the recovery hook the
+ // provider invokes on AppState 'active'.
+ describe('foreground recovery', () => {
+ function openAndAuthenticate(socket: MockWebSocket) {
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_authenticated"}')
+ }
+
+ it('repro: a parked reconnect loop never retries on its own', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ openAndAuthenticate(mockSockets[0]!)
+ mockSockets[0]!.close()
+
+ await vi.runAllTimersAsync()
+ expect(client.getState()).toBe('reconnecting')
+ expect(client.getReconnectAttempt()).toBe(12)
+
+ // Stuck: arbitrary additional time produces no further attempts.
+ const socketsBefore = mockSockets.length
+ await vi.advanceTimersByTimeAsync(600_000)
+ expect(mockSockets.length).toBe(socketsBefore)
+
+ client.close()
+ })
+
+ it('restarts a parked reconnect loop on foreground', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ openAndAuthenticate(mockSockets[0]!)
+ mockSockets[0]!.close()
+ await vi.runAllTimersAsync()
+ expect(client.getReconnectAttempt()).toBe(12)
+
+ const socketsBefore = mockSockets.length
+ client.notifyForeground()
+
+ expect(mockSockets.length).toBe(socketsBefore + 1)
+ expect(client.getReconnectAttempt()).toBe(0)
+ openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
+ expect(client.getState()).toBe('connected')
+
+ client.close()
+ })
+
+ it('fast-forwards a pending backoff timer on foreground', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ openAndAuthenticate(mockSockets[0]!)
+ mockSockets[0]!.close()
+ expect(client.getState()).toBe('reconnecting')
+
+ const socketsBefore = mockSockets.length
+ client.notifyForeground()
+
+ expect(mockSockets.length).toBe(socketsBefore + 1)
+ openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
+ expect(client.getState()).toBe('connected')
+
+ // The cleared backoff timer must not fire a duplicate attempt.
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(mockSockets.length).toBe(socketsBefore + 1)
+
+ client.close()
+ })
+
+ it('reaps a half-open socket within 8s of foreground', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const socket = mockSockets[0]!
+ openAndAuthenticate(socket)
+
+ // Half-open: readyState stays OPEN but the server never answers.
+ client.notifyForeground()
+ expect(sentRequests(socket, 'status.get')).toHaveLength(1)
+
+ await vi.advanceTimersByTimeAsync(8_000)
+ expect(socket.close).toHaveBeenCalled()
+ expect(client.getState()).toBe('reconnecting')
+
+ await vi.advanceTimersByTimeAsync(500)
+ openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
+ expect(client.getState()).toBe('connected')
+
+ client.close()
+ })
+
+ it('keeps a healthy connection when the foreground probe is answered', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const socket = mockSockets[0]!
+ openAndAuthenticate(socket)
+
+ client.notifyForeground()
+ const probe = sentRequest(socket, 'status.get')
+ socket.receive(`encrypted:${JSON.stringify({ id: probe.id, ok: true, result: {} })}`)
+
+ await vi.advanceTimersByTimeAsync(10_000)
+ expect(socket.close).not.toHaveBeenCalled()
+ expect(client.getState()).toBe('connected')
+
+ client.close()
+ })
+
+ it('is a no-op after the client is closed', () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ openAndAuthenticate(mockSockets[0]!)
+ client.close()
+
+ const socketsBefore = mockSockets.length
+ client.notifyForeground()
+ expect(mockSockets.length).toBe(socketsBefore)
+ expect(client.getState()).toBe('disconnected')
+ })
+ })
+
+ // Issue #5200: a single auth rejection used to latch 'auth-failed'
+ // permanently, forcing a needless re-pair even when the desktop still
+ // listed the device with a valid token. The client now retries the
+ // handshake a bounded number of times before declaring auth dead.
+ describe('auth rejection retry (issue #5200)', () => {
+ function authenticate(socket: MockWebSocket) {
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_authenticated"}')
+ }
+
+ it('retries the handshake on a transient e2ee_error instead of latching auth-failed', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const first = mockSockets[0]!
+ first.open()
+ first.receive(JSON.stringify({ type: 'e2ee_ready' }))
+
+ // Transient rejection during handshake — must NOT latch auth-failed.
+ first.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
+ expect(client.getState()).toBe('reconnecting')
+
+ // A fresh socket gets a fresh handshake; this time it authenticates.
+ await vi.advanceTimersByTimeAsync(500)
+ authenticate(mockSockets[mockSockets.length - 1]!)
+ expect(client.getState()).toBe('connected')
+
+ client.close()
+ })
+
+ it('latches auth-failed once the retry budget is exhausted', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+
+ // Three consecutive handshake rejections (AUTH_RETRY_BUDGET = 3).
+ for (let i = 0; i < 3; i++) {
+ if (i > 0) {
+ await vi.advanceTimersByTimeAsync(500)
+ }
+ const socket = mockSockets[mockSockets.length - 1]!
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
+ }
+
+ expect(client.getState()).toBe('auth-failed')
+
+ client.close()
+ })
+
+ it('resets the budget after a successful connect between rejections', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+
+ // Two rejections, then a clean connect resets the budget...
+ for (let i = 0; i < 2; i++) {
+ if (i > 0) {
+ await vi.advanceTimersByTimeAsync(500)
+ }
+ const socket = mockSockets[mockSockets.length - 1]!
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
+ }
+ await vi.advanceTimersByTimeAsync(500)
+ authenticate(mockSockets[mockSockets.length - 1]!)
+ expect(client.getState()).toBe('connected')
+
+ // ...so a later mid-session rejection gets the full budget again
+ // rather than immediately latching auth-failed.
+ const live = mockSockets[mockSockets.length - 1]!
+ const request = client.sendRequest('status.get').catch(() => undefined)
+ // sendRequest awaits waitForConnected before sending — let it flush.
+ await Promise.resolve()
+ const id = sentRequest(live, 'status.get').id
+ live.receive(
+ `encrypted:${JSON.stringify({ id, ok: false, error: { code: 'unauthorized' } })}`
+ )
+ await request
+ expect(client.getState()).toBe('reconnecting')
+
+ client.close()
+ })
+ })
+
it('rejects requests waiting for reconnect after the retry cap', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts
index 68c80c2284d..0179f893123 100644
--- a/mobile/src/transport/rpc-client.ts
+++ b/mobile/src/transport/rpc-client.ts
@@ -28,6 +28,7 @@ import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
+import { describeSocketEvent } from './socket-event-debug'
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -91,6 +92,10 @@ export type RpcClient = {
// to distinguish "host moved/never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
+ // Why: app-resume hook. Android/iOS can kill the TCP path or park the
+ // reconnect loop while the app is backgrounded; callers invoke this on
+ // AppState 'active' so the session recovers without an app restart.
+ notifyForeground: () => void
close: () => void
}
@@ -114,6 +119,15 @@ const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
// drift the user sees "Reconnecting…" while the loop is silently
// parked.
const GIVE_UP_AFTER_ATTEMPTS = 12
+// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead.
+// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair
+// while the desktop still listed it as paired with a valid token — a transient
+// rejection (mid-session resume race, a stale frame after background) latched
+// the terminal auth-failed state permanently. Retry the full handshake this
+// many times with a clean reconnect before declaring auth dead. A genuinely
+// revoked token is rejected on every attempt and converges to auth-failed in
+// seconds; a one-off glitch self-heals without the user re-pairing.
+const AUTH_RETRY_BUDGET = 3
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
@@ -177,6 +191,11 @@ export function connect(
let handshakeTimer: ReturnType | null = null
let activityProbeTimer: ReturnType | null = null
let intentionallyClosed = false
+ // Why: consecutive auth rejections since the last successful connect. We
+ // tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed
+ // so a transient rejection doesn't force a needless re-pair. Reset to 0 on
+ // every 'connected'.
+ let authRejectionCount = 0
let lastConnectedAt: number | null = null
// Why: diagnostic — when the rpc-client gets stuck in a state where every
// openConnection fails with code 1006 and only a force-quit recovers, we
@@ -242,6 +261,9 @@ export function connect(
})
if (next === 'connected') {
lastConnectedAt = Date.now()
+ // Why: a clean handshake proves the token is valid — clear the auth
+ // retry budget so a future isolated rejection gets the full budget again.
+ authRejectionCount = 0
for (const waiter of connectWaiters.splice(0)) {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
@@ -470,6 +492,12 @@ export function connect(
removeStreamListener(id)
continue
}
+ // Why: setState('connected') notifies UI listeners synchronously;
+ // a listener may subscribe and send immediately before this
+ // reconnect replay loop resumes.
+ if (stream.sent) {
+ continue
+ }
if (stream.method === 'browser.screencast') {
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
@@ -485,18 +513,11 @@ export function connect(
}
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
- emitLog(
- 'error',
- 'Authentication rejected',
- typeof msg.error?.message === 'string' ? msg.error.message : 'Unauthorized'
- )
- intentionallyClosed = true
- ws?.close()
- ws = null
- activeBrowserScreencastRequestId = null
- pendingBrowserScreencastRequestId = null
- setState('auth-failed')
- rejectAllPending('Unauthorized — pairing may be revoked')
+ if (handshakeTimer) {
+ clearTimeout(handshakeTimer)
+ handshakeTimer = null
+ }
+ handleAuthRejection('Unauthorized — pairing may be revoked')
}
} catch {
// Not JSON — ignore during handshake.
@@ -538,16 +559,12 @@ export function connect(
return
}
- // Why: auth failure is distinct from transient disconnect — retrying
- // with a rejected token causes infinite reconnect churn.
+ // Why: a mid-session unauthorized may be a transient glitch, not a dead
+ // pairing (issue #5200). handleAuthRejection retries the handshake a few
+ // times before latching auth-failed, while still bounding churn via the
+ // budget so a genuinely revoked token doesn't reconnect forever.
if (!response.ok && response.error.code === 'unauthorized') {
- intentionallyClosed = true
- ws?.close()
- ws = null
- activeBrowserScreencastRequestId = null
- pendingBrowserScreencastRequestId = null
- setState('auth-failed')
- rejectAllPending('Unauthorized — pairing may be revoked')
+ handleAuthRejection('Unauthorized — pairing may be revoked')
return
}
@@ -645,38 +662,9 @@ export function connect(
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
- // Why: inline the diagnostic dump. Earlier hot-reload tripped
- // `Property 'enumKeys' doesn't exist` because a stale closure
- // captured a half-loaded module. Inlining keeps the handler's
- // behavior fully decided at construction time.
- let closeEventKeys: string[] = []
- let closeEventStr = ''
- try {
- closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
- } catch {
- closeEventKeys = []
- }
- try {
- const seen = new WeakSet()
- closeEventStr = JSON.stringify(
- event,
- (_k, v) => {
- if (typeof v === 'object' && v !== null) {
- if (seen.has(v as object)) {
- return '[circular]'
- }
- seen.add(v as object)
- }
- if (typeof v === 'function') {
- return '[fn]'
- }
- return v
- },
- 0
- ).slice(0, 500)
- } catch {
- closeEventStr = '[unstringifiable]'
- }
+ // Why: statically imported (not closure-built) — an earlier hot-reload
+ // bug came from a stale closure capturing a half-loaded module.
+ const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: e?.code,
reason: e?.reason,
@@ -688,8 +676,8 @@ export function connect(
constructToCloseMs,
aliveMs,
inboundIdleMs,
- eventKeys: closeEventKeys,
- eventStr: closeEventStr
+ eventKeys: closeEvent.keys,
+ eventStr: closeEvent.json
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
@@ -704,41 +692,13 @@ export function connect(
// onclose fires right after, but logging the error message gives us
// the original cause that the close code alone can hide.
const e = event as { message?: string } | undefined
- // Why: inlined defensively — see ws.onclose comment.
- let errEventKeys: string[] = []
- let errEventStr = ''
- try {
- errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
- } catch {
- errEventKeys = []
- }
- try {
- const seen = new WeakSet()
- errEventStr = JSON.stringify(
- event,
- (_k, v) => {
- if (typeof v === 'object' && v !== null) {
- if (seen.has(v as object)) {
- return '[circular]'
- }
- seen.add(v as object)
- }
- if (typeof v === 'function') {
- return '[fn]'
- }
- return v
- },
- 0
- ).slice(0, 500)
- } catch {
- errEventStr = '[unstringifiable]'
- }
+ const errEvent = describeSocketEvent(event)
console.log('[net] ws.onerror', {
message: e?.message,
state,
attempt: reconnectAttempt,
- eventKeys: errEventKeys,
- eventStr: errEventStr
+ eventKeys: errEvent.keys,
+ eventStr: errEvent.json
})
}
}
@@ -756,6 +716,9 @@ export function connect(
sharedKey = null
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
+ for (const stream of streamListeners.values()) {
+ stream.sent = false
+ }
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
@@ -779,6 +742,51 @@ export function connect(
scheduleReconnect()
}
+ // Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session
+ // unauthorized RPC) may be transient — issue #5200. Retry the full handshake
+ // up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off
+ // glitch self-heals instead of forcing the user to re-pair. A genuinely
+ // revoked token fails every retry and latches auth-failed within seconds.
+ function handleAuthRejection(reason: string): void {
+ activeBrowserScreencastRequestId = null
+ pendingBrowserScreencastRequestId = null
+ authRejectionCount++
+ if (authRejectionCount < AUTH_RETRY_BUDGET) {
+ console.log('[net] auth rejected — retrying handshake', {
+ attempt: authRejectionCount,
+ budget: AUTH_RETRY_BUDGET,
+ endpoint: redactedEndpoint(endpoint)
+ })
+ emitLog(
+ 'warn',
+ 'Authentication rejected',
+ `Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
+ )
+ // Why: close the current socket but DON'T set intentionallyClosed —
+ // we want handleSocketClosed to route into the reconnect path so the
+ // token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs.
+ const closing = ws
+ ws = null
+ sharedKey = null
+ rejectAllPending(reason)
+ if (closing) {
+ closing.close()
+ }
+ setState('reconnecting')
+ scheduleReconnect()
+ return
+ }
+ console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
+ attempt: authRejectionCount,
+ endpoint: redactedEndpoint(endpoint)
+ })
+ intentionallyClosed = true
+ ws?.close()
+ ws = null
+ setState('auth-failed')
+ rejectAllPending(reason)
+ }
+
function scheduleReconnect() {
// Why: spinning reconnect forever drains battery and floods logs
// when the host is genuinely unreachable (wrong IP, port closed,
@@ -818,56 +826,58 @@ export function connect(
// at the top of the file. Fires while the channel is in 'connected'
// state, sends a tiny status.get, and force-closes the WS if the probe
// fails (which the existing onclose path then turns into a reconnect).
+ function runActivityProbe() {
+ // Why: only probe while the channel is actually in 'connected'. The
+ // sendRequest path itself waits for connected, but a probe scheduled
+ // during a reconnect would just stack up timeouts and confuse logs.
+ if (state !== 'connected' || !ws) {
+ return
+ }
+ const probeWs = ws
+ // Why: short timeout (8s) — server's heartbeat is 15s, so if we
+ // don't see *anything* back within 8s the link is almost certainly
+ // half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
+ // user wait nearly a minute before reconnect kicks in.
+ const id = nextId()
+ const probeStart = Date.now()
+ let timedOut = false
+ const timeout = setTimeout(() => {
+ timedOut = true
+ pending.delete(id)
+ console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
+ waitedMs: Date.now() - probeStart,
+ state
+ })
+ // Why: only force-close if this is still the same socket the
+ // probe was sent on; a normal close that already swapped `ws`
+ // shouldn't trigger a redundant terminate.
+ if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
+ probeWs.close()
+ }
+ }, 8_000)
+ pending.set(id, {
+ resolve: () => {
+ if (timedOut) {
+ return
+ }
+ clearTimeout(timeout)
+ },
+ reject: () => {
+ if (timedOut) {
+ return
+ }
+ clearTimeout(timeout)
+ }
+ })
+ if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
+ clearTimeout(timeout)
+ pending.delete(id)
+ }
+ }
+
function startActivityProbe() {
stopActivityProbe()
- activityProbeTimer = setInterval(() => {
- // Why: only probe while the channel is actually in 'connected'. The
- // sendRequest path itself waits for connected, but a probe scheduled
- // during a reconnect would just stack up timeouts and confuse logs.
- if (state !== 'connected' || !ws) {
- return
- }
- const probeWs = ws
- // Why: short timeout (8s) — server's heartbeat is 15s, so if we
- // don't see *anything* back within 8s the link is almost certainly
- // half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
- // user wait nearly a minute before reconnect kicks in.
- const id = nextId()
- const probeStart = Date.now()
- let timedOut = false
- const timeout = setTimeout(() => {
- timedOut = true
- pending.delete(id)
- console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
- waitedMs: Date.now() - probeStart,
- state
- })
- // Why: only force-close if this is still the same socket the
- // probe was sent on; a normal close that already swapped `ws`
- // shouldn't trigger a redundant terminate.
- if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
- probeWs.close()
- }
- }, 8_000)
- pending.set(id, {
- resolve: () => {
- if (timedOut) {
- return
- }
- clearTimeout(timeout)
- },
- reject: () => {
- if (timedOut) {
- return
- }
- clearTimeout(timeout)
- }
- })
- if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
- clearTimeout(timeout)
- pending.delete(id)
- }
- }, ACTIVITY_PROBE_INTERVAL_MS)
+ activityProbeTimer = setInterval(runActivityProbe, ACTIVITY_PROBE_INTERVAL_MS)
}
function stopActivityProbe() {
@@ -1216,6 +1226,38 @@ export function connect(
return () => stateListeners.delete(listener)
},
+ notifyForeground(): void {
+ if (intentionallyClosed) {
+ return
+ }
+ if (state === 'connected') {
+ // Why: the OS can kill the TCP path while the app is backgrounded
+ // without delivering onclose, leaving a half-open socket that
+ // blackholes input. Probe now so death is detected in ≤8s instead
+ // of waiting out the 20s interval (issue #5049).
+ console.log('[net] foreground — probing live connection')
+ startActivityProbe()
+ runActivityProbe()
+ return
+ }
+ if (state === 'reconnecting') {
+ // Why: while backgrounded the retry loop may have parked at the
+ // give-up cap or be sitting on a 60s backoff timer. Returning to
+ // the foreground is a strong user signal — restart with a fresh
+ // attempt budget immediately instead of requiring an app restart.
+ console.log('[net] foreground — restarting reconnect loop', {
+ attempt: reconnectAttempt,
+ hadTimer: !!reconnectTimer
+ })
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ reconnectAttempt = 0
+ openConnection()
+ }
+ },
+
close() {
intentionallyClosed = true
if (reconnectTimer) {
diff --git a/mobile/src/transport/socket-event-debug.ts b/mobile/src/transport/socket-event-debug.ts
new file mode 100644
index 00000000000..89b2167c79f
--- /dev/null
+++ b/mobile/src/transport/socket-event-debug.ts
@@ -0,0 +1,34 @@
+// Why: RN's WebSocket close/error events are loosely typed and vary per
+// platform. Serialize them defensively (circular-safe, function-safe,
+// truncated) so the [net] diagnostics can never crash mid-handler.
+export function describeSocketEvent(event: unknown): { keys: string[]; json: string } {
+ let keys: string[] = []
+ try {
+ keys = event && typeof event === 'object' ? Object.keys(event as object) : []
+ } catch {
+ keys = []
+ }
+ let json = ''
+ try {
+ const seen = new WeakSet()
+ json = JSON.stringify(
+ event,
+ (_k, v) => {
+ if (typeof v === 'object' && v !== null) {
+ if (seen.has(v as object)) {
+ return '[circular]'
+ }
+ seen.add(v as object)
+ }
+ if (typeof v === 'function') {
+ return '[fn]'
+ }
+ return v
+ },
+ 0
+ ).slice(0, 500)
+ } catch {
+ json = '[unstringifiable]'
+ }
+ return { keys, json }
+}
diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts
new file mode 100644
index 00000000000..80889753def
--- /dev/null
+++ b/mobile/src/worktree/agent-row-display.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ agentDisplayLabel,
+ agentDotState,
+ agentIdentityLabel,
+ formatTimeAgo
+} from './agent-row-display'
+
+function row(overrides: Partial = {}): RuntimeWorktreeAgentRow {
+ return {
+ paneKey: 'p',
+ parentPaneKey: null,
+ state: 'working',
+ agentType: 'claude',
+ prompt: '',
+ lastAssistantMessage: null,
+ toolName: null,
+ toolInput: null,
+ interrupted: false,
+ stateStartedAt: 0,
+ updatedAt: 0,
+ ...overrides
+ }
+}
+
+describe('agentDotState', () => {
+ it('maps known states through and unknown to idle', () => {
+ expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
+ expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
+ expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
+ expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
+ expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
+ })
+
+ it('reports interrupted regardless of state', () => {
+ expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted')
+ })
+
+ it('decays a stale active state to idle, matching desktop', () => {
+ const stale = AGENT_STATUS_STALE_AFTER_MS + 1
+ // Active states past the staleness window read as idle…
+ expect(agentDotState(row({ state: 'working', updatedAt: 0 }), stale)).toBe('idle')
+ expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), stale)).toBe('idle')
+ expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), stale)).toBe('idle')
+ // …exactly at the threshold it is still fresh (decay is strictly past it).
+ expect(
+ agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS)
+ ).toBe('working')
+ // 'done' never decays; interrupted still wins.
+ expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
+ expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe(
+ 'interrupted'
+ )
+ })
+})
+
+describe('agentDisplayLabel', () => {
+ it('prefers last message, then prompt, then state label', () => {
+ expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there')
+ expect(agentDisplayLabel(row({ lastAssistantMessage: ' ', prompt: 'do the thing' }), 0)).toBe(
+ 'do the thing'
+ )
+ expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
+ 'Working'
+ )
+ })
+
+ it('falls back to the decayed state label when stale', () => {
+ expect(
+ agentDisplayLabel(
+ row({ state: 'working', prompt: '', updatedAt: 0 }),
+ AGENT_STATUS_STALE_AFTER_MS + 1
+ )
+ ).toBe('Idle')
+ })
+})
+
+describe('agentIdentityLabel', () => {
+ it('maps known agent types and falls back to initials', () => {
+ expect(agentIdentityLabel('claude')).toBe('CL')
+ expect(agentIdentityLabel('codex')).toBe('CX')
+ expect(agentIdentityLabel('mystery')).toBe('MY')
+ expect(agentIdentityLabel(null)).toBe('')
+ })
+})
+
+describe('formatTimeAgo', () => {
+ const now = 10_000_000
+ it('formats across thresholds', () => {
+ expect(formatTimeAgo(now - 30_000, now)).toBe('just now')
+ expect(formatTimeAgo(now - 5 * 60_000, now)).toBe('5m')
+ expect(formatTimeAgo(now - 3 * 3_600_000, now)).toBe('3h')
+ expect(formatTimeAgo(now - 2 * 86_400_000, now)).toBe('2d')
+ })
+})
diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts
new file mode 100644
index 00000000000..f052f603cf2
--- /dev/null
+++ b/mobile/src/worktree/agent-row-display.ts
@@ -0,0 +1,104 @@
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+
+// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
+// 30 min). Defined locally rather than imported because a runtime-value import
+// from a root .ts breaks mobile's vitest transform (no tsconfig in the
+// mobile-only checkout); root type-only imports stay fine.
+export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
+
+// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
+// status state; 'blocked'/'waiting' read as attention states, 'done' as
+// complete, everything else idle.
+export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted'
+
+export function agentDotState(
+ row: Pick,
+ now: number
+): AgentDotState {
+ if (row.interrupted) {
+ return 'interrupted'
+ }
+ switch (row.state) {
+ case 'working':
+ case 'blocked':
+ case 'waiting':
+ // Why: an agent that exits without a final report would otherwise read as
+ // active forever. Decay a stale active state to idle, matching desktop's
+ // renderer-side staleness decay (worktree-agent-rows.ts).
+ return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
+ case 'done':
+ return 'done'
+ }
+ return 'idle'
+}
+
+// Mirrors desktop agentStateLabel.
+export function agentStateLabel(state: AgentDotState): string {
+ switch (state) {
+ case 'working':
+ return 'Working'
+ case 'blocked':
+ return 'Blocked'
+ case 'waiting':
+ return 'Waiting for input'
+ case 'interrupted':
+ return 'Interrupted'
+ case 'done':
+ return 'Done'
+ case 'idle':
+ return 'Idle'
+ }
+}
+
+// Primary row text: prefer the agent's last message, then the user prompt, then
+// a human-readable state label so a row is never blank. Matches the desktop
+// DashboardAgentRow displayLabel fallback chain.
+export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string {
+ const message = row.lastAssistantMessage?.trim()
+ if (message) {
+ return message
+ }
+ const prompt = row.prompt.trim()
+ if (prompt) {
+ return prompt
+ }
+ return agentStateLabel(agentDotState(row, now))
+}
+
+// Short agent identity label by type (Claude/Codex/Gemini/…), used when no
+// identity icon is available on mobile. Falls back to the first two letters.
+export function agentIdentityLabel(agentType: string | null): string {
+ if (!agentType) {
+ return ''
+ }
+ const normalized = agentType.toLowerCase()
+ const known: Record = {
+ claude: 'CL',
+ codex: 'CX',
+ gemini: 'GM',
+ cursor: 'CR',
+ copilot: 'CP',
+ amp: 'AM',
+ aider: 'AI',
+ opencode: 'OC'
+ }
+ return known[normalized] ?? normalized.slice(0, 2).toUpperCase()
+}
+
+// Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd).
+export function formatTimeAgo(ts: number, now: number): string {
+ const delta = now - ts
+ if (delta < 60_000) {
+ return 'just now'
+ }
+ const minutes = Math.floor(delta / 60_000)
+ if (minutes < 60) {
+ return `${minutes}m`
+ }
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) {
+ return `${hours}h`
+ }
+ const days = Math.floor(hours / 24)
+ return `${days}d`
+}
diff --git a/mobile/src/worktree/agent-row-lineage.test.ts b/mobile/src/worktree/agent-row-lineage.test.ts
new file mode 100644
index 00000000000..468f21202fa
--- /dev/null
+++ b/mobile/src/worktree/agent-row-lineage.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import { buildAgentRowLineageTree, flattenAgentRowLineage } from './agent-row-lineage'
+
+function row(
+ paneKey: string,
+ parentPaneKey: string | null = null,
+ overrides: Partial