Merge remote-tracking branch 'origin/main' into brennanb2025/workspace-cleanup-dialog

# Conflicts:
#	src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx
#	src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
#	src/renderer/src/store/slices/diffComments.test.ts
This commit is contained in:
Brennan Benson
2026-08-11 16:01:29 -07:00
868 changed files with 49487 additions and 26331 deletions
+3
View File
@@ -378,6 +378,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows PowerShell CLI boundary
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
- name: Build package inputs
run: pnpm run build:release
@@ -77,7 +77,6 @@ jobs:
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'config/build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
+4
View File
@@ -2,6 +2,10 @@
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Electron UI Validation
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
+124 -14
View File
@@ -1,7 +1,8 @@
import { spawnSync } from 'node:child_process'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
type NormalizedInputOptions = Rollup.NormalizedInputOptions
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
'port-scan-command-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
...PLAIN_NODE_ENTRY_NAMES,
...WORKER_THREAD_ENTRY_NAMES
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
// Why: writeBundle skips any name missing from the bundle, so a renamed or
// removed rollup input would silently drop that entry from the guard and let the
// regression back in. Pin the lists to the input keys at build start instead.
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
if (typeof input === 'string' || Array.isArray(input)) {
return
}
const inputNames = new Set(Object.keys(input))
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
if (missing.length > 0) {
throw new Error(
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
`the current entry names — a stale name silently stops guarding that entry.`
)
}
}
function collectReachableChunks(
entry: OutputChunk,
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
}
}
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
export type SmokeTimings = {
timeoutMs: number
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
// needs an uncatchable follow-up to stay a deadline.
killGraceMs: number
}
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
// can hold the pipes open long after the child is gone.
const SMOKE_STDERR_DRAIN_MS = 250
type SmokeResult = {
status: number | null
signal: NodeJS.Signals | null
stderr: string
error?: Error
timedOut: boolean
}
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
// build forever. Escalate to SIGKILL instead.
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let timedOut = false
let settled = false
let forceKillTimer: NodeJS.Timeout | undefined
let drainTimer: NodeJS.Timeout | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const deadlineTimer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
}, timings.timeoutMs)
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadlineTimer)
clearTimeout(forceKillTimer)
clearTimeout(drainTimer)
resolve({ status, signal, stderr, error, timedOut })
}
child.on('error', (error: Error) => finish(null, null, error))
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
// cannot outlast the process itself.
child.on('close', (status, signal) => finish(status, signal))
child.on('exit', (status, signal) => {
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
})
})
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
const result = await runDaemonEntry(entryPath, timings)
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
// Almost always means the daemon stopped rejecting an empty argv and started
// listening instead.
if (result.timedOut) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
`empty argv under plain Node, so the smoke killed it.`
)
}
if (result.signal) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
)
}
const stderr = result.stderr
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
`${result.status}). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
export function createPlainNodeEntryGuardPlugin(
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
buildStart(options: NormalizedInputOptions) {
assertEntryNamesAreRollupInputs(options.input)
},
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
daemonOutputDir = options.dir
}
},
closeBundle() {
async closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
smokeLoadDaemonEntry(outputDir)
await smokeLoadDaemonEntry(outputDir, smokeTimings)
}
}
}
+7
View File
@@ -166,6 +166,12 @@ module.exports = {
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
// app.asar. Left packed, that spawn fails closed and every OpenCode session
// disappears from Agent Session History in packaged builds only. Worker
// entries reached solely from the Electron main process stay packed, since
// asar redirects their app.asar paths.
asarUnpack: [
'out/package.json',
'out/cli/**',
@@ -183,6 +189,7 @@ module.exports = {
'out/main/hermes/**',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
@@ -1,11 +1,4 @@
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
-3
View File
@@ -232,7 +232,6 @@ inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/settings/SettingsFormControls.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
@@ -245,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
@@ -268,7 +266,6 @@ inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useIssueMetadata.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
+27 -3
View File
@@ -1969,7 +1969,7 @@
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"commands": [
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
@@ -1989,11 +1989,18 @@
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery"
]
},
{
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
"assertions": [
@@ -2711,11 +2718,13 @@
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/daemon/terminal-host.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
@@ -2727,6 +2736,13 @@
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"completed and explicitly retired workers retain no provider-resume authority on first workspace activation",
"the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart"
]
},
{
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"assertions": [
@@ -7867,16 +7883,24 @@
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
"tests/e2e/completed-worker-retirement-resume.unit.test.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
"assertions": [
"an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"assertions": [
@@ -7,6 +7,9 @@ import process from 'node:process'
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`.
const TEST_SUPPORT_FILE_PATTERN =
/[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
@@ -73,13 +76,14 @@ function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
export function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
relative.includes('/__tests__/') ||
TEST_SUPPORT_FILE_PATTERN.test(relative)
) {
return true
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs'
const ROOT = process.cwd()
@@ -42,3 +42,23 @@ describe('localization coverage candidates', () => {
expect(reports).toEqual([])
})
})
describe('localization coverage file skipping', () => {
function skipped(relativePath) {
return isSkippedFile(ROOT, `${ROOT}/${relativePath}`)
}
it('skips test-only modules that sit beside their spec', () => {
expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true)
expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true)
expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true)
expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true)
expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true)
})
it('still scans shipped modules whose names merely mention a fixture concept', () => {
expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false)
})
})
@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = join(import.meta.dirname, '..', '..')
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
@@ -171,6 +174,29 @@ describe('electron-builder config', () => {
)
})
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
// invisible to it and a packed worker entry fails closed — dropping every
// OpenCode session in packaged builds while dev stays green. Three legs must
// agree on the filename, so all three are read rather than hardcoded.
it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => {
const spawnSource = await readFile(
join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'),
'utf8'
)
const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1]
expect(entryFilename).toBeDefined()
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
// Why: the emitted path comes from the rollup input key under
// entryFileNames '[name].js', not from the source filename — renaming the
// key alone would leave the other two legs agreeing on a file that no
// longer exists.
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toContain("entryFileNames: '[name].js'")
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
@@ -1058,36 +1058,6 @@
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
},
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
"ko": "orca · zsh"
},
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
"ko": "생각 중..."
},
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
"ko": "fix/worktree-picker-truncates"
},
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
},
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
},
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
"ko": "pnpm playwright test"
},
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
"ko": "dashboard.spec.ts"
},
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
},
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
},
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
"ko": "작업 중"
},
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
"ko": "마일스톤"
},
@@ -1502,12 +1472,6 @@
"auto.components.new.workspace.ProjectCombobox.search": {
"ko": "프로젝트 검색..."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
"ko": "호스트 선택"
},
"auto.components.notification.sound.options.0acd3d384e": {
"ko": "클랙"
},
@@ -1520,9 +1484,6 @@
"auto.components.notification.sound.options.86af8d938c": {
"ko": "봉 소리"
},
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
"ko": "기능 활성화"
},
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
},
@@ -1538,12 +1499,6 @@
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
},
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
},
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
},
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
"ko": "로그인 중"
},
@@ -1556,18 +1511,6 @@
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
},
"auto.components.onboarding.RepoStep.466108ab89": {
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
},
"auto.components.onboarding.RepoStep.6558d50c69": {
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
},
"auto.components.onboarding.RepoStep.c33b190ca3": {
"ko": "호스트 경로만"
},
"auto.components.onboarding.RepoStep.cf23006ba7": {
"ko": "선택한 호스트"
},
"auto.components.onboarding.ThemeStep.7ee9234e54": {
"ko": "Ghostty 설정을 찾았습니다."
},
@@ -4976,9 +4919,6 @@
"auto.components.ui.color.picker.faa855a582": {
"ko": "Hex"
},
"auto.components.ui.team.multi.combobox.18ec58881e": {
"ko": "팀 검색..."
},
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
"ko": "리뷰 필요"
},
+74 -7
View File
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
import {
createPlainNodeEntryGuardPlugin,
GUARDED_ENTRY_NAMES
} from '../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
@@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
)
}
function runCloseBundle(plugin: Plugin): void {
async function runCloseBundle(plugin: Plugin): Promise<void> {
const hook = plugin.closeBundle
if (typeof hook !== 'function') {
throw new Error('Expected closeBundle hook')
}
hook.call({} as never)
await hook.call({} as never)
}
describe('plain Node entry guard', () => {
it('smoke-loads the daemon after output files are written', () => {
it('smoke-loads the daemon after output files are written', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
@@ -64,17 +67,17 @@ describe('plain Node entry guard', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
)
expect(() => runCloseBundle(plugin)).not.toThrow()
await expect(runCloseBundle(plugin)).resolves.toBeUndefined()
})
it('runs the deferred smoke from closeBundle', () => {
it('runs the deferred smoke from closeBundle', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node')
})
it('rejects Electron imports during the static bundle scan', () => {
@@ -84,6 +87,70 @@ describe('plain Node entry guard', () => {
'requires electron'
)
})
it('rejects Electron subpath requires', () => {
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow(
'requires electron'
)
})
it('fails the smoke when the daemon exits zero on an empty argv', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry <socket>")\n')
await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv')
})
// daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if
// it escalates to SIGKILL — spawnSync's own timeout would block here forever.
it('kills a daemon that traps SIGTERM and never exits', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 })
runWriteBundle(plugin, dir)
writeFileSync(
join(dir, 'daemon-entry.js'),
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n"
)
await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms')
}, 10_000)
})
// Why: writeBundle skips names absent from the bundle, so a renamed rollup input
// would silently stop guarding that entry instead of failing the build.
describe('guarded entry names', () => {
function runBuildStart(plugin: Plugin, input: unknown): void {
const hook = plugin.buildStart
if (typeof hook !== 'function') {
throw new Error('Expected buildStart hook')
}
hook.call({} as never, { input } as Rollup.NormalizedInputOptions)
}
it('rejects a guarded name that is no longer a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(
GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [
name,
`${name}.ts`
])
)
expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"')
})
it('passes when every guarded name is a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`]))
expect(() => runBuildStart(plugin, input)).not.toThrow()
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
@@ -5,6 +5,7 @@ import {
accessSync,
chmodSync,
constants as fsConstants,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
}
}
// Why: rewrite from the path embedded in the installed command, not a
// reconstructed join(home, ...). That way missing/failing-script cases cannot
// silently re-run the real script if the install layout changes.
function rewriteLauncherScriptPath(command, nextPath) {
const match = /if \[ -f '([^']+)'/.exec(command)
if (!match) {
throw new Error('Installed launcher command did not reference a quoted script path')
}
return command.replaceAll(match[1], nextPath)
}
async function verifyInstalledLauncher(home, payload) {
const settingsPath = join(home, '.claude', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const command = findStrings(settings).find(
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
)
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
if (
!command ||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
!command.includes('] && [ -r ') ||
!command.includes('else { command -p cat')
) {
throw new Error('Electron did not install the guarded Claude launcher')
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
try {
const missingPath = join(scratch, 'missing-hook.sh')
const missingResult = await runShell(
rewriteLauncherScriptPath(command, missingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
const failingPath = join(scratch, 'failing-hook.sh')
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
chmodSync(failingPath, 0o755)
const failingResult = await runShell(
rewriteLauncherScriptPath(command, failingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
throw new Error('Installed launcher did not preserve a running script failure')
+19
View File
@@ -27,6 +27,12 @@ const checks = [
],
enabled: true
},
{
name: 'Linux snapshot renderer tests',
command: 'python3',
args: ['native/computer-use-linux/runtime_render_test.py'],
enabled: true
},
{
name: 'native provider argument guardrails',
run: verifyNativeArgumentGuardrails,
@@ -69,6 +75,19 @@ const checks = [
run: verifyWindowsProviderHandshake,
enabled: process.platform === 'win32'
},
{
name: 'Windows snapshot renderer tests',
command: 'powershell.exe',
args: [
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'native/computer-use-windows/runtime-render.test.ps1'
],
enabled: process.platform === 'win32'
},
{
name: 'macOS helper app bundle and signature',
run: verifyMacOSHelperApp,
+2
View File
@@ -13,6 +13,7 @@
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -75,6 +76,7 @@
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/startup/windows-shell-path-ownership.ts",
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
+4 -4
View File
@@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees
- **Idle:** transparent background.
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically.
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned.
### Color mixing
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Use `cn()` for class merging. Pass user `className` last so callers can override.
- Use `class-variance-authority` (CVA) for variants when there are multiple.
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+3 -130
View File
@@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
@@ -267,7 +267,6 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} 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 { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
import {
@@ -275,7 +274,7 @@ import {
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
@@ -302,132 +301,6 @@ import type {
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}) {
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
const showCopy = doc.saveError || !doc.editable
const showSave = doc.isDirty || doc.saving
const showFloatingActions = statusText || showRefresh || showCopy || showSave
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
function DiffLineRow({
line,
title,
@@ -4636,7 +4509,7 @@ export default function SessionScreen() {
</View>
) : activeMarkdownTab ? (
<View style={styles.markdownFrame}>
<MarkdownReader
<MobileMarkdownReader
documentId={activeMarkdownTab.id}
doc={markdownDocs.get(activeMarkdownTab.id)}
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
+5 -3
View File
@@ -115,13 +115,15 @@ import {
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import {
type GitHubProjectSortDirection,
type GitHubProjectTable as SharedGitHubProjectTable,
groupRows,
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../src/tasks/mobile-github-project-group-sort'
} from '../../../../src/shared/github-project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check, ChevronDown } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
type Props = {
client: RpcClient | null
worktreeId: string
value: string
onChange: (ref: string) => void
editable?: boolean
}
// Base-branch field for the create-PR composer: a free-text input that also searches
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
// be entered.
export function MobilePrBasePicker({
client,
worktreeId,
value,
onChange,
editable = true
}: Props) {
const [results, setResults] = useState<string[]>([])
const [focused, setFocused] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Guards: drop results after unmount, and ignore an earlier search whose response
// arrives after a later one (out-of-order network) so stale matches can't clobber.
const mounted = useRef(true)
const seq = useRef(0)
useEffect(() => {
return () => {
mounted.current = false
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [])
const queryRefs = useCallback(
(query: string) => {
if (timer.current) {
clearTimeout(timer.current)
}
if (!client || query.trim().length === 0) {
// Advance the generation so an earlier in-flight search can't land and
// repopulate results after the input was cleared.
seq.current += 1
setResults([])
return
}
timer.current = setTimeout(() => {
const requestSeq = ++seq.current
void searchBaseRefs(client, worktreeId, query.trim())
.then((refs) => {
if (!mounted.current || requestSeq !== seq.current) {
return
}
setResults(refs.filter((r) => r !== query).slice(0, 6))
})
// Why: a rejected ref search must not escape as an unhandled rejection;
// drop to an empty result set (free text stays valid to submit).
.catch(() => {
if (mounted.current && requestSeq === seq.current) {
setResults([])
}
})
}, 200)
},
[client, worktreeId]
)
return (
<View>
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
<TextInput
style={styles.input}
value={value}
onChangeText={(text) => {
onChange(text)
queryRefs(text)
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="main"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={editable}
/>
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
</View>
{focused && results.length > 0 ? (
<View style={styles.results}>
{results.map((ref) => (
<Pressable
key={ref}
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
onPress={() => {
onChange(ref)
setResults([])
}}
>
<Text style={styles.resultText} numberOfLines={1}>
{ref}
</Text>
{ref === value ? (
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
) : null}
</Pressable>
))}
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
inputShell: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
inputShellDisabled: {
opacity: 0.6
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
},
results: {
marginTop: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
resultRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
resultRowPressed: { backgroundColor: colors.bgRaised },
resultText: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
}
})
@@ -0,0 +1,84 @@
import { createElement, createRef, forwardRef, useImperativeHandle } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MobileRichMarkdownEditor,
type MobileRichMarkdownEditorHandle
} from './MobileRichMarkdownEditor'
const mocks = vi.hoisted(() => ({
dismissKeyboard: vi.fn(),
injectJavaScript: vi.fn()
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
Keyboard: { dismiss: mocks.dismissKeyboard },
Linking: { openURL: vi.fn() },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
View: 'View'
}
})
vi.mock('react-native-webview', () => {
const WebView = forwardRef((props: Record<string, unknown>, ref) => {
useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript }))
return createElement('WebView', props)
})
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
Bold: 'Bold',
ChevronDown: 'ChevronDown',
Code2: 'Code2',
FileCode2: 'FileCode2',
Heading1: 'Heading1',
Heading2: 'Heading2',
Heading3: 'Heading3',
ImageIcon: 'ImageIcon',
Italic: 'Italic',
Keyboard: 'Keyboard',
Link: 'Link',
List: 'List',
ListOrdered: 'ListOrdered',
ListTodo: 'ListTodo',
Pilcrow: 'Pilcrow',
Quote: 'Quote',
Strikethrough: 'Strikethrough'
}))
describe('MobileRichMarkdownEditor', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
it('exposes WebView keyboard dismissal to its native parent', () => {
const editorRef = createRef<MobileRichMarkdownEditorHandle>()
act(() => {
renderer = create(
createElement(MobileRichMarkdownEditor, {
ref: editorRef,
content: '',
editable: true,
onChange: vi.fn()
})
)
})
act(() => editorRef.current?.dismissKeyboard())
expect(mocks.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()')
)
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
})
})
@@ -1,5 +1,15 @@
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ComponentType,
type ForwardedRef
} from 'react'
import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
Bold,
Code2,
@@ -77,6 +87,10 @@ type Props = {
onKeyboardInsetChange?: (bottom: number) => void
}
export type MobileRichMarkdownEditorHandle = {
dismissKeyboard: () => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
@@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
function MobileRichMarkdownEditorInner({
content,
editable,
onChange,
onKeyboardInsetChange
}: Props) {
function MobileRichMarkdownEditorInner(
{ content, editable, onChange, onKeyboardInsetChange }: Props,
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
@@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({
[inject]
)
const dismissKeyboard = useCallback(() => {
// Why: the caret lives in the WebView, so the injected blur is what closes the keyboard;
// Keyboard.dismiss only clears a native TextInput that stole focus first.
inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();')
Keyboard.dismiss()
}, [inject])
useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard])
return (
<View style={styles.container}>
<View style={styles.toolbar}>
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
const styles = StyleSheet.create({
container: {
+1 -2
View File
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
// control, matching TextInputModal / MobilePrBasePicker input shells.
// Keep search visually distinct from surrounding panels.
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
@@ -11,9 +11,7 @@ function editorScript(): string {
return script ?? ''
}
function extractFunctionSource(script: string, name: string): string {
const start = script.indexOf(`function ${name}`)
expect(start).toBeGreaterThanOrEqual(0)
function extractBracedSource(script: string, start: number, label: string): string {
const bodyStart = script.indexOf('{', start)
let depth = 0
for (let index = bodyStart; index < script.length; index += 1) {
@@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string {
}
}
}
throw new Error(`Could not extract ${name}`)
throw new Error(`Could not extract ${label}`)
}
function extractFunctionSource(script: string, name: string): string {
const start = script.indexOf(`function ${name}`)
expect(start).toBeGreaterThanOrEqual(0)
return extractBracedSource(script, start, name)
}
function extractEditorListenerSource(script: string, type: string): string {
const start = script.indexOf(`editor.addEventListener('${type}'`)
expect(start).toBeGreaterThanOrEqual(0)
return `${extractBracedSource(script, start, `${type} listener`)});`
}
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
@@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string {
return new Function(sources)() as (list: unknown) => string
}
type FakeRange = {
commonAncestorContainer: unknown
cloneRange: () => FakeRange
selectNodeContents: (node: unknown) => void
collapse: (toStart: boolean) => void
}
function createFakeRange(container: unknown): FakeRange {
const range: FakeRange = {
commonAncestorContainer: container,
cloneRange: () => createFakeRange(range.commonAncestorContainer),
selectNodeContents: (node) => {
range.commonAncestorContainer = node
},
collapse: () => {}
}
return range
}
type FakeClickEvent = {
clientX: number
clientY: number
target: { closest: (selector: string) => unknown }
preventDefault: () => void
}
// Drives the editor's real selection and click handling against a stub DOM whose blur
// drops the selection, the way WebKit does.
function createSelectionRuntime(caretContainer: string | null) {
const liveNodes = new Set(caretContainer ? [caretContainer] : [])
const listeners = new Map<string, (event: FakeClickEvent) => void>()
let focused = caretContainer != null
let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : []
let caretAtPoint: string | null = null
const editor = {
contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node),
focus: () => {
focused = true
fakeDocument.activeElement = editor
},
blur: () => {
focused = false
fakeDocument.activeElement = null
ranges = []
},
addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => {
listeners.set(type, handler)
}
}
const fakeDocument: {
activeElement: unknown
createRange: () => FakeRange
caretRangeFromPoint: () => FakeRange | null
} = {
activeElement: focused ? editor : null,
createRange: () => createFakeRange('detached'),
caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null)
}
const fakeWindow = {
getSelection: () => ({
get rangeCount() {
return ranges.length
},
getRangeAt: (index: number) => ranges[index],
removeAllRanges: () => {
ranges = []
},
addRange: (range: FakeRange) => {
ranges = [range]
}
})
}
const script = editorScript()
const sources = [
'var editor = arguments[0];',
'var document = arguments[1];',
'var window = arguments[2];',
'var editable = true;',
'var savedSelectionRange = null;',
'var selectionDroppedOnBlur = false;',
'function post() {}',
'function emitChange() {}',
extractFunctionSource(script, 'focusEditor'),
extractFunctionSource(script, 'rememberSelection'),
extractFunctionSource(script, 'applySelectionRange'),
extractFunctionSource(script, 'caretRangeAtPoint'),
extractFunctionSource(script, 'dismissKeyboard'),
extractFunctionSource(script, 'restoreSelectionOrEnd'),
extractEditorListenerSource(script, 'click'),
'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };'
].join('\n')
const api = new Function(sources)(editor, fakeDocument, fakeWindow) as {
dismissKeyboard: () => void
restoreSelectionOrEnd: () => void
}
return {
dismissKeyboard: api.dismissKeyboard,
restoreSelectionOrEnd: api.restoreSelectionOrEnd,
tapAt: (container: string, options?: { uneditableAncestor?: string }) => {
liveNodes.add(container)
caretAtPoint = container
listeners.get('click')?.({
clientX: 12,
clientY: 34,
target: {
closest: (selector: string) =>
selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null
},
preventDefault: () => {}
})
},
detachEditorContent: () => liveNodes.clear(),
selectedContainer: () => {
if (ranges.length === 0) {
return null
}
const container = ranges[0].commonAncestorContainer
return container === editor ? 'editor-end' : (container as string)
},
get focused() {
return focused
}
}
}
describe('mobile rich markdown editor HTML', () => {
it('builds parseable WebView JavaScript', () => {
const script = editorScript()
@@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => {
expect(emitChange).not.toContain('window.setTimeout')
expect(emitChange).toContain('generation: pendingGeneration')
})
it('exposes a keyboard dismissal command that blurs WebView focus', () => {
const script = editorScript()
const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard')
expect(dismissKeyboard).toContain('rememberSelection()')
expect(dismissKeyboard).toContain('document.activeElement.blur()')
expect(dismissKeyboard).toContain('editor.blur()')
expect(script).toContain('dismissKeyboard: dismissKeyboard')
})
it('reclaims editor focus at the tapped caret after keyboard dismissal', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.tapAt('paragraph-7')
expect(runtime.focused).toBe(true)
expect(runtime.selectedContainer()).toBe('paragraph-7')
})
it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' })
expect(runtime.focused).toBe(false)
expect(runtime.selectedContainer()).toBe(null)
})
it('restores the pre-dismissal caret so commands do not insert at the document end', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
expect(runtime.selectedContainer()).toBe(null)
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('paragraph-3')
expect(runtime.focused).toBe(true)
})
it('falls back to the document end when no caret was ever placed', () => {
const runtime = createSelectionRuntime(null)
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('editor-end')
})
it('drops a remembered caret whose nodes left the document', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.detachEditorContent()
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('editor-end')
})
})
@@ -1,5 +1,7 @@
import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
// Why: replacing innerHTML detaches the remembered caret's nodes.
savedSelectionRange = null;
selectionDroppedOnBlur = false;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
@@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
syncTaskCheckboxesDisabled();
}
function focusEditor() {
editor.focus();
}
function restoreSelectionOrEnd() {
focusEditor();
var selection = window.getSelection();
if (!selection || selection.rangeCount > 0) return;
var range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
function wrapSelection(tagName) {
restoreSelectionOrEnd();
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) return;
var wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch (_error) {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
selection.removeAllRanges();
selection.selectAllChildren(wrapper);
emitChange();
}
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
@@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
editor.addEventListener('input', function () {
selectionDroppedOnBlur = false;
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
@@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string {
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) return;
if (!input) {
if (!editable) return;
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
if (uneditable && uneditable !== editor) return;
selectionDroppedOnBlur = false;
if (document.activeElement === editor) return;
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
var caret = caretRangeAtPoint(event.clientX, event.clientY);
focusEditor();
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
return;
}
if (!editable) {
event.preventDefault();
return;
@@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
});
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
@@ -0,0 +1,13 @@
// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose
// rememberSelection/selectionDroppedOnBlur this depends on.
export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = `
function dismissKeyboard() {
// Why: WebKit discards the DOM selection on blur, so capture the caret before it goes.
rememberSelection();
selectionDroppedOnBlur = true;
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
editor.blur();
}
`
@@ -0,0 +1,71 @@
// Caret and selection management for the WebView editor. Split out so the editor
// document script stays inside its line budget.
export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = `
var savedSelectionRange = null;
var selectionDroppedOnBlur = false;
function focusEditor() {
editor.focus();
}
function rememberSelection() {
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange();
}
function applySelectionRange(range) {
var selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
selection.addRange(range);
savedSelectionRange = range.cloneRange();
}
function caretRangeAtPoint(x, y) {
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
if (!document.caretPositionFromPoint) return null;
var position = document.caretPositionFromPoint(x, y);
if (!position) return null;
var range = document.createRange();
range.setStart(position.offsetNode, position.offset);
range.collapse(true);
return range;
}
function restoreSelectionOrEnd() {
focusEditor();
var selection = window.getSelection();
if (!selection) return;
// Why: the blur dropped the live selection, so commands would otherwise insert at the document end.
if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) {
selectionDroppedOnBlur = false;
applySelectionRange(savedSelectionRange);
return;
}
if (selection.rangeCount > 0) return;
var range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
applySelectionRange(range);
}
function wrapSelection(tagName) {
restoreSelectionOrEnd();
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) return;
var wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch (_error) {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
selection.removeAllRanges();
selection.selectAllChildren(wrapper);
emitChange();
}
`
@@ -1,289 +0,0 @@
import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native'
import {
ArrowRight,
GitMerge,
GitPullRequestArrow,
Sparkles,
TriangleAlert,
X
} from 'lucide-react-native'
import { colors } 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,
getMobilePrCreateSuccessWarning,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from '../../source-control/mobile-pr-create'
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
import {
getPrComposeDisabledReason,
isBaseHeadDistinct
} from '../../source-control/pr-compose-validation'
import { MobilePrBasePicker } from '../MobilePrBasePicker'
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
export type PrComposePrefill = MobilePrPrefill
type Props = {
client: RpcClient | null
worktreeId: string
prefill: PrComposePrefill
// Head branch — enables the base≠head guard and the "from <branch>" hint.
head?: string | null
onCancel: () => void
onCreated: (url: string, warning?: string) => void
}
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
export function MobilePrComposeForm({
client,
worktreeId,
prefill,
head,
onCancel,
onCreated
}: Props) {
const copy = hostedReviewCopy(prefill.provider)
const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow
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<string | null>(null)
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
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)
}
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Failed to generate PR fields')
} finally {
setGenerating(false)
}
}, [base, body, client, draft, generating, title, worktreeId])
const headRef = head ?? ''
const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef)
const submitDisabledReason = getPrComposeDisabledReason({
title,
base,
head: headRef,
generating,
reviewLabel: copy.reviewLabel
})
const canSubmit = submitDisabledReason === null
const fieldsLocked = submitting || generating
const submit = useCallback(async () => {
if (!client || submitting || !canSubmit) {
return
}
setSubmitting(true)
setError(null)
try {
const outcome = await createMobilePr(client, worktreeId, {
provider: prefill.provider,
base,
// Send the same head the submit guard validated against, so the PR opens
// from the validated branch instead of a host-inferred one.
...(head ? { head } : {}),
title,
body,
draft,
pushBeforeCreate
})
if (outcome.ok) {
triggerSuccess()
const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
if (warning) {
setError(warning)
}
onCreated(outcome.url, warning)
} else {
triggerError()
setError(outcome.error)
}
} finally {
setSubmitting(false)
}
}, [
base,
body,
canSubmit,
client,
copy.titleLabel,
draft,
head,
onCreated,
prefill.provider,
pushBeforeCreate,
submitting,
title,
worktreeId
])
return (
<View style={styles.root}>
<View style={styles.headingRow}>
<View style={styles.headingTitle}>
<ReviewIcon size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.heading}>New {copy.reviewLabel}</Text>
</View>
<View style={styles.headingActions}>
<Pressable
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
disabled={generating || submitting}
onPress={() => void generate()}
accessibilityRole="button"
accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`}
>
{generating ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
<Text style={styles.genButtonText}>{generating ? 'Generating…' : 'Generate'}</Text>
</Pressable>
<Pressable
style={styles.iconButton}
onPress={onCancel}
disabled={submitting}
accessibilityRole="button"
accessibilityLabel="Cancel"
hitSlop={8}
>
<X size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</View>
</View>
{head ? (
<View style={styles.branchFlow}>
<Text style={styles.branchToken} numberOfLines={1}>
{head}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text
style={[styles.branchToken, baseConflict && styles.branchTokenError]}
numberOfLines={1}
>
{base || 'base'}
</Text>
</View>
) : null}
<View style={styles.fieldStack}>
<TextInput
style={styles.titleInput}
value={title}
onChangeText={setTitle}
placeholder="Title"
placeholderTextColor={colors.textMuted}
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} title`}
/>
<TextInput
style={styles.bodyInput}
value={body}
onChangeText={setBody}
placeholder="Description (optional)"
placeholderTextColor={colors.textMuted}
multiline
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} description`}
/>
</View>
{generating ? (
<View style={styles.notice}>
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.noticeText}>Generating title and description…</Text>
</View>
) : null}
<View style={styles.baseRow}>
<Text style={styles.baseLabel}>Base</Text>
<View style={styles.baseControl}>
<MobilePrBasePicker
client={client}
worktreeId={worktreeId}
value={base}
onChange={setBase}
editable={!fieldsLocked}
/>
</View>
</View>
<View style={styles.draftRow}>
<Text style={styles.draftText}>Create as draft</Text>
<Switch value={draft} onValueChange={setDraft} disabled={fieldsLocked} />
</View>
{error || submitDisabledReason ? (
<View style={styles.notice}>
<TriangleAlert size={13} color={colors.statusRed} strokeWidth={2.1} />
<Text style={[styles.noticeText, styles.errorText]}>{error ?? submitDisabledReason}</Text>
</View>
) : null}
<Pressable
style={({ pressed }) => [
styles.submit,
(submitting || !canSubmit) && styles.submitDisabled,
pressed && styles.submitPressed
]}
disabled={submitting || !canSubmit}
onPress={() => void submit()}
accessibilityRole="button"
>
{submitting ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
)}
<Text style={styles.submitText}>
{pushBeforeCreate
? draft
? `Push & create draft ${copy.shortLabel}`
: `Push & create ${copy.shortLabel}`
: draft
? `Create draft ${copy.shortLabel}`
: `Create ${copy.shortLabel}`}
</Text>
</Pressable>
</View>
)
}
@@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac
import { ConfirmModal } from '../ConfirmModal'
import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentComposer } from './PRCommentComposer'
import { formatPrCommentRelativeTime } from './pr-comment-time'
import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time'
import { prCommentsStyles as styles } from './pr-comments-styles'
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
@@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
import { PRCommentComposer } from './PRCommentComposer'
import {
PR_COMMENT_AUDIENCE_FILTERS,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
getPRCommentAudienceEmptyLabel,
type PRCommentAudienceFilter
} from './pr-comment-audience'
} from '../../../../src/shared/pr-comment-audience'
import {
PR_COMMENT_AUDIENCE_FILTERS,
getPRCommentAudienceEmptyLabel
} from './pr-comment-audience-labels'
import {
getPRCommentGroupCount,
getPRCommentGroupId,
@@ -25,7 +27,7 @@ import {
groupPRComments,
isResolvedPRCommentGroup,
type PRCommentGroup
} from './pr-comment-groups'
} from '../../../../src/shared/pr-comment-groups'
import { prCommentsStyles as styles } from './pr-comments-styles'
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
@@ -1,165 +0,0 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
export const mobilePrComposeFormStyles = StyleSheet.create({
root: {
gap: spacing.sm
},
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
marginBottom: spacing.xs
},
headingTitle: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
heading: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
headingActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
genButton: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
genButtonPressed: {
opacity: 0.7
},
genButtonText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '700'
},
iconButton: {
minWidth: 32,
minHeight: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
branchFlow: {
minHeight: 28,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
branchToken: {
maxWidth: 116,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
branchTokenError: {
color: colors.statusRed
},
fieldStack: {
gap: spacing.sm
},
titleInput: {
minHeight: 40,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
bodyInput: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
// Why: a moderate fixed height avoids over-expanding inside the sidebar scroll.
minHeight: 120,
textAlignVertical: 'top'
},
baseRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
baseLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize,
width: 36
},
baseControl: {
flex: 1,
minWidth: 0
},
draftRow: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm
},
draftText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '700'
},
notice: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs
},
noticeText: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
errorText: {
color: colors.statusRed
},
submit: {
marginTop: spacing.xs,
minHeight: 44,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs
},
submitDisabled: {
opacity: 0.45
},
submitPressed: {
opacity: 0.8
},
submitText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
}
})
@@ -0,0 +1,18 @@
import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience'
export const PR_COMMENT_AUDIENCE_FILTERS = [
{ value: 'all', label: 'All' },
{ value: 'human', label: 'Humans' },
{ value: 'bot', label: 'Bots' }
] satisfies { value: PRCommentAudienceFilter; label: string }[]
export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string {
switch (filter) {
case 'bot':
return 'No bot comments.'
case 'human':
return 'No human comments.'
case 'all':
return 'No comments yet.'
}
}
@@ -1,68 +0,0 @@
import type { PRComment } from '../../../../src/shared/types'
// Thread grouping for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants.
// Inline review comments sharing a threadId collapse into a root + replies; all
// other comments are standalone. Upstream order is preserved.
export type PRCommentGroup =
| { kind: 'standalone'; comment: PRComment }
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
export function groupPRComments(comments: PRComment[]): PRCommentGroup[] {
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
const groupsByFirstComment = new Map<PRComment, PRCommentGroup>()
for (const comment of comments) {
if (!comment.threadId) {
groupsByFirstComment.set(comment, { kind: 'standalone', comment })
continue
}
const existing = threadMap.get(comment.threadId)
if (existing) {
existing.replies.push(comment)
continue
}
threadMap.set(comment.threadId, { root: comment, replies: [] })
}
const emitted = new Set<string>()
const groups: PRCommentGroup[] = []
for (const comment of comments) {
if (!comment.threadId) {
const group = groupsByFirstComment.get(comment)
if (group) {
groups.push(group)
}
continue
}
if (emitted.has(comment.threadId)) {
continue
}
emitted.add(comment.threadId)
const thread = threadMap.get(comment.threadId)
if (thread) {
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
}
}
return groups
}
export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] {
return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment]
}
export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment {
return group.kind === 'thread' ? group.root : group.comment
}
export function getPRCommentGroupCount(group: PRCommentGroup): number {
return getPRCommentGroupComments(group).length
}
export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean {
return getPRCommentGroupRoot(group).isResolved === true
}
export function getPRCommentGroupId(group: PRCommentGroup): string {
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
} from './pr-comment-audience'
import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups'
import { formatPrCommentRelativeTime } from './pr-comment-time'
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
return {
author: 'octocat',
authorAvatarUrl: '',
body: '',
createdAt: '',
url: '',
...overrides
}
}
describe('pr comment audience', () => {
it('classifies app bots, [bot] suffix, and known automation logins', () => {
expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false)
expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true)
expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true)
expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true)
})
it('counts and filters by audience', () => {
const comments = [
comment({ id: 1, author: 'alice' }),
comment({ id: 2, author: 'dependabot[bot]' }),
comment({ id: 3, author: 'bob' })
]
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2])
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
expect(isBotPRComment(comments[1])).toBe(false)
expect(isBotPRComment(comments[1], overrides)).toBe(true)
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
})
})
describe('pr comment groups', () => {
it('threads comments sharing a threadId as root + replies, preserving order', () => {
const comments = [
comment({ id: 1, author: 'a' }),
comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }),
comment({ id: 3, author: 'c', threadId: 't1' })
]
const groups = groupPRComments(comments)
expect(groups).toHaveLength(2)
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
expect(groups[1].kind).toBe('thread')
if (groups[1].kind === 'thread') {
expect(groups[1].root.id).toBe(2)
expect(groups[1].replies.map((r) => r.id)).toEqual([3])
}
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
})
})
describe('pr comment relative time', () => {
const now = Date.parse('2026-06-16T12:00:00Z')
it('formats buckets and rejects bad input', () => {
expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now')
expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago')
expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago')
expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago')
expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('')
})
})
@@ -1,30 +0,0 @@
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out),
// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure +
// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts).
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) {
return ''
}
const delta = nowMs - ts
if (delta < 60_000) {
return 'just now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}d ago`
}
const months = Math.floor(days / 30)
if (months < 12) {
return `${months}mo ago`
}
return `${Math.floor(months / 12)}y ago`
}
@@ -0,0 +1,119 @@
import { createElement, forwardRef, useImperativeHandle } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdownReader } from './MobileMarkdownReader'
import type { MarkdownDocState } from './mobile-session-route-types'
const mocks = vi.hoisted(() => ({
dismissKeyboard: vi.fn(),
reportKeyboardInset: null as ((bottom: number) => void) | null
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: 'ios', select: (choices: Record<string, unknown>) => choices.ios },
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
Keyboard: 'KeyboardIcon',
RefreshCw: 'RefreshCw'
}))
vi.mock('../components/MobileRichMarkdownEditor', () => {
const Editor = forwardRef(
(props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => {
mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null
useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard }))
return createElement('MobileRichMarkdownEditor')
}
)
return { MobileRichMarkdownEditor: Editor }
})
const readyDoc: MarkdownDocState = {
status: 'ready',
content: '# Notes',
localContent: '# Notes',
baseVersion: '1',
isDirty: false,
editable: true
}
describe('MobileMarkdownReader', () => {
let renderer: ReactTestRenderer | null = null
function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer {
act(() => {
renderer = create(
createElement(MobileMarkdownReader, {
documentId: 'doc-1',
doc,
keyboardLift,
onRefresh: vi.fn(),
onChange: vi.fn(),
onSave: vi.fn(),
onCopy: vi.fn(),
onDiscard: vi.fn()
})
)
})
return renderer as unknown as ReactTestRenderer
}
function dismissButtons(instance: ReactTestRenderer) {
return instance.root.findAll(
(node) =>
typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard'
)
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
mocks.reportKeyboardInset = null
vi.clearAllMocks()
})
it('offers keyboard dismissal while the keyboard covers the editor', () => {
const instance = render(291)
const [button] = dismissButtons(instance)
expect(button).toBeDefined()
act(() => button.props.onPress())
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
})
it('hides the floating row entirely on a clean document with the keyboard closed', () => {
const instance = render(0)
expect(dismissButtons(instance)).toHaveLength(0)
expect(instance.root.findAllByType('Text')).toHaveLength(0)
})
it('keeps document actions available without offering dismissal when the keyboard is closed', () => {
const instance = render(0, { ...readyDoc, isDirty: true })
expect(dismissButtons(instance)).toHaveLength(0)
expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0)
})
it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => {
const instance = render(0)
expect(dismissButtons(instance)).toHaveLength(0)
act(() => mocks.reportKeyboardInset?.(291))
expect(dismissButtons(instance)).toHaveLength(1)
})
})
+172
View File
@@ -0,0 +1,172 @@
import { useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native'
import {
MobileRichMarkdownEditor,
type MobileRichMarkdownEditorHandle
} from '../components/MobileRichMarkdownEditor'
import { colors, spacing } from '../theme/mobile-theme'
import {
resolveMarkdownFloatingActionsBottom,
shouldShowMarkdownFloatingActions
} from './markdown-floating-actions-layout'
import type { MarkdownDocState } from './mobile-session-route-types'
import { styles } from './mobile-session-styles'
type Props = {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}
export function MobileMarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: Props) {
const editorRef = useRef<MobileRichMarkdownEditorHandle>(null)
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
const keyboardOpen = effectiveKeyboardLift > 0
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable)
const showCopy = Boolean(doc.saveError || !doc.editable)
const showSave = Boolean(doc.isDirty || doc.saving)
const showFloatingActions = shouldShowMarkdownFloatingActions({
keyboardLift: effectiveKeyboardLift,
hasStatus: statusText != null,
showRefresh,
showCopy,
showSave
})
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
ref={editorRef}
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{keyboardOpen ? (
<Pressable
style={[styles.markdownFloatingButton, styles.markdownKeyboardDismissButton]}
onPress={() => editorRef.current?.dismissKeyboard()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
) : null}
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
@@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => {
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const onChangeText = vi.fn()
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onChangeText,
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
@@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => {
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
expect(onChangeText).toHaveBeenCalledWith('/clear ')
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import type { TerminalQuickCommand } from '../../../src/shared/types'
import {
getQuickCommandDisplayPreview,
getTerminalQuickCommandBody,
isAgentQuickCommand
} from '../terminal/quick-commands'
type QuickCommandRowProps = {
command: TerminalQuickCommand
first: boolean
onLaunch: (command: TerminalQuickCommand) => void
onEdit: (command: TerminalQuickCommand) => void
onDelete: (command: TerminalQuickCommand) => void
disabled: boolean
}
type CopyFeedback = {
body: string
status: 'copied' | 'failed'
}
export function QuickCommandRow({
command,
first,
onLaunch,
onEdit,
onDelete,
disabled
}: QuickCommandRowProps) {
const isAgent = isAgentQuickCommand(command)
const body = getTerminalQuickCommandBody(command)
const canCopy = body.trim().length > 0
// Why: key feedback to the copied body so a prop change drops stale labels
// without setState-in-effect (react-doctor no-adjust-state-on-prop-change).
const [feedback, setFeedback] = useState<CopyFeedback | null>(null)
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const copyStatus: 'idle' | 'copied' | 'failed' =
feedback != null && feedback.body === body ? feedback.status : 'idle'
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
}
}, [])
// Drop any pending reset timer when the body changes; display status is already idle.
useEffect(() => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
copyResetTimerRef.current = null
}
}, [body])
const handleCopy = async (): Promise<void> => {
if (!canCopy || disabled) {
return
}
try {
await Clipboard.setStringAsync(body)
if (!mountedRef.current) {
return
}
setFeedback({ body, status: 'copied' })
} catch {
if (!mountedRef.current) {
return
}
setFeedback({ body, status: 'failed' })
}
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
copyResetTimerRef.current = setTimeout(() => {
copyResetTimerRef.current = null
if (mountedRef.current) {
setFeedback(null)
}
}, 1500)
}
const copyDisabled = disabled || !canCopy
const copyLabel =
copyStatus === 'copied'
? 'Copied'
: copyStatus === 'failed'
? "Couldn't copy"
: canCopy
? `Copy ${command.label}`
: 'Nothing to copy'
const copyIconColor =
copyStatus === 'copied'
? colors.statusGreen
: copyStatus === 'failed'
? colors.statusRed
: colors.textSecondary
return (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.rowAction,
// Why: row already dims when `disabled`; only dim again for empty body.
!canCopy && styles.disabled,
pressed && !copyDisabled && styles.pressed
]}
disabled={copyDisabled}
onPress={() => void handleCopy()}
accessibilityRole="button"
accessibilityLabel={copyLabel}
accessibilityState={{ disabled: copyDisabled }}
>
{copyStatus === 'copied' ? (
<Check size={15} color={copyIconColor} />
) : (
<Copy size={15} color={copyIconColor} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
pressed: { backgroundColor: colors.bgRaised },
disabled: { opacity: 0.45 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
rowMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingLeft: spacing.md,
minWidth: 0
},
rowIcon: {
width: 26,
height: 26,
borderRadius: 6,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
rowText: { flex: 1, minWidth: 0 },
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
mono: { fontFamily: typography.monoFamily },
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }
})
@@ -17,6 +17,7 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({
Check: 'Check',
Copy: 'Copy',
Pencil: 'Pencil',
Play: 'Play',
Plus: 'Plus',
@@ -24,6 +25,8 @@ vi.mock('lucide-react-native', () => ({
Trash2: 'Trash2'
}))
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
describe('QuickCommandsList search', () => {
+4 -82
View File
@@ -1,14 +1,11 @@
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { Check, Plus, Search } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
import {
getQuickCommandDisplayPreview,
isAgentQuickCommand,
supportsTerminalAgentQuickCommand
} from '../terminal/quick-commands'
import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands'
import { QuickCommandRow } from './QuickCommandRow'
export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
supportsTerminalAgentQuickCommand(entry.id)
@@ -162,67 +159,6 @@ function QuickCommandGroup({
)
}
function QuickCommandRow({
command,
first,
onLaunch,
onEdit,
onDelete,
disabled
}: {
command: TerminalQuickCommand
first: boolean
onLaunch: (command: TerminalQuickCommand) => void
onEdit: (command: TerminalQuickCommand) => void
onDelete: (command: TerminalQuickCommand) => void
disabled: boolean
}) {
const isAgent = isAgentQuickCommand(command)
return (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
export function QuickCommandAgentPicker({
selected,
onSelect
@@ -292,15 +228,6 @@ const styles = StyleSheet.create({
group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' },
row: { flexDirection: 'row', alignItems: 'center' },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
rowMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingLeft: spacing.md,
minWidth: 0
},
rowIcon: {
width: 26,
height: 26,
@@ -309,11 +236,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center'
},
rowText: { flex: 1, minWidth: 0 },
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
mono: { fontFamily: typography.monoFamily },
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' },
agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary },
addRow: {
flexDirection: 'row',
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout'
import {
resolveMarkdownFloatingActionsBottom,
shouldShowMarkdownFloatingActions
} from './markdown-floating-actions-layout'
describe('resolveMarkdownFloatingActionsBottom', () => {
it('keeps markdown actions at their resting bottom when the keyboard is closed', () => {
@@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => {
).toBe(303)
})
})
describe('shouldShowMarkdownFloatingActions', () => {
const idle = {
keyboardLift: 0,
hasStatus: false,
showRefresh: false,
showCopy: false,
showSave: false
}
it('shows the floating row for keyboard dismissal without document actions', () => {
expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true)
})
it('hides the floating row on a clean document with the keyboard closed', () => {
expect(shouldShowMarkdownFloatingActions(idle)).toBe(false)
})
it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)(
'shows the floating row for %s alone',
(field) => {
expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true)
}
)
})
@@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({
}): number {
return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom
}
export function shouldShowMarkdownFloatingActions({
keyboardLift,
hasStatus,
showRefresh,
showCopy,
showSave
}: {
keyboardLift: number
hasStatus: boolean
showRefresh: boolean
showCopy: boolean
showSave: boolean
}): boolean {
return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
@@ -7,10 +7,13 @@ import {
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
afterEach(() => vi.useRealTimers())
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
@@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => {
})
})
describe('typeMobileNativeChatCommandWithOutcome', () => {
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
vi.useFakeTimers()
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
const result = typeMobileNativeChatCommandWithOutcome({
client,
terminal: 'term',
command: '/model'
})
await vi.runAllTimersAsync()
await expect(result).resolves.toBe('accepted')
expect(
vi.mocked(client.sendRequest).mock.calls.map((call) => {
const params = call[1] as { text: string; enter: boolean }
return { text: params.text, enter: params.enter }
})
).toEqual(
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
text,
enter: false
}))
)
})
it('retires a parked launch draft only with the final typed Enter', async () => {
vi.useFakeTimers()
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
const result = typeMobileNativeChatCommandWithOutcome({
client,
terminal: 'term',
command: '/model',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
})
await vi.runAllTimersAsync()
await result
const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{
text: string
resolvedLaunchDraft?: { text: string; createdAt: number }
}>
expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true)
expect(params.at(-1)).toMatchObject({
text: '\r',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
})
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
@@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
type MobileTerminalClient = {
id: string
@@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage(
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
}
export async function typeMobileNativeChatCommandWithOutcome(args: {
client: RpcClient
terminal: string
command: string
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
deadline?: number
}): Promise<MobileNativeChatSendOutcome> {
let writeIndex = 0
return typeAgentTuiCommand({
command: args.command,
write: (key) => {
const isSubmit = writeIndex === args.command.length + 1
writeIndex += 1
return sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: key,
enter: false,
...(isSubmit && args.resolvedLaunchDraft
? { resolvedLaunchDraft: args.resolvedLaunchDraft }
: {}),
...(args.mobileClient ? { mobileClient: args.mobileClient } : {}),
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
}
})
}
/**
* Clear the agent's input line as its OWN write, before any body.
*
@@ -159,6 +159,11 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
markdownKeyboardDismissButton: {
width: 34,
justifyContent: 'center',
paddingHorizontal: 0
},
markdownSaveButton: {
backgroundColor: colors.bgRaised
},
@@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
const typeCommandWithOutcome = vi.fn()
vi.mock('./mobile-native-chat-send', () => ({
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args),
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
@@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => {
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
typeCommandWithOutcome.mockReset()
typeCommandWithOutcome.mockResolvedValue('accepted')
acceptSend.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
@@ -215,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => {
expect(onCommandSend).toHaveBeenCalledWith('/clear')
})
it('never creates an optimistic echo for an unknown slash token', async () => {
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
})
it.each(['claude', 'openclaude'] as const)(
'keeps %s composer slash sends pasted',
async (agent) => {
// `/model` is not in Claude's autocomplete catalog, but the session-option
// recorder still recognizes it without claiming a generic command ran.
mount(() => null, agent)
await act(async () => {
await api!.send('/model sonnet')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
expect(sendWithOutcome).toHaveBeenCalledOnce()
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
}
)
it('classifies per agent: /model is a catalog command for Codex', async () => {
it('types Codex composer slash sends instead of pasting them', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.send('/model')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('/model')
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('keeps Codex skill sends pasted', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.send('$ref-oss')
})
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).toHaveBeenCalledWith('$ref-oss')
expect(sendWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ text: '$ref-oss', terminal: 'term' })
)
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
})
it('holds only chat sends for transcript confirmation on a lost ack', async () => {
@@ -248,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => {
expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1)
})
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
it.each(['claude', 'openclaude'] as const)(
'keeps %s session-option commands pasted',
async (agent) => {
mount(() => ({ text: DRAFT, createdAt: 1 }), agent)
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' })
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
}
)
it('routes typed picker commands around the pasted composer-text send', async () => {
mount(() => null, 'codex')
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model sonnet')
outcome = await api!.dispatchCommand('/model', { delivery: 'type' })
})
expect(outcome).toBe('accepted')
expect(acceptSend).not.toHaveBeenCalled()
expect(onCommandSend).not.toHaveBeenCalled()
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('types Codex session-option commands without caller delivery metadata', async () => {
mount(() => null, 'codex')
await act(async () => {
await api!.dispatchCommand('/model')
})
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('binds classification to the agent that started the send', async () => {
@@ -4,8 +4,11 @@ import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog'
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
@@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = {
answerQuestion: (text: string) => Promise<boolean>
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
* composer draft; callers need the outcome to track dispatched state. */
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
text: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
}
/** The native-chat send seam: one write path shared by composer sends, image
@@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: {
return 'rejected'
}
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
// Why: pre-clear only when nothing was deliberately pasted first. The heal
// above fires only for terminals a mobile image paste marked, so a desktop
// launch-draft prefill parked on the input line would otherwise glue onto
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
//
// Also skipped once the dedicated clear above ran: the line is already
// empty, and a Ctrl+U written immediately before body text in the SAME
// write reaches the agent as a literal control character rather than a
// keypress (observed live as a stray \x15 heading the received message).
clearInputFirst: !images?.length && !seededLaunchDraft,
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? {
resolvedLaunchDraft: {
text: seededLaunchDraft.text,
createdAt: seededLaunchDraft.createdAt
}
}
: {}),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
const classification = classifyMobileNativeChatSend(agent, text)
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
const resolvedLaunchDraft =
syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt }
: undefined
const outcome =
agent === 'codex' &&
classification !== 'chat' &&
isSlashCommandDraft(text) &&
!images?.length
? await typeMobileNativeChatCommandWithOutcome({
client,
terminal: handle,
command: text,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
...(mobileClient ? { mobileClient } : {}),
deadline
})
: await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
// Why: an image send already cleared before its paste; a second
// clear here would wipe the image before submission.
clearInputFirst: !images?.length && !seededLaunchDraft,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
deadline,
...(mobileClient ? { mobileClient } : {})
})
// Why (desktop parity): a slash/skill send dispatches into the agent's own
// TUI, not the conversation — the transcript never echoes it as a user
// turn, so an optimistic bubble would never reconcile and the
// unconfirmed hold could never observe a landing.
const classification = classifyMobileNativeChatSend(agent, text)
if (outcome === 'unknown') {
if (classification === 'chat') {
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
@@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: {
// spaces a send's body and its Enter ~500ms apart — so without this lock an
// apply lands between them and is submitted as part of the user's prompt.
const dispatchCommand = useCallback(
async (text: string): Promise<MobileNativeChatSendOutcome> => {
async (
text: string,
_options?: { delivery?: CatalogCommandDelivery }
): Promise<MobileNativeChatSendOutcome> => {
const terminal = handleRef.current
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
return 'rejected'
}
try {
if (agentRef.current === 'codex') {
if (!client || !terminal || !enabled) {
return 'rejected'
}
const deadline = openMobileNativeChatSendBudget()
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
if (
!(await healMobileNativeChatStaleInput({
client,
terminal,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
return 'rejected'
}
return typeMobileNativeChatCommandWithOutcome({
client,
terminal,
command: text,
...(mobileClient ? { mobileClient } : {}),
deadline
})
}
return await sendMessage(text, undefined, false, false)
} finally {
if (terminal) {
@@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: {
}
}
},
[handleRef, sendMessage]
[client, deviceTokenRef, enabled, handleRef, sendMessage]
)
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
@@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => {
let renderer: ReactTestRenderer | null = null
let api: MobileNativeChatSessionOptionsController | null = null
let hookArgs: HookArgs
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
const dispatchCommand = vi.fn<HookArgs['dispatchCommand']>()
const onAgentPicker = vi.fn()
function Probe(): null {
@@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
})
it('applies Codex model changes through the native command', async () => {
it('types the Codex picker command and switches to the terminal', async () => {
mount({ agent: 'codex' })
expect(api!.snapshot[0]?.action).toBeUndefined()
expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' })
await act(async () => {
await api!.setOption('model', 'gpt-5.5')
await api!.invokeAction('model')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5')
expect(onAgentPicker).not.toHaveBeenCalled()
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('types the Codex effort picker command', async () => {
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
await act(async () => {
await api!.invokeAction('effort')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('seeds the current model from a hook-reported provider model', () => {
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import {
getAgentSessionOptionCatalog,
type AgentSessionOptionCatalog,
type CatalogCommandDelivery,
type CatalogModel
} from '../../../src/shared/agent-session-option-catalog'
import type {
@@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: {
scopeKey: string | null
/** Provider model from live agent status, when the hook reported one. */
reportedModel: string | null
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
command: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
/** A model change that must happen in the agent's own TUI picker was
* dispatched — bring the terminal view forward. */
onAgentPicker?: () => void
@@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: {
?.options.find((option) => option.id === id)?.apply
const midSession = apply?.midSession
if (midSession?.kind === 'agent-picker') {
const outcome = await dispatchCommand(midSession.command)
const outcome = midSession.delivery
? await dispatchCommand(midSession.command, { delivery: midSession.delivery })
: await dispatchCommand(midSession.command)
if (outcome === 'rejected') {
return false
}
@@ -109,7 +109,10 @@ describe('useMobileNativeChatSession', () => {
await Promise.resolve()
})
expect(state?.messages).toEqual([])
// The retained window keeps rendering while the source is gone; what must
// never land is the page that resolved after it disappeared.
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-page')
expect(state?.messages).toHaveLength(40)
expect(state?.status).toBe('idle')
expect(state?.loadingEarlier).toBe(false)
})
@@ -557,6 +560,30 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
})
})
it('keeps the retained transcript rendered when the stream reports an error', async () => {
// A transient read failure must not blank a conversation the user is looking
// at; the last settled list for this identity stays until a read supersedes it.
let emitFrame: (frame: unknown) => void = () => {}
const client = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFrame = onData
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
} as unknown as RpcClient
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready', ids: ['a-1'] })
renders.length = 0
await act(async () => emitFrame({ type: 'error', message: 'stream broke' }))
expect(renders.at(-1)).toMatchObject({
status: 'error',
transcriptLoading: false,
ids: ['a-1']
})
})
it('never holds a cached list across a host/workspace source change', async () => {
const firstClient = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
@@ -273,11 +273,12 @@ export function useMobileNativeChatSession(args: {
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
// Held for any unsettled read, not just an in-flight one: a stream error or a
// dropped client would otherwise trade the conversation for an error card.
const visibleMessages = transcriptRetentionRef.current.visible({
identity,
messages,
settled: settledReady,
loading: status === 'loading'
settled: settledReady
})
return {
@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mapBaseRefResults } from './mobile-base-ref-search'
describe('mapBaseRefResults', () => {
it('extracts a clean string list from a well-formed payload', () => {
expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev'])
})
it('returns [] for malformed payloads and drops bad entries', () => {
expect(mapBaseRefResults(null)).toEqual([])
expect(mapBaseRefResults({})).toEqual([])
expect(mapBaseRefResults({ refs: 'x' })).toEqual([])
expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two'])
})
})
@@ -1,31 +0,0 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create'
export function mapBaseRefResults(raw: unknown): string[] {
if (raw === null || typeof raw !== 'object') {
return []
}
const refs = (raw as { refs?: unknown }).refs
if (!Array.isArray(refs)) {
return []
}
return refs.filter((r): r is string => typeof r === 'string' && r.length > 0)
}
export async function searchBaseRefs(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
query: string,
limit = 20
): Promise<string[]> {
const response = await client.sendRequest('repo.searchRefs', {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
query,
limit
})
if (!response.ok) {
return []
}
return mapBaseRefResults((response as RpcSuccess).result)
}
@@ -1,268 +0,0 @@
import type { MobileGitUpstreamStatus } from './mobile-git-status'
export type MobileSourceControlPrimaryActionKind =
| 'commit'
| 'stage'
| 'push'
| 'pull'
| 'sync'
| 'publish'
export type MobileSourceControlRemoteOpKind =
| 'push'
| 'force_push'
| 'pull'
| 'sync'
| 'fetch'
| 'fast_forward'
| 'publish'
| 'rebase'
export type MobileSourceControlPrimaryActionTitleIntent =
| 'commit_in_progress'
| 'force_push_in_progress'
| 'action_in_progress'
| 'remote_operation_in_progress'
| 'remote_operation_blocks_commit'
| 'resolve_conflicts_before_commit'
| 'commit_staged_changes'
| 'enter_commit_message'
| 'stage_all_changes'
| 'stage_file_to_commit'
| 'checkout_branch_before_publish'
| 'publish_branch'
| 'force_push_with_lease'
| 'sync_counts'
| 'pull_count'
| 'push_count'
| 'nothing_to_commit_up_to_date'
export type MobileSourceControlPrimaryActionDecision = {
kind: MobileSourceControlPrimaryActionKind
disabled: boolean
labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
titleIntent: MobileSourceControlPrimaryActionTitleIntent
count?: number
ahead?: number
behind?: number
upstreamName?: string
requiresForceWithLease?: boolean
}
export type MobileSourceControlPrimaryActionDecisionInputs = {
stagedCount: number
hasUnstagedChanges: boolean
hasStageableChanges: boolean
hasPartiallyStagedChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
isCommitting: boolean
isRemoteOperationActive: boolean
upstreamStatus: MobileGitUpstreamStatus | undefined
inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}
// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
// tree. Keep this mobile mirror narrow and parity-tested against the shared
// commit-area decision core so the semantic ladder cannot drift silently.
export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const {
stagedCount,
hasUnstagedChanges,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
upstreamStatus,
branchCommitsAhead,
hasCurrentBranch = true
} = inputs
if (isCommitting) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_in_progress',
disabled: true
}
}
if (isRemoteOperationActive) {
return resolveMobilePrimaryActionDuringRemoteOp(inputs)
}
if (hasUnresolvedConflicts) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'resolve_conflicts_before_commit',
disabled: true
}
}
const hasStaged = stagedCount > 0
if (hasStaged && hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_staged_changes',
disabled: false
}
}
if (hasStaged && !hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'enter_commit_message',
disabled: true
}
}
if (!hasStaged && hasStageableChanges) {
return {
kind: 'stage',
labelIntent: 'stage',
titleIntent: 'stage_all_changes',
disabled: false
}
}
if (!upstreamStatus) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'stage_file_to_commit',
disabled: true
}
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'checkout_branch_before_publish',
disabled: true
}
}
return {
kind: 'publish',
labelIntent: 'publish',
titleIntent: 'publish_branch',
disabled: false
}
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_with_lease',
disabled: false,
count: branchCommitsAhead,
upstreamName: upstreamStatus.upstreamName,
requiresForceWithLease: true
}
}
return {
kind: 'sync',
labelIntent: 'sync',
titleIntent: 'sync_counts',
disabled: false,
ahead: upstreamStatus.ahead,
behind: upstreamStatus.behind
}
}
if (upstreamStatus.behind > 0) {
return {
kind: 'pull',
labelIntent: 'pull',
titleIntent: 'pull_count',
disabled: false,
count: upstreamStatus.behind
}
}
if (upstreamStatus.ahead > 0) {
return {
kind: 'push',
labelIntent: 'push',
titleIntent: 'push_count',
disabled: false,
count: upstreamStatus.ahead
}
}
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
disabled: true
}
}
function resolveMobilePrimaryActionDuringRemoteOp(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
...inputs,
isRemoteOperationActive: false
})
const inFlightIsPrimaryKind =
inFlightRemoteOpKind === 'push' ||
inFlightRemoteOpKind === 'pull' ||
inFlightRemoteOpKind === 'sync' ||
inFlightRemoteOpKind === 'publish'
if (inFlightRemoteOpKind === 'force_push') {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_in_progress',
disabled: true,
requiresForceWithLease: true
}
}
if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
return {
kind: inFlightRemoteOpKind,
labelIntent: inFlightRemoteOpKind,
titleIntent: 'action_in_progress',
disabled: true
}
}
const titleIntent = hasUnresolvedConflicts
? 'resolve_conflicts_before_commit'
: candidate.kind === 'commit'
? 'remote_operation_blocks_commit'
: 'remote_operation_in_progress'
return {
...candidate,
titleIntent,
disabled: true
}
}
function shouldForcePushWithLeaseForMobileUpstream(
status: MobileGitUpstreamStatus | undefined
): boolean {
return (
status?.hasUpstream === true &&
status.ahead > 0 &&
status.behind > 0 &&
status.behindCommitsArePatchEquivalent === true
)
}
@@ -1,9 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecisionInputs
} from './mobile-source-control-primary-action-decision'
import {
buildMobileSourceControlPrimaryAction,
type MobileSourceControlPrimaryActionArgs,
@@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => {
expect(action.disabled).toBe(false)
})
})
function decisionInputs(
overrides: Partial<MobileSourceControlPrimaryActionDecisionInputs> = {}
): MobileSourceControlPrimaryActionDecisionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
describe('mobile source-control primary action decision parity', () => {
it.each([
{
name: 'dirty tree stages first',
input: decisionInputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
})
},
{
name: 'staged message commits',
input: decisionInputs({ stagedCount: 1, hasMessage: true })
},
{
name: 'staged without message blocks commit',
input: decisionInputs({ stagedCount: 1, hasMessage: false })
},
{
name: 'unresolved conflicts block commit',
input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
},
{
name: 'unpublished branch publishes',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: true
})
},
{
name: 'detached head blocks publish',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: false
})
},
{
name: 'tracked ahead pushes',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
},
{
name: 'tracked behind pulls',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
},
{
name: 'tracked diverged syncs',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
},
{
name: 'patch-equivalent diverged force-pushes with lease',
input: decisionInputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 10,
behind: 2,
behindCommitsArePatchEquivalent: true
}
})
},
{
name: 'in-flight pull mirrors pull',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'pull',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight force push mirrors force push',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'force_push',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight push blocks committable candidate',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'push',
stagedCount: 1,
hasMessage: true
})
}
])('matches the shared commit-area decision for $name', ({ input }) => {
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
resolveSourceControlCommitAreaPrimaryActionDecision(input)
)
})
})
@@ -1,14 +1,17 @@
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecision,
type MobileSourceControlPrimaryActionKind,
type MobileSourceControlRemoteOpKind
} from './mobile-source-control-primary-action-decision'
resolveSourceControlCommitAreaPrimaryActionDecision,
type SourceControlCommitAreaPrimaryActionDecision,
type SourceControlRemoteOpKind
} from '../../../src/shared/source-control-primary-action-decision'
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
import type { MobileGitStatusResult } from './mobile-git-status'
type GitStep = { method: string; params?: Record<string, unknown> }
type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind']
type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision
type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind
export type MobileSourceControlPrimaryAction = {
kind: MobileSourceControlPrimaryActionKind
label: string
@@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = {
export function buildMobileSourceControlPrimaryAction(
args: MobileSourceControlPrimaryActionArgs
): MobileSourceControlPrimaryAction {
const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
const decision = resolveSourceControlCommitAreaPrimaryActionDecision({
stagedCount: args.stagedCount,
hasUnstagedChanges: args.unstagedCount > 0,
hasStageableChanges: args.stageablePaths.length > 0,
@@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe
return 'Stage at least one file to commit.'
case 'checkout_branch_before_publish':
return 'Check out a branch before publishing commits.'
case 'checking_review_status':
return 'Checking review status.'
case 'review_already_merged':
return 'Nothing to commit. The review is already merged.'
case 'publish_branch':
return 'Publish this branch to origin.'
case 'push_linked_review':
return 'Push updates to the linked review branch.'
case 'linked_review_target_unavailable':
return 'The linked review branch is unavailable.'
case 'force_push_with_lease':
return 'Force push with lease to update the remote branch.'
case 'sync_counts':
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
canSubmitPrCompose,
getPrComposeDisabledReason,
isBaseHeadDistinct
} from './pr-compose-validation'
describe('isBaseHeadDistinct', () => {
it('is true when base and head differ', () => {
expect(isBaseHeadDistinct('main', 'feature')).toBe(true)
})
it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => {
expect(isBaseHeadDistinct('main', 'main')).toBe(false)
expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false)
expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false)
expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false)
})
it('is false for an empty base', () => {
expect(isBaseHeadDistinct('', 'feature')).toBe(false)
})
})
describe('canSubmitPrCompose', () => {
it('requires a non-empty title and a distinct base', () => {
expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true)
expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false)
expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false)
})
})
describe('getPrComposeDisabledReason', () => {
it('returns null when the form can submit', () => {
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'feature',
generating: false,
reviewLabel: 'pull request'
})
).toBeNull()
})
it('names the active blocker', () => {
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'feature',
generating: true,
reviewLabel: 'pull request'
})
).toBe('Wait for generation to finish.')
expect(
getPrComposeDisabledReason({
title: '',
base: 'main',
head: 'feature',
generating: false,
reviewLabel: 'merge request'
})
).toBe('Enter a merge request title.')
expect(
getPrComposeDisabledReason({
title: 'Title',
base: '',
head: 'feature',
generating: false,
reviewLabel: 'pull request'
})
).toBe('Choose a base branch.')
expect(
getPrComposeDisabledReason({
title: 'Title',
base: 'main',
head: 'main',
generating: false,
reviewLabel: 'pull request'
})
).toBe('Base branch must differ from the head branch.')
})
})
@@ -1,55 +0,0 @@
// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not
// referenced across the package boundary — Metro's resolver is rooted at mobile/ and
// can't bundle a runtime import from repo-root/src, unlike erased `import type`s).
// Keep in sync with the shared versions so mobile and desktop compare refs identically.
function normalizeHeadRef(ref: string): string {
return ref
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^refs\/remotes\/[^/]+\//, '')
}
function normalizeBaseRef(ref: string): string {
return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '')
}
// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog:
// the base ref must be non-empty and must differ from the head branch after ref
// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive.
export function isBaseHeadDistinct(base: string, head: string): boolean {
const b = normalizeBaseRef(base).toLowerCase()
const h = normalizeHeadRef(head).toLowerCase()
return b.length > 0 && b !== h
}
export function canSubmitPrCompose(title: string, base: string, head: string): boolean {
return title.trim().length > 0 && isBaseHeadDistinct(base, head)
}
export function getPrComposeDisabledReason({
title,
base,
head,
generating,
reviewLabel
}: {
title: string
base: string
head: string
generating: boolean
reviewLabel: string
}): string | null {
if (generating) {
return 'Wait for generation to finish.'
}
if (title.trim().length === 0) {
return `Enter a ${reviewLabel} title.`
}
if (base.trim().length === 0) {
return 'Choose a base branch.'
}
if (!isBaseHeadDistinct(base, head)) {
return 'Base branch must differ from the head branch.'
}
return null
}
@@ -1,261 +0,0 @@
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
export type GitHubProjectFieldValue =
| { kind: 'text'; text: string }
| { kind: 'number'; number: number }
| { kind: 'date'; date: string }
| { kind: 'single-select'; optionId: string; name: string }
| { kind: 'iteration'; iterationId: string; title: string }
| { kind: 'labels'; labels: Array<{ name: string }> }
| { kind: 'users'; users: Array<{ login: string }> }
export type GitHubProjectField = {
id: string
name: string
kind: string
options?: Array<{ id: string }>
iterations?: Array<{
id: string
title?: string
startDate: string
duration: number
completed: boolean
}>
}
export type GitHubProjectRow = {
position?: number | null
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
}
export type GitHubProjectSort = {
direction: GitHubProjectSortDirection
field: GitHubProjectField
}
export type GitHubProjectTable = {
selectedView: {
groupByFields: GitHubProjectField[]
sortByFields: GitHubProjectSort[]
}
rows: GitHubProjectRow[]
}
export type ProjectGroup = {
key: string
label: string
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectFieldValue): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((label) => label.name).join(', ')
case 'users':
return value.users.map((user) => user.login).join(', ')
}
}
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const iterations = field.iterations ?? []
const idx = iterations.findIndex((it) => it.id === value.iterationId)
const meta = iterations.find((it) => it.id === value.iterationId)
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
const label = deriveStringValue(value)
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, value]) => ({
key,
label: value.label,
iteration: value.iteration,
rows: value.rows
}))
}
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const options = field.options ?? []
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const iterations = field.iterations ?? []
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
if (cmp !== 0) {
return cmp
}
}
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}
+2
View File
@@ -2,6 +2,7 @@ import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
import {
applyTerminalQuickCommandMutation,
flattenTerminalQuickCommand,
getTerminalQuickCommandBody,
isTerminalAgentQuickCommand,
MAX_QUICK_COMMANDS,
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
@@ -20,6 +21,7 @@ import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents'
// — agent-branded labels, native row truncation, and the launch plan — live here.
export {
isTerminalAgentQuickCommand as isAgentQuickCommand,
getTerminalQuickCommandBody,
terminalQuickCommandMatchesRepo as quickCommandMatchesRepo,
supportsTerminalAgentQuickCommand,
MAX_QUICK_COMMANDS,
@@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startPairingConnectionAttempt } from './pairing-connection-attempt'
describe('pairing connection attempt cleanup', () => {
afterEach(() => {
vi.useRealTimers()
})
it('closes the temporary client when the overall pairing timeout fires', () => {
vi.useFakeTimers()
const closeClient = vi.fn()
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
expect(attempt.timedOut).toBe(false)
vi.advanceTimersByTime(24_999)
expect(closeClient).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(attempt.timedOut).toBe(true)
expect(closeClient).toHaveBeenCalledTimes(1)
attempt.dispose()
expect(closeClient).toHaveBeenCalledTimes(1)
})
it('clears the timeout and closes the temporary client when disposed early', () => {
vi.useFakeTimers()
const closeClient = vi.fn()
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
attempt.dispose()
expect(attempt.timedOut).toBe(false)
expect(closeClient).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(25_000)
expect(attempt.timedOut).toBe(false)
expect(closeClient).toHaveBeenCalledTimes(1)
})
})
@@ -1,50 +0,0 @@
export type PairingConnectionAttempt = {
readonly timedOut: boolean
dispose: () => void
}
export function startPairingConnectionAttempt({
timeoutMs,
closeClient
}: {
timeoutMs: number
closeClient: () => void
}): PairingConnectionAttempt {
let disposed = false
let clientClosed = false
let timedOut = false
let timer: ReturnType<typeof setTimeout> | null = null
function closeClientOnce() {
if (clientClosed) {
return
}
clientClosed = true
closeClient()
}
function dispose() {
if (disposed) {
return
}
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
closeClientOnce()
}
timer = setTimeout(() => {
timer = null
timedOut = true
dispose()
}, timeoutMs)
return {
get timedOut() {
return timedOut
},
dispose
}
}
+5 -2
View File
@@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs
item = record(node, len(records), path, window_rect)
child_items = list(children(node))
role_key = (item["controlType"] or "").lower()
summary_values = text_snippets(node, limit=8, max_depth=4)
generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
generic_summary = None
if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]:
summary_values = text_snippets(node, limit=8, max_depth=4)
if len(summary_values) >= 2 and is_plain_text_subtree(node):
generic_summary = " ".join(summary_values)
if should_elide(item, len(child_items), generic_summary):
for child_index, child in child_items:
walk(child, depth, path + [child_index])
@@ -0,0 +1,198 @@
import importlib.util
import sys
import types
import unittest
from pathlib import Path
class FakeStateType:
PROTECTED = 1
SELECTED = 2
class FakeText:
@staticmethod
def get_character_count(node):
return len(node.value)
@staticmethod
def get_text(node, start, end):
return node.value[start:end]
def load_runtime():
gi = types.ModuleType("gi")
repository = types.ModuleType("gi.repository")
gi.require_version = lambda *_: None
repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText)
repository.Gdk = types.SimpleNamespace()
repository.GdkPixbuf = types.SimpleNamespace()
gi.repository = repository
sys.modules["gi"] = gi
sys.modules["gi.repository"] = repository
path = Path(__file__).with_name("runtime.py")
spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
runtime = load_runtime()
class FakeAccessible:
def __init__(self, role, name="", value="", children=(), counter=None):
self.role = role
self.name = name
self.value = value
self.children = list(children)
self.counter = counter or {"child_reads": 0}
for child in self.children:
child.use_counter(self.counter)
def use_counter(self, counter):
self.counter = counter
for child in self.children:
child.use_counter(counter)
def get_child_count(self):
self.counter["child_reads"] += 1
return len(self.children)
def get_child_at_index(self, index):
self.counter["child_reads"] += 1
return self.children[index]
def get_role_name(self):
return self.role
def get_name(self):
return self.name
def get_accessible_id(self):
return ""
def get_toolkit_name(self):
return "fake"
def get_component_iface(self):
return None
def get_state_set(self):
return None
def get_n_actions(self):
return 0
def is_text(self):
return bool(self.value)
def get_text_iface(self):
return self if self.value else None
def get_value_iface(self):
return None
class FailingChildrenAccessible(FakeAccessible):
def get_child_count(self):
raise RuntimeError("defunct node")
def render(root):
return runtime.render_accessibility_tree(root, None, [0])
class RuntimeRenderTest(unittest.TestCase):
def test_named_non_generic_node_skips_unused_summary_walk(self):
root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")])
records, lines, truncation = render(root)
self.assertEqual([record["name"] for record in records], ["Save"])
self.assertEqual(lines, ["0 button Save"])
self.assertEqual(root.counter["child_reads"], 2)
self.assertFalse(truncation["truncated"])
def test_named_generic_node_skips_unused_summary_walk(self):
root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")])
records, lines, _ = render(root)
self.assertEqual([record["name"] for record in records], ["Details", "body"])
self.assertEqual(lines, ["0 section Details", "\t1 text body"])
self.assertEqual(root.counter["child_reads"], 3)
def test_unnamed_generic_node_keeps_plain_text_summary(self):
root = FakeAccessible(
"section",
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
)
records, lines, _ = render(root)
self.assertEqual(len(records), 1)
self.assertEqual(lines, ["0 section, Text: Alpha Beta"])
self.assertEqual(root.counter["child_reads"], 13)
def test_row_keeps_its_specific_summary_walk(self):
root = FakeAccessible(
"row",
"Invoice",
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
)
records, lines, _ = render(root)
self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"])
self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"])
self.assertEqual(root.counter["child_reads"], 10)
def test_empty_generic_wrapper_keeps_elision_path_and_depth(self):
root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")])
records, lines, _ = render(root)
self.assertEqual([record["runtimeId"] for record in records], [[0, 0]])
self.assertEqual(lines, ["0 button Continue"])
def test_child_failure_keeps_fail_soft_row_output(self):
root = FailingChildrenAccessible("row", "Invoice")
records, lines, truncation = render(root)
self.assertEqual([record["name"] for record in records], ["Invoice"])
self.assertEqual(lines, ["0 row Invoice"])
self.assertFalse(truncation["truncated"])
def test_node_limit_keeps_exact_prefix_and_truncation(self):
root = FakeAccessible(
"document",
"Results",
children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)],
)
records, _, truncation = render(root)
self.assertEqual(len(records), runtime.MAX_NODES)
self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}")
self.assertTrue(truncation["truncated"])
self.assertFalse(truncation["maxDepthReached"])
def test_depth_limit_keeps_exact_prefix_and_flag(self):
root = FakeAccessible("document", "Depth 65")
for depth in range(runtime.MAX_DEPTH, -1, -1):
root = FakeAccessible("document", f"Depth {depth}", children=[root])
records, _, truncation = render(root)
self.assertEqual(len(records), runtime.MAX_DEPTH + 1)
self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}")
self.assertTrue(truncation["truncated"])
self.assertTrue(truncation["maxDepthReached"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,224 @@
$ErrorActionPreference = "Stop"
function Assert-TestEqual($Actual, $Expected, [string]$Label) {
if ($Actual -ne $Expected) {
throw "$Label expected '$Expected', received '$Actual'"
}
}
Add-Type -TypeDefinition @"
using System.Collections;
using System.Collections.Generic;
public sealed class OrcaRenderTestCounter {
public int FindAll;
}
public sealed class OrcaRenderTestBounds {
public bool IsEmpty = true;
public double X;
public double Y;
public double Width;
public double Height;
}
public sealed class OrcaRenderTestControlType {
public string ProgrammaticName = "";
}
public sealed class OrcaRenderTestCurrent {
public string AutomationId = "";
public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds();
public string ClassName = "fake";
public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType();
public bool IsPassword;
public string LocalizedControlType = "";
public string Name = "";
public long NativeWindowHandle;
}
public sealed class OrcaRenderTestPatternCurrent {
public bool IsSelected;
public string Value = "";
}
public sealed class OrcaRenderTestPattern {
public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent();
}
public sealed class OrcaRenderTestCollection : IEnumerable {
private readonly OrcaRenderTestElement[] values;
public OrcaRenderTestCollection(OrcaRenderTestElement[] values) {
this.values = values;
}
public int Count {
get { return values.Length; }
}
public OrcaRenderTestElement Item(int index) {
return values[index];
}
public IEnumerator GetEnumerator() {
return values.GetEnumerator();
}
}
public sealed class OrcaRenderTestElement {
public readonly List<OrcaRenderTestElement> Children =
new List<OrcaRenderTestElement>();
public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter();
public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent();
public bool FailFindAll;
public int RuntimeIdValue;
public string ValueText = "";
public OrcaRenderTestCollection FindAll(object scope, object condition) {
Counter.FindAll++;
if (FailFindAll) {
throw new System.InvalidOperationException("defunct node");
}
return new OrcaRenderTestCollection(Children.ToArray());
}
public OrcaRenderTestPattern GetCurrentPattern(object pattern) {
OrcaRenderTestPattern result = new OrcaRenderTestPattern();
result.Current.Value = ValueText;
return result;
}
public int[] GetRuntimeId() {
return new int[] { RuntimeIdValue };
}
public object[] GetSupportedPatterns() {
return new object[0];
}
}
"@
function New-TestCounter {
New-Object -TypeName OrcaRenderTestCounter
}
function New-TestElement {
param(
[string]$Role,
[string]$Name = "",
[string]$Value = "",
[object[]]$Children = @(),
$Counter = $(New-TestCounter),
[switch]$FailFindAll,
[int]$RuntimeId = 1
)
$element = New-Object -TypeName OrcaRenderTestElement
$element.Counter = $Counter
$element.FailFindAll = [bool]$FailFindAll
$element.RuntimeIdValue = $RuntimeId
$element.ValueText = $Value
$element.Current.ControlType.ProgrammaticName = $Role
$element.Current.LocalizedControlType = $Role
$element.Current.Name = $Name
foreach ($child in @($Children)) {
[void]$element.Children.Add($child)
}
$element
}
$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json")
try {
Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}'
$runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath
$handshake = $runtimeOutput | ConvertFrom-Json
Assert-TestEqual $handshake.ok $true "runtime handshake"
$counter = New-TestCounter
$leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "named control record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line"
Assert-TestEqual $counter.findAll 1 "named control child enumeration"
$counter = New-TestCounter
$leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records"
Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines"
Assert-TestEqual $counter.findAll 2 "named generic child enumeration"
$counter = New-TestCounter
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
$root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary"
Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration"
$counter = New-TestCounter
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
$root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records"
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary"
Assert-TestEqual $counter.findAll 6 "row child enumeration"
$counter = New-TestCounter
$button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2
$root = New-TestElement -Role "group" -Children @($button) -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line"
Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration"
$counter = New-TestCounter
$root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 1 "failed child read record count"
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line"
Assert-TestEqual $counter.findAll 2 "failed child read retries"
$originalMaxNodes = $MaxNodes
try {
$MaxNodes = 3
$counter = New-TestCounter
$children = @()
for ($index = 0; $index -lt 3; $index++) {
$children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2)
}
$root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 3 "node limit record count"
Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix"
Assert-TestEqual $tree.truncation.truncated $true "node limit truncation"
Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag"
} finally {
$MaxNodes = $originalMaxNodes
}
$originalMaxDepth = $MaxDepth
try {
$MaxDepth = 2
$counter = New-TestCounter
$root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4
for ($depth = 2; $depth -ge 0; $depth--) {
$root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1)
}
$tree = Render-OrcaTree $root $null
Assert-TestEqual $tree.elements.Count 3 "depth limit record count"
Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix"
Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation"
Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag"
} finally {
$MaxDepth = $originalMaxDepth
}
Write-Output "windows-snapshot-render-tests-ok"
} finally {
Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue
}
+5 -3
View File
@@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs =
$title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
$role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
$roleKey = $role.ToLowerInvariant()
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
$genericSummary = $null
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
$genericSummary = ($snippets -join " ")
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) {
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
$genericSummary = ($snippets -join " ")
}
}
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
for ($i = 0; $i -lt $children.Count; $i++) {
+152 -18
View File
@@ -24,7 +24,6 @@ import {
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
wrapWindowsGitBashHookCommand,
readHooksJsonWithRaw,
wrapWindowsHookCommand,
writeManagedScript,
@@ -32,6 +31,7 @@ import {
type HooksConfig
} from './installer-utils'
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
let tmpDir: string
let configPath: string
@@ -320,6 +320,53 @@ describe('removeManagedCommands', () => {
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
})
it('removes exec-form hooks whose managed script path is an argument', () => {
const cleaned = removeManagedCommands(
[
{
hooks: [
{
type: 'command',
command: 'C:\\Windows\\System32\\conhost.exe',
args: [
'--headless',
'C:\\Windows\\System32\\cmd.exe',
'/d',
'/c',
'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd'
]
},
{ type: 'command', command: 'echo keep me' }
]
}
],
match
)
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
})
it('preserves user hooks with malformed args fields', () => {
const definitions = [
{
hooks: [
{
type: 'command' as const,
command: 'echo keep me',
args: 'not-an-array' as unknown as string[]
},
{
type: 'command' as const,
command: 'echo keep me too',
args: [42] as unknown as string[]
}
]
}
]
expect(removeManagedCommands(definitions, match)).toEqual(definitions)
})
})
describe('hookDefinitionHasManagedCommand', () => {
@@ -338,6 +385,34 @@ describe('hookDefinitionHasManagedCommand', () => {
match
)
).toBe(true)
expect(
hookDefinitionHasManagedCommand(
{
hooks: [
{
type: 'command',
command: 'C:\\Windows\\System32\\conhost.exe',
args: ['--headless', 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd']
}
]
},
match
)
).toBe(true)
expect(
hookDefinitionHasManagedCommand(
{
hooks: [
{
type: 'command',
command: 'echo no',
args: 'not-an-array' as unknown as string[]
}
]
},
match
)
).toBe(false)
expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false)
})
})
@@ -573,28 +648,87 @@ describe('wrapWindowsCmdHookCommand', () => {
})
})
describe('wrapWindowsGitBashHookCommand', () => {
it('guards the forward-slash fast path and drains when missing', () => {
expect(
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
).toBe(
`if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
describe('wrapRuntimeHomeHookCommand', () => {
it('selects the runtime platform variant under HOME', () => {
const command = wrapRuntimeHomeHookCommand('claude-hook')
expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)')
expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)')
expect(command).not.toContain('uname')
expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"')
expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"')
expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//)
})
it('rejects a script base name that could inject shell syntax', () => {
expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow(
'Invalid managed script base name'
)
})
it('falls back to the encoded launcher when bash would split the path', () => {
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
const command = wrapWindowsGitBashHookCommand(scriptPath)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
it('executes the destination HOME script for the current runtime', () => {
const sourceHome = join(tmpDir, 'source profile')
const destinationHome = join(tmpDir, "destination $HOME ' & profile")
const sourceScriptDir = join(sourceHome, '.orca', 'agent-hooks')
const destinationScriptDir = join(destinationHome, '.orca', 'agent-hooks')
mkdirSync(sourceScriptDir, { recursive: true })
mkdirSync(destinationScriptDir, { recursive: true })
const windowsExitCode = process.platform === 'win32' ? 7 : 9
const posixExitCode = process.platform === 'win32' ? 9 : 7
writeFileSync(
join(destinationScriptDir, 'claude-hook.cmd'),
`@echo off\r\nexit /b ${windowsExitCode}\r\n`,
'utf-8'
)
writeFileSync(
join(destinationScriptDir, 'claude-hook.sh'),
`#!/bin/sh\nexit ${posixExitCode}\n`,
'utf-8'
)
writeFileSync(join(sourceScriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 9\r\n', 'utf-8')
writeFileSync(join(sourceScriptDir, 'claude-hook.sh'), '#!/bin/sh\nexit 9\n', 'utf-8')
chmodSync(join(destinationScriptDir, 'claude-hook.sh'), 0o755)
const shell =
process.platform === 'win32'
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
: '/bin/sh'
const result = spawnSync(shell, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
env: {
...process.env,
HOME: destinationHome.replaceAll('\\', '/'),
USERPROFILE: destinationHome
}
})
expect(result.error).toBeUndefined()
expect(result.status, result.stderr.toString()).toBe(7)
})
it('falls back to the encoded launcher when bash metacharacters are present', () => {
const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
const command = wrapWindowsGitBashHookCommand(scriptPath)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(command).not.toContain('& bob')
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
it.skipIf(process.platform !== 'win32')('keeps common Windows profiles on the fast path', () => {
const destinationHome = join(tmpDir, 'destination 国際 profile')
const scriptDir = join(destinationHome, '.orca', 'agent-hooks')
mkdirSync(scriptDir, { recursive: true })
writeFileSync(join(scriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 7\r\n', 'utf-8')
const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
const result = spawnSync(gitBash, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
env: { ...process.env, HOME: destinationHome.replaceAll('\\', '/') }
})
expect(result.error).toBeUndefined()
expect(result.status, result.stderr.toString()).toBe(7)
})
it('drains stdin when HOME is unavailable', () => {
const command = `unset HOME; ${wrapRuntimeHomeHookCommand('claude-hook')}`
const shell =
process.platform === 'win32'
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
: '/bin/sh'
const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') })
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
})
})
+12 -13
View File
@@ -20,6 +20,7 @@ import { writeRollingFileBackup } from '../rolling-file-backup'
export type HookCommandConfig = {
type: 'command'
command: string
args?: string[]
timeout?: number
async?: boolean
statusMessage?: string
@@ -151,16 +152,6 @@ export function wrapWindowsCmdHookCommand(scriptPath: string): string {
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
const bashPath = scriptPath.replaceAll('\\', '/')
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
: wrapWindowsHookCommand(scriptPath)
}
/**
* Extra form lines inserted before the final `payload@-` line (each should end with ` ^`).
* Used by Grok to attach `grokHome` without fragile string replace on the shared template.
@@ -215,7 +206,8 @@ export function removeManagedCommands(
const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key]))
const hasNestedHooks = Array.isArray(definition.hooks)
const hasManagedNestedHook =
hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command))
hasNestedHooks &&
definition.hooks!.some((hook) => hookHasManagedCommand(hook, isManagedCommand))
if (directManagedKeys.length === 0 && !hasManagedNestedHook) {
return [definition]
@@ -227,7 +219,9 @@ export function removeManagedCommands(
}
if (hasManagedNestedHook) {
const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command))
const filteredHooks = definition.hooks!.filter(
(hook) => !hookHasManagedCommand(hook, isManagedCommand)
)
if (filteredHooks.length > 0) {
nextDefinition.hooks = filteredHooks
} else {
@@ -246,6 +240,11 @@ export function removeManagedCommands(
})
}
function hookHasManagedCommand(hook: HookCommandConfig, matches: (value?: string) => boolean) {
const args = Array.isArray(hook.args) ? hook.args : []
return matches(hook.command) || args.some((arg) => typeof arg === 'string' && matches(arg))
}
export function hookDefinitionHasManagedCommand(
definition: HookDefinition,
isManagedCommand: (command: string | undefined) => boolean
@@ -255,7 +254,7 @@ export function hookDefinitionHasManagedCommand(
isManagedCommand(definition.bash) ||
isManagedCommand(definition.powershell) ||
(Array.isArray(definition.hooks) &&
definition.hooks.some((hook) => isManagedCommand(hook.command)))
definition.hooks.some((hook) => hookHasManagedCommand(hook, isManagedCommand)))
)
}
@@ -79,12 +79,9 @@ import { GeminiHookService } from '../gemini/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { KimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import {
wrapPosixHookCommand,
wrapWindowsGitBashHookCommand,
wrapWindowsHookCommand
} from './installer-utils'
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
const REMOTE_HOME = '/home/dev'
@@ -387,9 +384,9 @@ describe('Windows managed hook stdin structure', () => {
args: ['/d', '/c', wrapWindowsHookCommand(missingScript)]
},
{
name: 'Git Bash fast path',
name: 'portable Git Bash launcher',
executable: gitBash,
args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)]
args: ['-lc', wrapRuntimeHomeHookCommand('missing-orca-hook')]
}
]
for (const launcher of launcherCases) {
@@ -0,0 +1,22 @@
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/
const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*'
export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string {
if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) {
throw new Error(`Invalid managed script base name: ${scriptBaseName}`)
}
const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"`
const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"`
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"'
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64')
const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi`
const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi`
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
}
@@ -0,0 +1,346 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: false },
safeStorage: { isEncryptionAvailable: () => false }
}))
import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = []
const apiUrl = 'http://localhost:3000'
const writeRequest = {
sourceKey: '/repo/report.html',
content: '<h1>Recovery</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
apiUrl,
authToken: 'token-a'
}
afterEach(async () => {
vi.unstubAllGlobals()
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('ArtifactCloudService committed response loss recovery', () => {
it('reconciles one remotely revocable artifact after a committed create loses its response', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
await expect(publishedLink(userDataPath)).resolves.toBeNull()
await expect(
service(userDataPath).publish({ ...writeRequest, content: '<h1>Changed after loss</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed after loss</h1>')
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
})
it('replays the exact create when content is unchanged after response loss', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
await expect(service(userDataPath).publish(writeRequest)).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe(writeRequest.content)
})
it('updates a recovered share when its content changed after response loss', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
await expect(
service(userDataPath).share({ ...writeRequest, content: '<h1>Changed share</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { artifact: { slug: 'artifact-1' } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed share</h1>')
})
it('retains recovery until a changed-content update succeeds', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
server.rejectNextUpdateStatus = 503
const changed = { ...writeRequest, content: '<h1>Changed after update failure</h1>' }
await expect(service(userDataPath).share(changed)).rejects.toMatchObject({ statusCode: 503 })
await expect(service(userDataPath).share(changed)).resolves.toMatchObject({
status: 'ok',
value: { artifact: { slug: 'artifact-1' } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
expect(server.artifactContent('artifact-1')).toBe(changed.content)
})
it('clears the durable mapping when a committed delete retry returns 404', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
vi.stubGlobal('fetch', server.fetch)
await service(userDataPath).publish(writeRequest)
server.loseNextDeleteResponse = true
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).rejects.toThrow('response lost')
expect(server.deleteMutations).toBe(1)
expect(server.artifactSlugs()).toEqual([])
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).resolves.toEqual({ status: 'ok', value: undefined })
expect(server.deleteMutations).toBe(1)
expect(server.artifactSlugs()).toEqual([])
await expect(publishedLink(userDataPath)).resolves.toBeNull()
})
it('keeps the durable mapping when a delete receives an unrelated 404', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
vi.stubGlobal('fetch', server.fetch)
await service(userDataPath).publish(writeRequest)
server.rejectNextDeleteCode = 'not_found'
await expect(
service(userDataPath).unshare({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
).rejects.toMatchObject({ statusCode: 404, errorCode: 'not_found' })
expect(server.deleteMutations).toBe(0)
expect(server.artifactSlugs()).toEqual(['artifact-1'])
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
})
it('drops an uncommitted validation failure so corrected content can create', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.rejectNextCreateStatus = 422
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toMatchObject({
statusCode: 422
})
await expect(
service(userDataPath).publish({ ...writeRequest, content: '<h1>Corrected</h1>' })
).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe('<h1>Corrected</h1>')
})
it('keeps a replay intent when validation changes after the original commit', async () => {
const userDataPath = await createUserDataPath()
const server = new ArtifactFaultServer()
server.loseNextCreateResponse = true
vi.stubGlobal('fetch', server.fetch)
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
server.rejectNextCreateStatus = 422
const changed = { ...writeRequest, content: '<h1>Changed after validation</h1>' }
await expect(service(userDataPath).publish(changed)).rejects.toMatchObject({ statusCode: 422 })
await expect(service(userDataPath).publish(changed)).resolves.toMatchObject({
status: 'ok',
value: { item: { artifact: { slug: 'artifact-1' } } }
})
expect(server.createMutations).toBe(1)
expect(server.artifactContent('artifact-1')).toBe(changed.content)
})
})
class ArtifactFaultServer {
readonly fetch = vi.fn(this.handle.bind(this))
createMutations = 0
deleteMutations = 0
loseNextCreateResponse = false
loseNextDeleteResponse = false
rejectNextCreateStatus: number | null = null
rejectNextDeleteCode: string | null = null
rejectNextUpdateStatus: number | null = null
private readonly artifacts = new Map<string, string>()
private readonly createsByKey = new Map<string, { body: string; response: object }>()
artifactSlugs(): string[] {
return [...this.artifacts.keys()].sort()
}
artifactContent(slug: string): string | undefined {
const body = this.artifacts.get(slug)
return body ? (JSON.parse(body) as { content?: string }).content : undefined
}
private async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const method = init?.method ?? 'GET'
if (method === 'POST') {
return this.create(init)
}
if (method === 'PUT') {
return this.update(String(input), init)
}
if (method === 'DELETE') {
return this.delete(String(input))
}
throw new Error(`Unexpected artifact request: ${method} ${String(input)}`)
}
private create(init?: RequestInit): Response {
if (this.rejectNextCreateStatus !== null) {
const status = this.rejectNextCreateStatus
this.rejectNextCreateStatus = null
return jsonResponse({ code: 'artifact_validation_failed' }, status)
}
const key = new Headers(init?.headers).get('idempotency-key')
if (new Headers(init?.headers).get('authorization') !== 'Bearer token-a') {
throw new Error('Missing artifact authorization')
}
if (!key) {
throw new Error('Missing idempotency key')
}
const body = String(init?.body)
const existing = this.createsByKey.get(key)
if (existing) {
if (existing.body !== body) {
throw new Error('Idempotency key reused with another request')
}
return jsonResponse(existing.response, 201)
}
this.createMutations += 1
const slug = `artifact-${this.createMutations}`
const response = createResponseBody(slug)
this.createsByKey.set(key, { body, response })
this.artifacts.set(slug, body)
if (this.loseNextCreateResponse) {
this.loseNextCreateResponse = false
throw new TypeError('response lost after committed create')
}
return jsonResponse(response, 201)
}
private delete(url: string): Response {
if (this.rejectNextDeleteCode !== null) {
const code = this.rejectNextDeleteCode
this.rejectNextDeleteCode = null
return jsonResponse({ code }, 404)
}
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
if (!this.artifacts.delete(slug)) {
return jsonResponse({ code: 'artifact_not_found' }, 404)
}
this.deleteMutations += 1
if (this.loseNextDeleteResponse) {
this.loseNextDeleteResponse = false
throw new TypeError('response lost after committed delete')
}
return new Response(null, { status: 204 })
}
private update(url: string, init?: RequestInit): Response {
if (this.rejectNextUpdateStatus !== null) {
const status = this.rejectNextUpdateStatus
this.rejectNextUpdateStatus = null
return jsonResponse({ code: 'artifact_update_failed' }, status)
}
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
if (!this.artifacts.has(slug)) {
return jsonResponse({ code: 'artifact_not_found' }, 404)
}
const headers = new Headers(init?.headers)
if (
headers.get('authorization') !== 'Bearer token-a' ||
headers.get('x-orca-edit-token') !== `edit-${slug}`
) {
return jsonResponse({ code: 'artifact_forbidden' }, 403)
}
this.artifacts.set(slug, String(init?.body))
return jsonResponse(createResponseBody(slug), 200)
}
}
async function createUserDataPath(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-recovery-'))
createdPaths.push(path)
return path
}
function service(userDataPath: string): ArtifactCloudService {
return new ArtifactCloudService(userDataPath, () => true)
}
async function publishedLink(userDataPath: string): Promise<string | null> {
const result = await service(userDataPath).getPublishedLink({
sourceKey: writeRequest.sourceKey,
apiUrl,
authToken: 'token-a'
})
return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null
}
function jsonResponse(body: object, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
})
}
function createResponseBody(slug: string): object {
return {
artifact: {
version: 1,
slug,
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt: '2026-09-06T00:00:00.000Z',
byteSize: 17,
deletedAt: null
},
shareUrl: `https://share.onorca.dev/a/${slug}`,
editToken: `edit-${slug}`
}
}
+8 -1
View File
@@ -1,7 +1,14 @@
import type { ArtifactWriteRequest } from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
export type ArtifactWriteBody = {
content: string
contentType: ArtifactWriteRequest['contentType']
fileName: string
title?: string
}
export function artifactWriteBody(request: ArtifactWriteRequest): ArtifactWriteBody {
return {
content: request.content,
contentType: request.contentType,
+29 -7
View File
@@ -12,6 +12,7 @@ import type {
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { prepareArtifactCloudUse } from '../orca-profiles/profile-artifact-cloud-cleanup'
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
import {
allowsArtifactCloudAuthOverride,
@@ -28,6 +29,7 @@ import {
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
import { ArtifactPublisher } from './artifact-publisher'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
type ArtifactAuthContext = {
profileId: string
@@ -35,6 +37,28 @@ type ArtifactAuthContext = {
assertCurrent: () => void
}
async function deleteArtifactRequest(
apiUrl: string,
token: string,
path: string,
editToken?: string
): Promise<void> {
try {
await artifactRequest<void>(apiUrl, token, path, {
method: 'DELETE',
...(editToken ? { editToken } : {})
})
} catch (error) {
if (
!(error instanceof OrcaCloudRequestError) ||
error.statusCode !== 404 ||
error.errorCode !== 'artifact_not_found'
) {
throw error
}
}
}
function tokenFingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
@@ -227,10 +251,8 @@ export class ArtifactCloudService {
}
return this.publisher.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
method: 'DELETE',
editToken: record.editToken
})
await deleteArtifactRequest(apiUrl, token, `/${record.slug}`, record.editToken)
auth.assertCurrent()
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
sourceKey: request.sourceKey,
slug: record.slug
@@ -244,9 +266,8 @@ export class ArtifactCloudService {
return this.withAuth(options, (token, apiUrl, auth) =>
this.publisher.runForSlug(id, auth, async () => {
auth.assertCurrent()
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
method: 'DELETE'
})
await deleteArtifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`)
auth.assertCurrent()
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
})
)
@@ -258,6 +279,7 @@ export class ArtifactCloudService {
): Promise<ArtifactCloudOperation<T>> {
const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl)
const active = ensureActiveOrcaProfile(this.userDataPath)
prepareArtifactCloudUse(active.profile, this.userDataPath)
if (options.authToken?.trim()) {
if (!allowsArtifactCloudAuthOverride()) {
throw new Error(
@@ -0,0 +1,319 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts'
import {
MAX_ARTIFACT_CREATE_INTENT_BYTES,
MAX_PENDING_ARTIFACT_CREATES,
clearArtifactCreateIntents,
getArtifactCreateIntent,
getOrCreateArtifactCreateIntent,
removeArtifactCreateIntent
} from './artifact-create-intent-store'
import type { ArtifactShareScope } from './artifact-share-record-store'
vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() }))
const createdPaths: string[] = []
const scope: ArtifactShareScope = {
cloudUserId: 'user-a',
cloudProfileId: 'cloud-a',
cloudOrganizationId: 'org-a',
apiOrigin: 'https://share.onorca.dev'
}
const body = {
content: '<h1>Original</h1>',
contentType: 'text/html' as const,
fileName: 'report.html',
title: 'Original'
}
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('artifact create intent store', () => {
it('retains the first key and exact request until the matching create completes', async () => {
const userDataPath = await createUserDataPath()
const sourceKey = String.raw`C:\repo\report.html`
const first = getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
sourceKey,
scope,
'key-a',
body
)
const retry = getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
sourceKey,
scope,
'key-b',
{ ...body, content: '<h1>Changed</h1>' }
)
expect(first).toEqual(retry)
expect(retry).toMatchObject({ idempotencyKey: 'key-a', body })
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-b')
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).not.toBeNull()
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a')
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).toBeNull()
})
it.each([
['user', { cloudUserId: 'user-b' }],
['profile', { cloudProfileId: 'cloud-b' }],
['organization', { cloudOrganizationId: 'org-b' }],
['API origin', { apiOrigin: 'http://localhost:3000' }]
])('isolates recovery intent by %s', async (_name, changedScope) => {
const userDataPath = await createUserDataPath()
const sourceKey = '/repo/report.html'
getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a', body)
expect(
getArtifactCreateIntent('local-profile', userDataPath, sourceKey, {
...scope,
...changedScope
})
).toBeNull()
})
it('bounds unresolved payload storage without dropping an existing intent', async () => {
const userDataPath = await createUserDataPath()
for (let index = 0; index < MAX_PENDING_ARTIFACT_CREATES; index += 1) {
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
`/repo/report-${index}.html`,
scope,
`key-${index}`,
body
)
}
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/overflow.html',
scope,
'overflow-key',
body
)
).toThrow(/waiting for recovery/)
expect(
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report-0.html',
scope,
'replacement-key',
{ ...body, content: 'replacement' }
).idempotencyKey
).toBe('key-0')
})
it('clears pending content at the profile lifecycle boundary', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
clearArtifactCreateIntents('local-profile', userDataPath)
expect(
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toBeNull()
expect(await readdir(directory)).toEqual([])
})
it('removes crash-left temporary writes before admitting another intent', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
await writeFile(join(directory, 'crash-left.tmp'), 'partial')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/other.html',
scope,
'key-b',
body
)
expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false)
})
it('hardens one Windows journal directory without per-file PowerShell launches', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.mocked(execFileSync).mockImplementation((file) =>
String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : ''
)
try {
const userDataPath = await createUserDataPath()
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/other.html',
scope,
'key-b',
body
)
const powershellCalls = vi
.mocked(execFileSync)
.mock.calls.filter(([file]) => String(file).endsWith('powershell.exe'))
expect(powershellCalls).toHaveLength(1)
expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1')
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
vi.mocked(execFileSync).mockReset()
}
})
it('refuses to overwrite an unreadable matching intent', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await writeFile(join(directory, fileName), '{broken-json')
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-b',
body
)
).toThrow(/could not be read safely/)
})
it('removes an unreadable intent after its mutation completes', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await writeFile(join(directory, fileName), '{broken-json')
expect(() =>
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
).not.toThrow()
expect(await readdir(directory)).toEqual([])
})
it('rejects a persisted content type outside the artifact allowlist', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
const path = join(directory, fileName)
const intent = JSON.parse(await readFile(path, 'utf8')) as { body: { contentType: string } }
intent.body.contentType = 'application/octet-stream'
await writeFile(path, JSON.stringify(intent))
expect(() =>
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toThrow(/unsupported format/)
})
it('persists a valid artifact request near the RPC limit', async () => {
const userDataPath = await createUserDataPath()
const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) }
expect(
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody })
).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES)
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
nearLimitBody
)
).not.toThrow()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
const [fileName] = await readdir(directory)
expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES)
})
it('rejects an oversized recovery record before reading it', async () => {
const userDataPath = await createUserDataPath()
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
scope,
'key-a',
body
)
const [fileName] = await readdir(directory)
await truncate(join(directory, fileName), MAX_ARTIFACT_CREATE_INTENT_BYTES + 1)
expect(() =>
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
).toThrow(/exceeds the supported size/)
})
})
async function createUserDataPath(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-'))
createdPaths.push(path)
return path
}
@@ -0,0 +1,255 @@
import { createHash, randomBytes } from 'node:crypto'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
import {
bestEffortFsyncDirectorySync,
fsyncFileSync,
hardenSecurePath
} from '../../shared/secure-file'
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
import type { ArtifactWriteBody } from './artifact-cloud-request'
import type { ArtifactShareScope } from './artifact-share-record-store'
export const MAX_PENDING_ARTIFACT_CREATES = 32
export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024
const MAX_HARDENED_INTENT_DIRECTORIES = 64
const hardenedIntentDirectories = new Set<string>()
export type ArtifactCreateIntent = {
version: 1
sourceKey: string
scope: ArtifactShareScope
idempotencyKey: string
body: ArtifactWriteBody
}
function intentDirectory(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-create-intents')
}
function ensureIntentDirectory(profileId: string, userDataPath: string): string {
const directory = intentDirectory(profileId, userDataPath)
mkdirSync(directory, { recursive: true, mode: 0o700 })
if (!hardenedIntentDirectories.has(directory)) {
hardenSecurePath(directory, {
isDirectory: true,
platform: process.platform,
sync: true
})
if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) {
const oldest = hardenedIntentDirectories.values().next().value
if (oldest !== undefined) {
hardenedIntentDirectories.delete(oldest)
}
}
hardenedIntentDirectories.add(directory)
}
return directory
}
function removeTemporaryIntents(directory: string): void {
for (const name of readdirSync(directory)) {
if (name.endsWith('.tmp')) {
rmSync(join(directory, name), { force: true })
}
}
}
function writeIntent(path: string, directory: string, serializedIntent: string): void {
// Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches.
const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
try {
writeFileSync(temporaryPath, serializedIntent, {
encoding: 'utf8',
mode: 0o600
})
fsyncFileSync(temporaryPath)
renameSync(temporaryPath, path)
bestEffortFsyncDirectorySync(directory)
} catch (error) {
rmSync(temporaryPath, { force: true })
throw error
}
}
function intentPath(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope
): string {
const identity = JSON.stringify([
sourceKey,
scope.cloudUserId,
scope.cloudProfileId,
scope.cloudOrganizationId,
scope.apiOrigin
])
const fileName = `${createHash('sha256').update(identity).digest('hex')}.json`
return join(intentDirectory(profileId, userDataPath), fileName)
}
function scopeMatches(left: ArtifactShareScope, right: ArtifactShareScope): boolean {
return (
left.cloudUserId === right.cloudUserId &&
left.cloudProfileId === right.cloudProfileId &&
left.cloudOrganizationId === right.cloudOrganizationId &&
left.apiOrigin === right.apiOrigin
)
}
function isWriteBody(value: unknown): value is ArtifactWriteBody {
if (!value || typeof value !== 'object') {
return false
}
const body = value as Partial<ArtifactWriteBody>
return (
typeof body.content === 'string' &&
(body.contentType === 'text/html' || body.contentType === 'text/markdown') &&
typeof body.fileName === 'string' &&
(body.title === undefined || typeof body.title === 'string')
)
}
function isScope(value: unknown): value is ArtifactShareScope {
if (!value || typeof value !== 'object') {
return false
}
const scope = value as Partial<ArtifactShareScope>
return [
scope.cloudUserId,
scope.cloudProfileId,
scope.cloudOrganizationId,
scope.apiOrigin
].every((field) => typeof field === 'string')
}
function readIntent(path: string): ArtifactCreateIntent {
let size: number
try {
size = statSync(path).size
} catch (error) {
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
}
if (size > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
throw new Error('Artifact create recovery record exceeds the supported size.')
}
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('Artifact create recovery record has an unsupported format.')
}
const intent = parsed as Partial<ArtifactCreateIntent>
if (
intent.version !== 1 ||
typeof intent.sourceKey !== 'string' ||
typeof intent.idempotencyKey !== 'string' ||
!intent.idempotencyKey ||
!isScope(intent.scope) ||
!isWriteBody(intent.body)
) {
throw new Error('Artifact create recovery record has an unsupported format.')
}
return intent as ArtifactCreateIntent
}
export function getArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope
): ArtifactCreateIntent | null {
const path = intentPath(profileId, userDataPath, sourceKey, scope)
if (!existsSync(path)) {
return null
}
const intent = readIntent(path)
if (intent.sourceKey !== sourceKey || !scopeMatches(intent.scope, scope)) {
throw new Error('Artifact create recovery record does not match its storage identity.')
}
return intent
}
export function getOrCreateArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope,
idempotencyKey: string,
body: ArtifactWriteBody
): ArtifactCreateIntent {
const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope)
if (existing) {
return existing
}
const directory = ensureIntentDirectory(profileId, userDataPath)
removeTemporaryIntents(directory)
const pendingCount = readdirSync(directory).filter((name) => name.endsWith('.json')).length
if (pendingCount >= MAX_PENDING_ARTIFACT_CREATES) {
throw new Error('Too many artifact creates are waiting for recovery. Retry an earlier share.')
}
const intent: ArtifactCreateIntent = {
version: 1,
sourceKey,
scope,
idempotencyKey,
body
}
const serializedIntent = JSON.stringify(intent, null, 2)
if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
throw new Error('Artifact create recovery record exceeds the supported size.')
}
writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent)
return intent
}
export function removeArtifactCreateIntent(
profileId: string,
userDataPath: string,
sourceKey: string,
scope: ArtifactShareScope,
expectedIdempotencyKey: string
): void {
const path = intentPath(profileId, userDataPath, sourceKey, scope)
if (!existsSync(path)) {
return
}
let matches = true
try {
matches = readIntent(path).idempotencyKey === expectedIdempotencyKey
} catch {
// An unreadable record cannot be replayed, so a completed mutation may discard it safely.
}
if (matches) {
rmSync(path, { force: true })
bestEffortFsyncDirectorySync(intentDirectory(profileId, userDataPath))
}
}
export function clearArtifactCreateIntents(profileId: string, userDataPath: string): void {
const directory = intentDirectory(profileId, userDataPath)
if (!existsSync(directory)) {
return
}
for (const name of readdirSync(directory)) {
if (name.endsWith('.json') || name.endsWith('.tmp')) {
rmSync(join(directory, name), { force: true })
}
}
bestEffortFsyncDirectorySync(directory)
}
+157 -30
View File
@@ -4,7 +4,17 @@ import type {
ArtifactWriteRequest
} from '../../shared/artifacts'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
import {
artifactRequest,
type ArtifactWriteBody,
artifactWriteBody
} from './artifact-cloud-request'
import {
type ArtifactCreateIntent,
getArtifactCreateIntent,
getOrCreateArtifactCreateIntent,
removeArtifactCreateIntent
} from './artifact-create-intent-store'
import {
type ArtifactShareScope,
getArtifactShareRecord,
@@ -15,6 +25,30 @@ import {
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
type ArtifactCreateOutcome = {
editToken: string
intent: ArtifactCreateIntent
result: ArtifactPublishResult
}
function artifactWriteBodiesMatch(left: ArtifactWriteBody, right: ArtifactWriteBody): boolean {
return (
left.content === right.content &&
left.contentType === right.contentType &&
left.fileName === right.fileName &&
left.title === right.title
)
}
function discardsCreateIntent(error: unknown): boolean {
return (
error instanceof OrcaCloudRequestError &&
error.statusCode >= 400 &&
error.statusCode < 500 &&
![408, 409, 425, 429].includes(error.statusCode)
)
}
type ArtifactPublishAuthContext = {
profileId: string
scope: ArtifactShareScope
@@ -51,7 +85,23 @@ export class ArtifactPublisher {
): Promise<ArtifactListItem> {
return this.runForSource(request.sourceKey, auth, async () => {
auth.assertCurrent()
return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
const pending = getArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
if (pending && !artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
const item = await this.updateExisting(request, token, apiUrl, auth, {
slug: created.result.item.artifact.slug,
editToken: created.editToken
})
this.removeCreateIntent(request, auth, created.intent)
return item
}
this.removeCreateIntent(request, auth, created.intent)
return created.result.item
})
}
@@ -64,6 +114,25 @@ export class ArtifactPublisher {
): Promise<ArtifactPublishResult> {
return this.runForSource(request.sourceKey, auth, async () => {
auth.assertCurrent()
const pending = getArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope
)
if (pending) {
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
if (artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
this.removeCreateIntent(request, auth, created.intent)
return created.result
}
const item = await this.updateExisting(request, token, apiUrl, auth, {
slug: created.result.item.artifact.slug,
editToken: created.editToken
})
this.removeCreateIntent(request, auth, created.intent)
return { change: 'created', item }
}
const record = getArtifactShareRecord(
auth.profileId,
this.userDataPath,
@@ -72,24 +141,8 @@ export class ArtifactPublisher {
)
if (record) {
try {
return await this.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
method: 'PUT',
editToken: record.editToken,
body: artifactWriteBody(request)
})
auth.assertCurrent()
refreshArtifactShareRecordExpiration(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
record,
item.artifact.expiresAt
)
return { change: 'updated', item }
})
const item = await this.updateExisting(request, token, apiUrl, auth, record)
return { change: 'updated', item }
} catch (error) {
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
throw error
@@ -101,7 +154,9 @@ export class ArtifactPublisher {
})
}
}
return this.create(request, token, apiUrl, auth, idempotencyKey)
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, null)
this.removeCreateIntent(request, auth, created.intent)
return created.result
})
}
@@ -121,18 +176,72 @@ export class ArtifactPublisher {
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
}
private updateExisting(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
record: { slug: string; editToken: string }
): Promise<ArtifactListItem> {
return this.runForSlug(record.slug, auth, async () => {
auth.assertCurrent()
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
method: 'PUT',
editToken: record.editToken,
body: artifactWriteBody(request)
})
auth.assertCurrent()
refreshArtifactShareRecordExpiration(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
record,
item.artifact.expiresAt
)
return item
})
}
private async create(
request: ArtifactWriteRequest,
token: string,
apiUrl: string,
auth: ArtifactPublishAuthContext,
idempotencyKey: string
): Promise<ArtifactPublishResult> {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: artifactWriteBody(request),
idempotencyKey
})
idempotencyKey: string,
pending: ArtifactCreateIntent | null
): Promise<ArtifactCreateOutcome> {
const replaying = pending !== null
const intent =
pending ??
getOrCreateArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
idempotencyKey,
artifactWriteBody(request)
)
let response: ArtifactCreateResponse
try {
response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
method: 'POST',
body: intent.body,
idempotencyKey: intent.idempotencyKey
})
} catch (error) {
// A replay may outlive its server receipt, so never abandon a possibly committed artifact.
if (!replaying && discardsCreateIntent(error)) {
removeArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
intent.idempotencyKey
)
}
throw error
}
auth.assertCurrent()
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
slug: response.artifact.slug,
@@ -142,11 +251,29 @@ export class ArtifactPublisher {
...auth.scope
})
return {
change: 'created',
item: { artifact: response.artifact, shareUrl: response.shareUrl }
editToken: response.editToken,
intent,
result: {
change: 'created',
item: { artifact: response.artifact, shareUrl: response.shareUrl }
}
}
}
private removeCreateIntent(
request: ArtifactWriteRequest,
auth: ArtifactPublishAuthContext,
intent: ArtifactCreateIntent
): void {
removeArtifactCreateIntent(
auth.profileId,
this.userDataPath,
request.sourceKey,
auth.scope,
intent.idempotencyKey
)
}
private async runSerialized<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = this.queues.get(key) ?? Promise.resolve()
let release = (): void => {}
@@ -0,0 +1,88 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const fsyncMockState = vi.hoisted(() => ({
directoryDescriptor: -1,
directoryErrorCode: 'EINVAL'
}))
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
closeSync: (descriptor: number) => {
if (descriptor !== fsyncMockState.directoryDescriptor) {
actual.closeSync(descriptor)
}
},
fsyncSync: (descriptor: number) => {
if (descriptor === fsyncMockState.directoryDescriptor) {
throw Object.assign(new Error('directory fsync failed'), {
code: fsyncMockState.directoryErrorCode
})
}
return actual.fsyncSync(descriptor)
},
openSync: (path: string, flags: string | number) =>
actual.statSync(path).isDirectory()
? fsyncMockState.directoryDescriptor
: actual.openSync(path, flags)
}
})
import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file'
import {
clearArtifactCreateIntents,
getOrCreateArtifactCreateIntent
} from './artifact-create-intent-store'
const createdPaths: string[] = []
afterEach(() => {
fsyncMockState.directoryErrorCode = 'EINVAL'
for (const path of createdPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('skips directory fsync on Windows and propagates I/O failures elsewhere', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-eio-'))
createdPaths.push(directory)
fsyncMockState.directoryErrorCode = 'EIO'
const fsyncDirectory = (): void => bestEffortFsyncDirectorySync(directory)
if (process.platform === 'win32') {
expect(fsyncDirectory).not.toThrow()
} else {
expect(fsyncDirectory).toThrow('directory fsync failed')
}
})
it('keeps durable artifact records usable when directory fsync is unsupported', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-'))
createdPaths.push(userDataPath)
const recordPath = join(userDataPath, 'artifact-shares.json')
expect(() => writeDurableSecureJsonFile(recordPath, { ok: true })).not.toThrow()
expect(JSON.parse(readFileSync(recordPath, 'utf8'))).toEqual({ ok: true })
expect(() =>
getOrCreateArtifactCreateIntent(
'local-profile',
userDataPath,
'/repo/report.html',
{
cloudUserId: 'user-a',
cloudProfileId: 'profile-a',
cloudOrganizationId: 'org-a',
apiOrigin: 'https://share.onorca.dev'
},
'key-a',
{ content: 'hello', contentType: 'text/markdown', fileName: 'report.md' }
)
).not.toThrow()
expect(() => clearArtifactCreateIntents('local-profile', userDataPath)).not.toThrow()
})
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFile } from '../../shared/secure-file'
import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file'
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
export type ArtifactShareScope = {
@@ -183,7 +183,7 @@ export function saveArtifactShareRecord(
const records = readRecords(profileId, userDataPath)
records.shares[sourceKey] = { ...record, savedAt: Date.now() }
records.shares = pruneRecords(records.shares, Date.now()).shares
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function refreshArtifactShareRecordExpiration(
@@ -228,7 +228,7 @@ export function removeArtifactShareRecords(
delete records.shares[sourceKey]
}
}
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
}
export function clearArtifactShareRecords(profileId: string, userDataPath: string): void {
@@ -238,7 +238,7 @@ export function clearArtifactShareRecords(profileId: string, userDataPath: strin
} catch {
// Clearing must recover sign-out from an unreadable token index.
}
writeSecureJsonFile(recordPath(profileId, userDataPath), {
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), {
version: 2,
lifecycleGeneration: lifecycleGeneration + 1,
lifecycleNonce: randomUUID(),
@@ -0,0 +1,42 @@
import { Buffer } from 'node:buffer'
export const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
// Two interchangeable Bitbucket Cloud auth shapes: a single access token (Bearer)
// or an email + API token pair (HTTP Basic). Either satisfies `hasAuth`, and both
// may come from env vars or the in-app credential store.
export type BitbucketAuthConfig = {
baseUrl: string
accessToken: string | null
email: string | null
apiToken: string | null
}
export function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
export function getEnvAuthConfig(): BitbucketAuthConfig {
return {
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL,
accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'),
email: envValue('ORCA_BITBUCKET_EMAIL'),
apiToken: envValue('ORCA_BITBUCKET_API_TOKEN')
}
}
export function hasAuth(config: BitbucketAuthConfig): boolean {
return Boolean(config.accessToken || (config.email && config.apiToken))
}
export function authHeaders(config: BitbucketAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.email && config.apiToken) {
const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
+99
View File
@@ -124,6 +124,105 @@ describe('Bitbucket client', () => {
).resolves.toMatchObject({ number: 42 })
})
it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.includes('/statuses/build')) {
return Response.json({ values: [] })
}
return Response.json({
values: [
{ ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'feature/login' } } }
]
})
})
vi.stubGlobal('fetch', fetchMock)
// Why: a merged branch match is history. Returning it made eligibility
// report "a pull request already exists" and blocked the branch's next PR.
await expect(
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
).resolves.toBeNull()
})
it('keeps a DECLINED PR on a feature branch visible', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.includes('/statuses/build')) {
return Response.json({ values: [] })
}
return Response.json({
values: [
{ ...bitbucketPr(9), state: 'DECLINED', source: { branch: { name: 'feature/login' } } }
]
})
})
vi.stubGlobal('fetch', fetchMock)
// Why: only merged matches are hidden. Hiding declined too made a declined
// PR permanently invisible off the default branch, unlike every other provider.
await expect(
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
).resolves.toMatchObject({ number: 9, state: 'closed' })
})
it('returns null instead of querying anonymously when no credential resolves', async () => {
delete process.env.ORCA_BITBUCKET_EMAIL
delete process.env.ORCA_BITBUCKET_API_TOKEN
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
// Why: an unauthenticated query 404s on private repos, which would read as
// "no pull request" and offer Create for a branch that already has one.
await expect(
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
).resolves.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches an explicitly linked DECLINED PR before the branch index', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/pullrequests/7')) {
return Response.json({ ...bitbucketPr(7), state: 'DECLINED' })
}
if (url.includes('/statuses/build')) {
return Response.json({ values: [] })
}
return new Response('branch index unavailable', { status: 503 })
})
vi.stubGlobal('fetch', fetchMock)
await expect(
getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 7)
).resolves.toMatchObject({ number: 7, state: 'closed' })
expect(fetchMock.mock.calls.map(([url]) => url)).not.toContainEqual(
expect.stringContaining('/pullrequests?')
)
})
it('falls back to the branch index when a linked PR number is stale', async () => {
const branchPR = bitbucketPr(7)
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/pullrequests/99')) {
return new Response('not found', { status: 404 })
}
if (url.includes('/statuses/build')) {
return Response.json({ values: [] })
}
return Response.json({
values: [{ ...branchPR, source: { ...branchPR.source, branch: { name: 'feature/login' } } }]
})
})
vi.stubGlobal('fetch', fetchMock)
await expect(
getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 99)
).resolves.toMatchObject({ number: 7, state: 'open' })
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
expect.stringContaining('/pullrequests/99'),
expect.stringContaining('/pullrequests?'),
expect.stringContaining('/statuses/build')
])
})
it('fetches a branch pull request and commit build status', async () => {
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.includes('/statuses/build')) {
+86 -70
View File
@@ -1,4 +1,3 @@
import { Buffer } from 'node:buffer'
import type { CheckStatus } from '../../shared/types'
import {
deriveBitbucketBuildStatus,
@@ -8,25 +7,26 @@ import {
type RawBitbucketBuildStatus,
type RawBitbucketPullRequest
} from './pull-request-mappers'
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref'
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
import { authHeaders, getEnvAuthConfig, hasAuth } from './bitbucket-auth-config'
import { accountNameFromUser, fetchBitbucketUser } from './user-request'
import {
getStoredBitbucketCredentialError,
getStoredBitbucketMetadata,
hasStoredBitbucketCredential,
loadStoredBitbucketSecret
} from './credential-store'
import { resolveBitbucketAuthConfig, storedAuthConfig } from './resolve-auth'
const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
const REQUEST_TIMEOUT_MS = 5000
const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const
type BitbucketAuthConfig = {
baseUrl: string
accessToken: string | null
email: string | null
apiToken: string | null
}
export type BitbucketAuthStatus = {
configured: boolean
authenticated: boolean
@@ -38,42 +38,16 @@ type RequestOptions = {
timeoutMs?: number
}
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function getAuthConfig(): BitbucketAuthConfig {
return {
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL,
accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'),
email: envValue('ORCA_BITBUCKET_EMAIL'),
apiToken: envValue('ORCA_BITBUCKET_API_TOKEN')
}
}
function hasAuth(config: BitbucketAuthConfig): boolean {
return Boolean(config.accessToken || (config.email && config.apiToken))
}
function authHeaders(config: BitbucketAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.email && config.apiToken) {
const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
function isStringArray(value: string | readonly string[]): value is readonly string[] {
return Array.isArray(value)
}
function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string {
const config = getAuthConfig()
const base = config.baseUrl.replace(/\/+$/, '')
function apiUrl(
baseUrl: string,
path: string,
searchParams?: RequestOptions['searchParams']
): string {
const base = baseUrl.replace(/\/+$/, '')
const url = new URL(`${base}${path}`)
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
@@ -95,11 +69,24 @@ async function requestJson<T>(
// Why: the existing-review lookup behind Create must distinguish a real
// transport/auth failure from an accepted "no PR". When true, a failed request
// throws instead of collapsing to null so callers never report false not_found.
throwOnFailure = false
throwOnFailure = false,
// Why: a linked PR number can be stale (deleted PR, wrong repo). A 404 there
// must fall through to the branch lookup rather than throw and hide the
// branch's real review, so only that caller opts into it.
notFoundIsNull = false
): Promise<T | null> {
const config = getAuthConfig()
const config = resolveBitbucketAuthConfig()
// Why: a denied keychain prompt leaves no usable credential. Issuing the
// request anyway gets a 404 on private repos, which reads as "no pull
// request" and offers Create for a branch that already has one.
if (!hasAuth(config)) {
if (throwOnFailure) {
throw new Error('Bitbucket request failed: no usable credential')
}
return null
}
try {
const response = await fetch(apiUrl(path, options.searchParams), {
const response = await fetch(apiUrl(config.baseUrl, path, options.searchParams), {
headers: {
Accept: 'application/json',
...authHeaders(config)
@@ -108,6 +95,9 @@ async function requestJson<T>(
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
if (response.status === 404 && notFoundIsNull) {
return null
}
if (throwOnFailure) {
throw new Error(`Bitbucket request failed: HTTP ${response.status}`)
}
@@ -157,21 +147,37 @@ async function normalizePullRequest(
return mapBitbucketPullRequest(raw, status)
}
// Never decrypts. Env credentials are checked live; a stored credential is
// revalidated only when its secret already sits in memory from an earlier API
// call, and otherwise trusted from plaintext metadata — decrypting here would
// prompt for keychain access every time Settings opens.
export async function getBitbucketAuthStatus(): Promise<BitbucketAuthStatus> {
const config = getAuthConfig()
if (!hasAuth(config)) {
return { configured: false, authenticated: false, account: null }
const env = getEnvAuthConfig()
if (hasAuth(env)) {
const user = await fetchBitbucketUser(env)
return {
configured: true,
authenticated: user !== null,
account: accountNameFromUser(user)
}
}
const user = await requestJson<{
username?: string | null
display_name?: string | null
account_id?: string | null
}>('/user', { timeoutMs: 4000 })
return {
configured: true,
authenticated: user !== null,
account: user?.username ?? user?.display_name ?? user?.account_id ?? null
const metadata = getStoredBitbucketMetadata()
if (metadata && hasStoredBitbucketCredential()) {
if (getStoredBitbucketCredentialError()) {
return { configured: true, authenticated: false, account: metadata.account }
}
const cached = loadStoredBitbucketSecret()
if (!cached) {
return { configured: true, authenticated: true, account: metadata.account }
}
const user = await fetchBitbucketUser(storedAuthConfig(metadata, cached))
return {
configured: true,
authenticated: user !== null,
account: accountNameFromUser(user) ?? metadata.account
}
}
return { configured: false, authenticated: false, account: null }
}
export async function getBitbucketPullRequest(
@@ -216,6 +222,18 @@ export async function getBitbucketPullRequestForBranch(
return null
}
if (typeof linkedPRNumber === 'number') {
const raw = await requestJson<RawBitbucketPullRequest>(
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`,
{},
throwOnFailure,
true
)
if (raw) {
return normalizePullRequest(repo, raw)
}
}
if (branchName) {
const query = [
`source.branch.name = "${escapeBitbucketQueryString(branchName)}"`,
@@ -235,10 +253,16 @@ export async function getBitbucketPullRequestForBranch(
)
const raw = list?.values?.[0]
if (raw) {
// Why (#9171): discard a non-open implicit branch match on the repo
// default branch and fall through to the linked-number fallback below.
const state = mapBitbucketPullRequestState(raw.state)
// Why: a merged PR we only matched by branch name is history, not review
// context (GitHub's isMergedImplicitPR rule). Keeping it reported "a pull
// request already exists" and blocked the branch's next PR. Scoped to
// merged so a declined PR stays visible off the default branch, matching
// every other provider; the linked lookup above already returned early
// for an explicitly linked review.
const isMergedImplicitMatch = state === 'merged'
const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({
state: mapBitbucketPullRequestState(raw.state),
state,
reviewNumber: raw.id ?? null,
linkedReviewNumber: linkedPRNumber,
branchName,
@@ -246,21 +270,13 @@ export async function getBitbucketPullRequestForBranch(
connectionId,
localGitOptions: getHostedReviewLocalGitOptions(options)
})
if (!hideOnDefaultBranch) {
if (!isMergedImplicitMatch && !hideOnDefaultBranch) {
return normalizePullRequest(repo, raw)
}
}
}
if (typeof linkedPRNumber !== 'number') {
return null
}
const raw = await requestJson<RawBitbucketPullRequest>(
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`,
{},
throwOnFailure
)
return raw ? normalizePullRequest(repo, raw) : null
return null
}
/**
@@ -0,0 +1,163 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
let tempHome = ''
async function loadModule() {
vi.resetModules()
vi.doMock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf-8')
}
}))
vi.doMock('node:os', async () => {
const actual = await vi.importActual<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
})
return import('./credential-connection')
}
beforeEach(() => {
process.env = { ...OLD_ENV }
for (const key of [
'ORCA_BITBUCKET_ACCESS_TOKEN',
'ORCA_BITBUCKET_EMAIL',
'ORCA_BITBUCKET_API_TOKEN',
'ORCA_BITBUCKET_API_BASE_URL'
]) {
delete process.env[key]
}
tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-conn-'))
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
})
describe('Bitbucket credential connection', () => {
it('verifies credentials before saving and reports a stored connection', async () => {
const conn = await loadModule()
globalThis.fetch = vi.fn(async () =>
Response.json({ username: 'ada' })
) as unknown as typeof fetch
await expect(
conn.connectBitbucket({
authMode: 'basic',
email: 'ada@example.com',
apiToken: 'tok'
})
).resolves.toEqual({ ok: true, account: 'ada' })
expect(conn.getBitbucketConnectionStatus()).toEqual({
configured: true,
source: 'stored',
account: 'ada',
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null
})
})
it('sends Basic auth built from the entered email and API token', async () => {
const conn = await loadModule()
const fetchSpy = vi.fn(async () => Response.json({ username: 'ada' }))
globalThis.fetch = fetchSpy as unknown as typeof fetch
await conn.connectBitbucket({ authMode: 'basic', email: 'ada@example.com', apiToken: 'tok' })
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.bitbucket.org/2.0/user')
expect((init.headers as Record<string, string>).Authorization).toBe(
`Basic ${Buffer.from('ada@example.com:tok').toString('base64')}`
)
})
it('rejects credentials that fail the /user check without saving them', async () => {
const conn = await loadModule()
globalThis.fetch = vi.fn(
async () => new Response(null, { status: 401 })
) as unknown as typeof fetch
const result = await conn.connectBitbucket({
authMode: 'token',
accessToken: 'bad'
})
expect(result.ok).toBe(false)
expect(conn.getBitbucketConnectionStatus().source).toBe('none')
})
it('rejects an incomplete basic-auth credential before making a request', async () => {
const conn = await loadModule()
const fetchSpy = vi.fn()
globalThis.fetch = fetchSpy as unknown as typeof fetch
const result = await conn.connectBitbucket({
authMode: 'basic',
email: 'ada@example.com'
})
expect(result.ok).toBe(false)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('reports environment variables as the source and takes precedence over stored creds', async () => {
const conn = await loadModule()
globalThis.fetch = vi.fn(async () =>
Response.json({ username: 'ada' })
) as unknown as typeof fetch
await conn.connectBitbucket({
authMode: 'basic',
email: 'ada@example.com',
apiToken: 'tok'
})
process.env.ORCA_BITBUCKET_ACCESS_TOKEN = 'env-token'
expect(conn.getBitbucketConnectionStatus()).toMatchObject({
configured: true,
source: 'environment',
authMode: 'token'
})
})
it('lets an env API base URL override the stored one without env credentials', async () => {
const conn = await loadModule()
globalThis.fetch = vi.fn(async () =>
Response.json({ username: 'ada' })
) as unknown as typeof fetch
await conn.connectBitbucket({
authMode: 'basic',
email: 'ada@example.com',
apiToken: 'tok',
baseUrl: 'https://stored.example.com/2.0'
})
// Only the base URL is in the env, so `hasAuth(env)` is false — precedence
// is per-setting, not all-or-nothing.
process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://env.example.com/2.0'
const { resolveBitbucketAuthConfig } = await import('./resolve-auth')
expect(resolveBitbucketAuthConfig().baseUrl).toBe('https://env.example.com/2.0')
})
it('clears the stored connection on disconnect', async () => {
const conn = await loadModule()
globalThis.fetch = vi.fn(async () =>
Response.json({ username: 'ada' })
) as unknown as typeof fetch
await conn.connectBitbucket({
authMode: 'basic',
email: 'ada@example.com',
apiToken: 'tok'
})
conn.disconnectBitbucket()
expect(conn.getBitbucketConnectionStatus().source).toBe('none')
})
})
+110
View File
@@ -0,0 +1,110 @@
import {
DEFAULT_API_BASE_URL,
envValue,
getEnvAuthConfig,
hasAuth,
type BitbucketAuthConfig
} from './bitbucket-auth-config'
import { accountNameFromUser, fetchBitbucketUser } from './user-request'
import {
clearStoredBitbucketCredential,
getStoredBitbucketMetadata,
hasStoredBitbucketCredential,
saveBitbucketCredential
} from './credential-store'
import type {
BitbucketConnectArgs,
BitbucketConnectResult,
BitbucketConnectionStatus
} from '../../shared/bitbucket-credentials'
export type { BitbucketConnectArgs, BitbucketConnectResult, BitbucketConnectionStatus }
const VERIFY_TIMEOUT_MS = 6000
function normalize(value: string | null | undefined): string | null {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : null
}
function buildCandidateConfig(input: BitbucketConnectArgs): BitbucketAuthConfig {
return {
baseUrl: normalize(input.baseUrl) ?? DEFAULT_API_BASE_URL,
accessToken: input.authMode === 'token' ? normalize(input.accessToken) : null,
email: input.authMode === 'basic' ? normalize(input.email) : null,
apiToken: input.authMode === 'basic' ? normalize(input.apiToken) : null
}
}
// Verifying against `/user` before persisting keeps the stored "connected
// account" honest and lets the dialog reject a dead token inline.
export async function connectBitbucket(
input: BitbucketConnectArgs
): Promise<BitbucketConnectResult> {
const config = buildCandidateConfig(input)
if (!hasAuth(config)) {
return {
ok: false,
error:
input.authMode === 'token'
? 'Enter an access token.'
: 'Enter both an email and an API token.'
}
}
const user = await fetchBitbucketUser(config, VERIFY_TIMEOUT_MS)
if (!user) {
return {
ok: false,
error: 'Could not authenticate with Bitbucket. Check the credentials and try again.'
}
}
const account = accountNameFromUser(user)
saveBitbucketCredential({
authMode: input.authMode,
email: config.email,
baseUrl: normalize(input.baseUrl),
account,
accessToken: config.accessToken,
apiToken: config.apiToken
})
return { ok: true, account }
}
export function disconnectBitbucket(): void {
clearStoredBitbucketCredential()
}
// Reads env vars and plaintext metadata only — never decrypts — so the Settings
// card can call it on every open without a keychain prompt.
export function getBitbucketConnectionStatus(): BitbucketConnectionStatus {
const env = getEnvAuthConfig()
if (hasAuth(env)) {
return {
configured: true,
source: 'environment',
account: null,
authMode: env.accessToken ? 'token' : 'basic',
email: env.email,
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL')
}
}
if (hasStoredBitbucketCredential()) {
const metadata = getStoredBitbucketMetadata()
return {
configured: true,
source: 'stored',
account: metadata?.account ?? null,
authMode: metadata?.authMode ?? null,
email: metadata?.email ?? null,
baseUrl: metadata?.baseUrl ?? null
}
}
return {
configured: false,
source: 'none',
account: null,
authMode: null,
email: null,
baseUrl: null
}
}
+218
View File
@@ -0,0 +1,218 @@
import { existsSync, mkdtempSync, statSync } from 'node:fs'
import type * as Fs from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let tempHome = ''
const decryptStringMock = vi.fn((value: Buffer) => value.toString('utf-8'))
async function loadStore(options: { unlinkError?: NodeJS.ErrnoException } = {}) {
vi.resetModules()
vi.doMock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: decryptStringMock
}
}))
vi.doMock('node:os', async () => {
const actual = await vi.importActual<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
})
if (options.unlinkError) {
// Why mocked: chmod-based failure injection is not portable — Windows has no
// POSIX modes and a root/elevated runner can unlink through a 0500 directory.
const error = options.unlinkError
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof Fs>('node:fs')
return {
...actual,
unlinkSync: () => {
throw error
}
}
})
}
return import('./credential-store')
}
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-store-'))
decryptStringMock.mockClear()
})
describe('Bitbucket credential store', () => {
it('persists plaintext metadata and an encrypted secret, then reads them back', async () => {
const store = await loadStore()
store.saveBitbucketCredential({
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null,
account: 'ada',
accessToken: null,
apiToken: 'secret-token'
})
expect(store.hasStoredBitbucketCredential()).toBe(true)
expect(store.getStoredBitbucketMetadata()).toMatchObject({
authMode: 'basic',
email: 'ada@example.com',
account: 'ada'
})
expect(store.loadStoredBitbucketSecret()).toEqual({
accessToken: null,
apiToken: 'secret-token'
})
})
it('writes both credential files 0600', async () => {
const store = await loadStore()
store.saveBitbucketCredential({
authMode: 'token',
email: null,
baseUrl: null,
account: 'dev',
accessToken: 'access-secret',
apiToken: null
})
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600)
}
})
it('re-tightens permissions when overwriting an existing credential', async () => {
const store = await loadStore()
const save = (account: string): void =>
store.saveBitbucketCredential({
authMode: 'token',
email: null,
baseUrl: null,
account,
accessToken: 'access-secret',
apiToken: null
})
save('first')
// Why: writeFileSync's mode is ignored for an existing file, so a loosened
// credential would stay world-readable across a reconnect.
const { chmodSync } = await import('node:fs')
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
chmodSync(join(tempHome, '.orca', file), 0o644)
}
save('second')
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600)
}
})
it('does not decrypt for metadata/status reads — only on a forced secret load', async () => {
const store = await loadStore()
store.saveBitbucketCredential({
authMode: 'token',
email: null,
baseUrl: 'https://api.bitbucket.org/2.0',
account: 'dev',
accessToken: 'access-secret',
apiToken: null
})
// Simulate a fresh session: caches cleared, files still on disk.
store._resetBitbucketCredentialCache()
expect(store.getStoredBitbucketMetadata()?.account).toBe('dev')
expect(store.hasStoredBitbucketCredential()).toBe(true)
expect(decryptStringMock).not.toHaveBeenCalled()
// Without force, the secret stays unread.
expect(store.loadStoredBitbucketSecret()).toBeNull()
expect(decryptStringMock).not.toHaveBeenCalled()
// Forcing the load decrypts exactly once, then caches.
expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({
accessToken: 'access-secret',
apiToken: null
})
expect(decryptStringMock).toHaveBeenCalledTimes(1)
expect(store.loadStoredBitbucketSecret()).not.toBeNull()
expect(decryptStringMock).toHaveBeenCalledTimes(1)
})
it('rejects non-string fields from hand-edited metadata and secret files', async () => {
const store = await loadStore()
const { writeFileSync } = await import('node:fs')
store.saveBitbucketCredential({
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null,
account: 'ada',
accessToken: null,
apiToken: 'secret-token'
})
writeFileSync(
join(tempHome, '.orca', 'bitbucket-credential.json'),
JSON.stringify({ version: 1, authMode: 'basic', email: { evil: true }, account: 42 })
)
writeFileSync(
join(tempHome, '.orca', 'bitbucket-credential.enc'),
JSON.stringify({ accessToken: ['nope'], apiToken: 7 })
)
store._resetBitbucketCredentialCache()
expect(store.getStoredBitbucketMetadata()).toMatchObject({ email: null, account: null })
expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({
accessToken: null,
apiToken: null
})
})
it('clears both files and in-memory state on disconnect', async () => {
const store = await loadStore()
store.saveBitbucketCredential({
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null,
account: 'ada',
accessToken: null,
apiToken: 'secret-token'
})
store.clearStoredBitbucketCredential()
expect(store.hasStoredBitbucketCredential()).toBe(false)
expect(store.getStoredBitbucketMetadata()).toBeNull()
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(false)
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.json'))).toBe(false)
})
it('surfaces a non-ENOENT delete failure instead of silently keeping the files', async () => {
const denied: NodeJS.ErrnoException = Object.assign(new Error('permission denied'), {
code: 'EACCES'
})
const store = await loadStore({ unlinkError: denied })
store.saveBitbucketCredential({
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null,
account: 'ada',
accessToken: null,
apiToken: 'secret-token'
})
// Clearing memory while the files survive would resurrect the credential on
// the next launch, so the failure has to reach the caller.
expect(() => store.clearStoredBitbucketCredential()).toThrow(/permission denied/)
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(true)
})
it('ignores a missing file on disconnect', async () => {
const missing: NodeJS.ErrnoException = Object.assign(new Error('no such file'), {
code: 'ENOENT'
})
const store = await loadStore({ unlinkError: missing })
expect(() => store.clearStoredBitbucketCredential()).not.toThrow()
})
})
+202
View File
@@ -0,0 +1,202 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken,
restrictCredentialFileToOwner,
writeEncryptedCredential
} from '../integration-credential-file'
import type { BitbucketAuthMode } from '../../shared/bitbucket-credentials'
// Why: the secret stays encrypted via safeStorage while this metadata stays
// plaintext, so status reads render the connected account without decrypting —
// otherwise every Settings open would trigger an OS keychain prompt.
export type BitbucketStoredMetadata = {
version: 1
authMode: BitbucketAuthMode
email: string | null
baseUrl: string | null
account: string | null
updatedAt: string
}
export type BitbucketStoredSecret = {
accessToken: string | null
apiToken: string | null
}
export type BitbucketCredentialSaveInput = {
authMode: BitbucketAuthMode
email: string | null
baseUrl: string | null
account: string | null
accessToken: string | null
apiToken: string | null
}
let cachedMetadata: BitbucketStoredMetadata | null = null
let metadataLoadedFromDisk = false
let cachedSecret: BitbucketStoredSecret | null = null
let credentialError: string | null = null
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getMetadataPath(): string {
return join(getOrcaDir(), 'bitbucket-credential.json')
}
function getSecretPath(): string {
return join(getOrcaDir(), 'bitbucket-credential.enc')
}
function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
// Why: hand-edited or truncated JSON must not put a non-string into an auth
// header, so every field is narrowed rather than trusted.
function asOptionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function readMetadataFromDisk(): BitbucketStoredMetadata | null {
const path = getMetadataPath()
if (!existsSync(path)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<BitbucketStoredMetadata>
if (parsed.authMode !== 'token' && parsed.authMode !== 'basic') {
return null
}
return {
version: 1,
authMode: parsed.authMode,
email: asOptionalString(parsed.email),
baseUrl: asOptionalString(parsed.baseUrl),
account: asOptionalString(parsed.account),
updatedAt: asOptionalString(parsed.updatedAt) ?? ''
}
} catch {
return null
}
}
export function getStoredBitbucketMetadata(): BitbucketStoredMetadata | null {
if (!metadataLoadedFromDisk) {
cachedMetadata = readMetadataFromDisk()
metadataLoadedFromDisk = true
}
return cachedMetadata
}
// Cheap "is a credential saved?" check: metadata present and a non-empty secret
// file. Never decrypts, so it is safe on every status poll.
export function hasStoredBitbucketCredential(): boolean {
return getStoredBitbucketMetadata() !== null && credentialFileHasContent(getSecretPath())
}
export function getStoredBitbucketCredentialError(): string | null {
return credentialError
}
// Cache-first; only touches the keychain when `force` is set and the secret is
// not already in memory (mirrors Linear's `loadToken({ force })`). Throws
// CredentialDecryptionError when ciphertext cannot be decrypted.
export function loadStoredBitbucketSecret(
options: { force?: boolean } = {}
): BitbucketStoredSecret | null {
if (cachedSecret !== null) {
return cachedSecret
}
if (!options.force) {
return null
}
const path = getSecretPath()
if (!existsSync(path)) {
return null
}
try {
const token = readStoredCredentialToken('Bitbucket', readFileSync(path))
if (!token) {
return null
}
const parsed = JSON.parse(token) as Partial<BitbucketStoredSecret>
cachedSecret = {
accessToken: asOptionalString(parsed.accessToken),
apiToken: asOptionalString(parsed.apiToken)
}
credentialError = null
return cachedSecret
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialError = error.message
throw error
}
return null
}
}
export function saveBitbucketCredential(input: BitbucketCredentialSaveInput): void {
ensureOrcaDir()
const secret: BitbucketStoredSecret = {
accessToken: input.accessToken,
apiToken: input.apiToken
}
writeEncryptedCredential('Bitbucket', getSecretPath(), JSON.stringify(secret))
const metadata: BitbucketStoredMetadata = {
version: 1,
authMode: input.authMode,
email: input.email,
baseUrl: input.baseUrl,
account: input.account,
updatedAt: new Date().toISOString()
}
writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
restrictCredentialFileToOwner(getMetadataPath())
cachedMetadata = metadata
metadataLoadedFromDisk = true
cachedSecret = secret
credentialError = null
}
// Why: swallowing a non-ENOENT unlink failure would clear memory while the
// files survive, so the credential silently returns on the next launch.
export function clearStoredBitbucketCredential(): void {
try {
for (const path of [getSecretPath(), getMetadataPath()]) {
try {
unlinkSync(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
}
} finally {
// Why: on a partial delete the caches must still be dropped, or the session
// keeps authenticating with a credential the user just disconnected.
cachedMetadata = null
metadataLoadedFromDisk = true
cachedSecret = null
credentialError = null
}
}
/** @internal - tests need a clean in-memory cache between cases. */
export function _resetBitbucketCredentialCache(): void {
cachedMetadata = null
metadataLoadedFromDisk = false
cachedSecret = null
credentialError = null
}
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createBitbucketPullRequest,
isBitbucketReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetBitbucketRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
const CREATE_INPUT = {
provider: 'bitbucket',
base: 'main',
head: 'feature/login',
title: 'Add login'
} as const
function createdPullRequestResponse(): Response {
return Response.json({
id: 42,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
}
describe('Bitbucket pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://bitbucket.org/team/repo.git\n',
stderr: ''
})
_resetBitbucketRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetBitbucketRepoRefCache()
})
it('posts the Bitbucket source/destination create body to the repository endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.origin).toBe('https://api.bitbucket.org')
expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests')
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toBe('Bearer bb-token')
expect(JSON.parse(String(requestInit.body))).toEqual({
title: 'Add login',
description: '',
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
return createdPullRequestResponse()
})
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toEqual({
ok: true,
number: 42,
url: 'https://bitbucket.org/team/repo/pull-requests/42'
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('uses the stored credential when no environment variable is set', async () => {
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
const fetchMock = vi.fn(async () => createdPullRequestResponse())
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT)
// No env var and no stored credential: fail closed rather than POST anonymously.
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('ignores a draft request rather than dead-ending a hidden persisted default', async () => {
const fetchMock = vi.fn(async () => createdPullRequestResponse())
globalThis.fetch = fetchMock as unknown as typeof fetch
// Why: the composer hides the Draft toggle for Bitbucket, so a `true` here
// is an unreachable persisted default the user cannot clear.
await expect(
createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true })
).resolves.toMatchObject({ ok: true, number: 42 })
expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty(
'draft'
)
})
it('reports authenticated only when a credential resolves', async () => {
expect(isBitbucketReviewCreationAuthenticated()).toBe(true)
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
expect(isBitbucketReviewCreationAuthenticated()).toBe(false)
})
it('maps a duplicate-branch rejection to already_exists with the existing review', async () => {
let call = 0
const fetchMock = vi.fn(async () => {
call += 1
if (call === 1) {
return Response.json(
{ error: { message: 'There is already a pull request for this branch.' } },
{ status: 400 }
)
}
return Response.json({
values: [
{
id: 7,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
}
]
})
})
globalThis.fetch = fetchMock as unknown as typeof fetch
expect(await createBitbucketPullRequest('/repo', CREATE_INPUT)).toMatchObject({
ok: false,
code: 'already_exists',
existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' }
})
})
it('maps a 401 to auth_required pointing at both credential paths', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
) as unknown as typeof fetch
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT)
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
expect(!result.ok && result.error).toContain('Settings')
})
it('refuses a non-Bitbucket remote', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://github.com/team/repo.git\n',
stderr: ''
})
globalThis.fetch = vi.fn() as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toMatchObject({
ok: false,
code: 'unsupported_provider'
})
})
})
+221
View File
@@ -0,0 +1,221 @@
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
HostedReviewApiRequestError,
requestHostedReviewJson
} from '../source-control/hosted-review-api-request'
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
import { authHeaders, hasAuth } from './bitbucket-auth-config'
import { resolveBitbucketAuthConfig } from './resolve-auth'
import { getBitbucketPullRequestForBranch } from './client'
import { mapBitbucketPullRequest, type RawBitbucketPullRequest } from './pull-request-mappers'
import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
export function isBitbucketReviewCreationAuthenticated(): boolean {
return hasAuth(resolveBitbucketAuthConfig())
}
function encodedRepoPath(repo: BitbucketRepoRef): string {
return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}`
}
function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyCreateError(error: unknown): CreateHostedReviewResult {
const message = apiErrorMessage(error)
if (message) {
console.warn('createBitbucketPullRequest failed:', message)
}
const lower = message.toLowerCase()
const status = error instanceof HostedReviewApiRequestError ? error.status : null
if (status === 401 || status === 403 || lower.includes('unauthorized')) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Bitbucket is not authenticated. Next step: connect Bitbucket in Settings > Integrations, or set ORCA_BITBUCKET_* in this environment.'
}
}
// Bitbucket answers a duplicate source branch with 400 plus this phrasing
// rather than a 409, so the text is the only reliable signal.
if (lower.includes('already exists') || lower.includes('already a pull request')) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.'
}
}
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
return {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
}
if (status === 400 || status === 422) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Bitbucket rejected the pull request. Check the base branch and branch state, then try again.'
}
}
if (status === 404) {
return {
ok: false,
code: 'validation',
error:
'Create PR failed: Bitbucket could not find the repository. Check that the credential has write access to it.'
}
}
return {
ok: false,
code: 'unknown',
error: 'Create PR failed: Bitbucket could not create the pull request. Try again in a moment.'
}
}
async function findExistingPullRequest(
repoPath: string,
head: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<{ number: number; url: string } | null> {
const existing = await getBitbucketPullRequestForBranch(
repoPath,
head,
null,
connectionId,
options
)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createBitbucketPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'bitbucket') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
const config = resolveBitbucketAuthConfig()
if (!hasAuth(config)) {
return {
ok: false,
code: 'auth_required',
error:
'Create PR failed: Bitbucket is not connected. Next step: connect Bitbucket in Settings > Integrations.'
}
}
const repo = await getBitbucketRepoRef(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a Bitbucket remote.'
}
}
const base = normalizeHostedReviewBaseRef(input.base)
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
const title = input.title.trim()
if (!base || !head || !title) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: base branch, head branch, and title are required.'
}
}
if (head.toLowerCase() === base.toLowerCase()) {
return {
ok: false,
code: 'validation',
error: 'Create PR failed: choose a different base branch before creating a pull request.'
}
}
// Why: Bitbucket Cloud has no draft pull requests and the composer hides the
// toggle, so `draft` here can only be an unreachable persisted default —
// rejecting it would dead-end the user with no control to clear.
const description =
input.useTemplate && !input.body?.trim()
? await readHostedPullRequestTemplate(repoPath, connectionId)
: (input.body ?? '')
const requestBody = {
title,
description,
source: { branch: { name: head } },
destination: { branch: { name: base } }
}
try {
const raw = await requestHostedReviewJson<RawBitbucketPullRequest>(
new URL(
`${config.baseUrl.replace(/\/+$/, '')}/repositories/${encodedRepoPath(repo)}/pullrequests`
),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(config)
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapBitbucketPullRequest(raw, 'neutral')
if (created) {
return { ok: true, number: created.number, url: created.url }
}
const found = await findExistingPullRequest(repoPath, head, connectionId, options).catch(
() => null
)
return found
? { ok: true, ...found }
: {
ok: false,
code: 'unknown_completion',
error: 'PR creation may have completed. Refreshing branch review state...'
}
} catch (error) {
const classified = classifyCreateError(error)
if (
!classified.ok &&
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
) {
const existing = await findExistingPullRequest(repoPath, head, connectionId, options).catch(
() => null
)
if (existing) {
return {
ok: false,
code: 'already_exists',
error: 'A pull request already exists for this branch.',
existingReview: existing
}
}
}
return classified
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
DEFAULT_API_BASE_URL,
envValue,
getEnvAuthConfig,
hasAuth,
type BitbucketAuthConfig
} from './bitbucket-auth-config'
import {
getStoredBitbucketMetadata,
loadStoredBitbucketSecret,
type BitbucketStoredMetadata,
type BitbucketStoredSecret
} from './credential-store'
export function storedAuthConfig(
metadata: BitbucketStoredMetadata,
secret: BitbucketStoredSecret
): BitbucketAuthConfig {
return {
// Why: an explicit ORCA_BITBUCKET_API_BASE_URL still wins even when the
// credential itself is stored — env precedence is per-setting, not all-or-nothing.
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? metadata.baseUrl ?? DEFAULT_API_BASE_URL,
accessToken: metadata.authMode === 'token' ? secret.accessToken : null,
email: metadata.authMode === 'basic' ? metadata.email : null,
apiToken: metadata.authMode === 'basic' ? secret.apiToken : null
}
}
// Env vars win over in-app credentials so existing headless/SSH setups keep
// working unchanged. The stored secret is decrypted lazily and only here, on a
// real API call — never on a status read.
export function resolveBitbucketAuthConfig(): BitbucketAuthConfig {
const env = getEnvAuthConfig()
if (hasAuth(env)) {
return env
}
const metadata = getStoredBitbucketMetadata()
if (!metadata) {
return env
}
try {
const secret = loadStoredBitbucketSecret({ force: true })
return secret ? storedAuthConfig(metadata, secret) : env
} catch {
// Decryption denied or unavailable: fall through as unauthenticated.
return env
}
}
@@ -0,0 +1,127 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Why: opening Settings renders the Bitbucket card, which runs the preflight
// status (getBitbucketAuthStatus) and the card's status IPC
// (getBitbucketConnectionStatus). Neither may decrypt the stored secret, or the
// OS would prompt to unlock the keychain on every Settings open. These tests
// simulate a cold session (files on disk, memory cache empty).
const decryptSpy = vi.fn((value: Buffer) => value.toString('utf-8'))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
let tempHome = ''
vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() }))
async function loadModules() {
vi.resetModules()
vi.doMock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: decryptSpy
}
}))
vi.doMock('node:os', async () => {
const actual = await vi.importActual<typeof Os>('node:os')
return { ...actual, homedir: () => tempHome }
})
// Stub repo detection so the PR fetch reaches the auth-resolution path.
vi.doMock('./repository-ref', () => ({
getBitbucketRepoRef: async () => ({ workspace: 'acme', repoSlug: 'app' })
}))
const store = await import('./credential-store')
const client = await import('./client')
const connection = await import('./credential-connection')
return { store, client, connection }
}
beforeEach(() => {
process.env = { ...OLD_ENV }
for (const key of [
'ORCA_BITBUCKET_ACCESS_TOKEN',
'ORCA_BITBUCKET_EMAIL',
'ORCA_BITBUCKET_API_TOKEN',
'ORCA_BITBUCKET_API_BASE_URL'
]) {
delete process.env[key]
}
tempHome = mkdtempSync(join(tmpdir(), 'orca-bb-nodecrypt-'))
decryptSpy.mockClear()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
})
const STORED_BASIC_CREDENTIAL = {
authMode: 'basic',
email: 'ada@example.com',
baseUrl: null,
account: 'ada',
accessToken: null,
apiToken: 'secret-token'
} as const
describe('Bitbucket status reads never decrypt the stored secret', () => {
it('renders connected state from plaintext metadata without touching the keychain', async () => {
const { store, client, connection } = await loadModules()
const fetchSpy = vi.fn()
globalThis.fetch = fetchSpy as unknown as typeof fetch
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
// Simulate relaunch: secret is on disk but the memory cache is cold, so a
// decrypt here would surface to the user as a keychain prompt.
store._resetBitbucketCredentialCache()
decryptSpy.mockClear()
const auth = await client.getBitbucketAuthStatus()
const status = connection.getBitbucketConnectionStatus()
expect(auth).toEqual({ configured: true, authenticated: true, account: 'ada' })
expect(status).toMatchObject({ source: 'stored', account: 'ada', authMode: 'basic' })
expect(decryptSpy).not.toHaveBeenCalled()
// A stored credential is trusted from metadata rather than revalidated.
expect(fetchSpy).not.toHaveBeenCalled()
})
it('stays cold across repeated status reads, then decrypts once for a real API call', async () => {
const { store, client, connection } = await loadModules()
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
store._resetBitbucketCredentialCache()
decryptSpy.mockClear()
for (let i = 0; i < 3; i += 1) {
await client.getBitbucketAuthStatus()
connection.getBitbucketConnectionStatus()
}
expect(decryptSpy).not.toHaveBeenCalled()
globalThis.fetch = vi.fn(async () => Response.json({ values: [] })) as unknown as typeof fetch
await client.getBitbucketPullRequest('/repo', 1, null)
expect(decryptSpy).toHaveBeenCalledTimes(1)
})
it('revalidates a stored credential once its secret is already warm in memory', async () => {
const { store, client } = await loadModules()
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
decryptSpy.mockClear()
// Saving leaves the secret cached, so status can check /user for free.
const fetchSpy = vi.fn(async () => new Response(null, { status: 401 }))
globalThis.fetch = fetchSpy as unknown as typeof fetch
await expect(client.getBitbucketAuthStatus()).resolves.toEqual({
configured: true,
authenticated: false,
account: 'ada'
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(decryptSpy).not.toHaveBeenCalled()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { authHeaders, type BitbucketAuthConfig } from './bitbucket-auth-config'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
const USER_REQUEST_TIMEOUT_MS = 4000
export type RawBitbucketUser = {
username?: string | null
display_name?: string | null
account_id?: string | null
}
export function accountNameFromUser(user: RawBitbucketUser | null): string | null {
return user?.username ?? user?.display_name ?? user?.account_id ?? null
}
// Shared by live env-var status checks and by connect-time verification, so a
// credential is proven against `/user` before it is ever persisted.
export async function fetchBitbucketUser(
config: BitbucketAuthConfig,
timeoutMs: number = USER_REQUEST_TIMEOUT_MS
): Promise<RawBitbucketUser | null> {
try {
const base = config.baseUrl.replace(/\/+$/, '')
const response = await fetch(`${base}/user`, {
headers: {
Accept: 'application/json',
...authHeaders(config)
},
signal: AbortSignal.timeout(timeoutMs)
})
if (!response.ok) {
await cancelUnreadResponseBody(response)
return null
}
return (await response.json()) as RawBitbucketUser
} catch {
return null
}
}
+7 -33
View File
@@ -1,22 +1,10 @@
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits'
import type { CdpCommandSender } from './snapshot-engine'
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
function getUtf8ByteLengthForCodePoint(codePoint: number): number {
if (codePoint <= 0x7f) {
return 1
}
if (codePoint <= 0x7ff) {
return 2
}
if (codePoint <= 0xffff) {
return 3
}
return 4
}
export function splitBrowserTextInsertionChunks(
text: string,
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
@@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks(
return
}
let currentStart = 0
let currentBytes = 0
let index = 0
while (index < text.length) {
const codePoint = text.codePointAt(index) ?? 0
const codeUnitLength = codePoint > 0xffff ? 2 : 1
const nextIndex = index + codeUnitLength
const characterBytes = getUtf8ByteLengthForCodePoint(codePoint)
if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) {
yield text.slice(currentStart, index)
currentStart = index
currentBytes = 0
}
currentBytes += characterBytes
index = nextIndex
}
if (currentStart < text.length) {
yield text.slice(currentStart)
let startIndex = 0
while (startIndex < text.length) {
const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax)
yield text.slice(startIndex, endIndex)
startIndex = endIndex
}
}
@@ -68,8 +43,7 @@ export async function insertTextThroughCdp(
let chunk = chunks.next()
while (!chunk.done) {
await sender('Input.insertText', { text: chunk.value })
// Why: browser automation text can be paste-sized; yielding keeps the main
// process responsive between bounded CDP payloads.
// Keep paste-sized insertion from monopolizing the main process.
chunk = chunks.next()
if (options?.yieldBetweenChunks !== false && !chunk.done) {
await yieldToEventLoop()
+25 -122
View File
@@ -1,20 +1,12 @@
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClaudeUsagePersistedState } from './types'
import type * as Scanner from './scanner'
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
@@ -23,29 +15,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
vi.mock('./scanner', async (importOriginal) => ({
...(await importOriginal<typeof Scanner>()),
scanClaudeUsageFiles: vi.fn()
@@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { scanClaudeUsageFiles } from './scanner'
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore({
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
return {
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
getAllWorktreeMeta: () => ({})
}
}
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore(createBackingStore())
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
schemaVersion: 1,
@@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initClaudeUsagePath()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanClaudeUsageFiles).mockReset()
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
processedFiles: [],
@@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('defaults a null legacy opt-in while invalidating the cache', () => {
writeFileSync(
join(tempUserData, 'orca-claude-usage.json'),
JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } })
)
const store = new ClaudeUsageStore(createBackingStore())
expect(store.getScanState().enabled).toBe(false)
})
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
const store = createStoreWithState({
sessions: [
@@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => {
expect(refreshMock).toHaveBeenCalledWith(false)
})
it('persists setEnabled via async durable write without leaving tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.setEnabled(true)
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persisted = JSON.parse(
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
)
expect(persisted.scanState.enabled).toBe(true)
// Pretty-print preserved for human inspection of the analytics cache.
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: ClaudeUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('persists a successful refresh with one full-cache write', async () => {
it('adapts Claude scans to pretty-printed cache persistence', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
@@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => {
await store.refresh(true)
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
).toMatchObject({
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], [])
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
})
+37 -187
View File
@@ -1,14 +1,11 @@
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
import { app } from 'electron'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScanState,
ClaudeUsageScope,
ClaudeUsageSessionRow,
ClaudeUsageSnapshot,
@@ -16,16 +13,15 @@ import type {
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
// after fork reclaim (#8006).
const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
@@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState {
}
}
function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState {
if (state.schemaVersion === SCHEMA_VERSION) {
return state
}
// Scanner changes invalidate totals, but preserving enabled keeps existing tracking on.
const defaults = getDefaultState()
return {
...defaults,
scanState: {
...defaults.scanState,
enabled: state.scanState.enabled ?? defaults.scanState.enabled
}
}
}
export function initClaudeUsagePath(): void {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
@@ -288,120 +299,22 @@ function estimateCostUsd(
)
}
function getRangeCutoff(range: ClaudeUsageRange): string | null {
if (range === 'all') {
return null
}
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
const now = new Date()
now.setHours(0, 0, 0, 0)
now.setDate(now.getDate() - (days - 1))
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getLocalDay(timestamp: string): string | null {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
return null
}
const year = parsed.getFullYear()
const month = String(parsed.getMonth() + 1).padStart(2, '0')
const day = String(parsed.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
JSON.stringify({
repoId,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
)
)
.sort()
return JSON.stringify(rows)
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
constructor(store: Store) {
this.store = store
this.state = this.load()
}
private load(): ClaudeUsagePersistedState {
try {
const usageFile = getClaudeUsageFile()
if (!existsSync(usageFile)) {
return getDefaultState()
}
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
if (parsed.schemaVersion !== SCHEMA_VERSION) {
// Why: scanner semantics affect persisted totals, so old Claude caches
// must be rebuilt after parser/source changes instead of reused briefly.
// Preserve scanState.enabled so existing users keep tracking on across
// schema bumps; the next refresh will repopulate the analytics.
const defaults = getDefaultState()
return {
...defaults,
scanState: {
...defaults.scanState,
enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled
}
}
}
return {
...getDefaultState(),
...parsed,
scanState: {
...getDefaultState().scanState,
...parsed.scanState
}
}
} catch (error) {
// Why: Claude usage is a local analytics feature, not primary workspace
// state. A corrupt cache should degrade to a fresh rebuild instead of
// preventing Orca from booting, but we leave the file on disk for debugging.
console.error('[claude-usage] Failed to load persisted state, starting fresh:', error)
return getDefaultState()
}
}
private writeToDisk(): Promise<void> {
// Pretty-print preserved: humans inspect this analytics cache on disk.
return this.writer.write(() => JSON.stringify(this.state, null, 2))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
this.state.scanState.enabled = enabled
await this.writeToDisk()
return this.getScanState()
}
getScanState(): ClaudeUsageScanState {
return {
...this.state.scanState,
isScanning: this.scanPromise !== null,
hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
}
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
'hasAnyClaudeData'
> {
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
super(store, {
logTag: '[claude-usage]',
resolveCacheFile: getClaudeUsageFile,
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyClaudeData',
jsonIndent: 2,
scan: scanClaudeUsageFiles
})
}
getSnapshot(
@@ -419,63 +332,6 @@ export class ClaudeUsageStore {
}
}
async refresh(force = false): Promise<ClaudeUsageScanState> {
if (!this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
await this.runScan()
return this.getScanState()
}
private async runScan(): Promise<void> {
if (this.scanPromise) {
await this.scanPromise
return
}
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanClaudeUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
})()
await this.scanPromise
}
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
await this.refresh(false)
return this.buildSummary(scope, range)
@@ -843,7 +699,7 @@ export class ClaudeUsageStore {
}
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
@@ -856,12 +712,12 @@ export class ClaudeUsageStore {
}
private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getRangeCutoff(range)
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
// Why: daily aggregates use local calendar days, so session filtering has
// to use the same conversion or the sessions table/counts can disagree
// with the chart around UTC day boundaries.
const day = getLocalDay(session.lastTimestamp)
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
@@ -883,10 +739,4 @@ export class ClaudeUsageStore {
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
)
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+70 -34
View File
@@ -17,18 +17,34 @@ vi.mock('electron', () => ({
import type { SFTPWrapper } from 'ssh2'
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
import { ClaudeHookService } from './hook-service'
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
const STATUSLINE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh'
const OPENCLAUDE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME)
const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME)
type TestHook = { command: string; args?: string[] }
function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) {
return matcher(hook.command) || hook.args?.some(matcher) === true
}
describe('getWindowsManagedLifecycleHook', () => {
it('resolves the managed script from the runtime Windows profile', () => {
const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd'
const hook = getWindowsManagedLifecycleHook(scriptPath)
expect(hook.args?.[0]).toBe('--headless')
expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i)
expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd')
expect(hook.args).not.toContain(scriptPath)
})
})
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
@@ -162,18 +178,27 @@ describe('ClaudeHookService.install', () => {
AWS_REGION: 'us-west-2'
}
})
const legacyCommands = legacy.hooks.Stop.flatMap(
(definition: { hooks: { command: string }[] }) =>
definition.hooks.map((hook) => hook.command)
const legacyHooks = legacy.hooks.Stop.flatMap(
(definition: { hooks: TestHook[] }) => definition.hooks
)
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true)
expect(legacyHooks.map((hook: TestHook) => hook.command)).toContain(
'/usr/local/bin/user-hook'
)
const managedHook = legacyHooks.find((hook: TestHook) =>
hasManagedCommand(hook, isClaudeManagedCommand)
)
expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/'))
expect(
legacyCommands.some((command: string) =>
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand))
).toBe(true)
expect(
legacyHooks.some((hook: TestHook) =>
hook.command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
)
).toBe(false)
expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true)
expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe(
true
)
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).toContain('DEVIN_PROJECT_DIR')
@@ -194,7 +219,13 @@ describe('ClaudeHookService.install', () => {
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
) as { statusLine?: { type: string; command: string } }
expect(settings.statusLine?.type).toBe('command')
expect(settings.statusLine?.command).toContain('claude-statusline')
expect(settings.statusLine?.command).toContain(
'"$HOME/.orca/agent-hooks/claude-statusline.cmd"'
)
expect(settings.statusLine?.command).toContain(
'"$HOME/.orca/agent-hooks/claude-statusline.sh"'
)
expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/'))
const script = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME),
@@ -307,12 +338,8 @@ describe('ClaudeHookService.install', () => {
}
})
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
// expands the raw path before invoking the managed .cmd.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command to survive spaces in the profile path (#6078)',
'runs portable managed hooks through headless exec form',
() => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
vi.stubEnv('HOME', tmpHome)
@@ -322,11 +349,26 @@ describe('ClaudeHookService.install', () => {
const settings = JSON.parse(
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
) as { hooks: Record<string, { hooks: TestHook[] }[]> }
const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME)
const runtimeScriptPath = join(
'%USERPROFILE%',
'.orca',
'agent-hooks',
CLAUDE_SCRIPT_FILE_NAME
)
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
expect(hook).toEqual({
type: 'command',
command: join(system32, 'conhost.exe'),
args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
timeout: 10
})
expect(hook.args).not.toContain(scriptPath)
}
} finally {
vi.unstubAllEnvs()
@@ -335,9 +377,6 @@ describe('ClaudeHookService.install', () => {
}
)
// Why: the launcher must stay PowerShell-encoded for Git Bash, but the hook
// POST inside the .cmd should use curl.exe so each hook spawns one
// interpreter, not two. Posting via a second PowerShell was the slow path.
it.skipIf(process.platform !== 'win32')(
'posts from the managed .cmd via curl.exe, not a second PowerShell',
() => {
@@ -372,11 +411,7 @@ describe('ClaudeHookService.installRemote', () => {
const settings = fs.files.get('/home/dev/.claude/settings.json')
expect(settings).toBeTruthy()
const parsed = JSON.parse(settings!)
// Why: every load-bearing event must be present and point at the
// remote-shaped script path with the guarded launcher applied. Drift in
// any of these is a real bug — Claude
// Code rejects unknown shapes silently and the agent-hooks pipeline
// goes dark.
// Why: Claude silently rejects drifted hook shapes, so assert every load-bearing event.
for (const event of [
// Why: SessionStart is the only signal a resumed/idle session ever emits;
// without it the sidebar row waits for the first prompt (STA-3386).
@@ -394,8 +429,8 @@ describe('ClaudeHookService.installRemote', () => {
]) {
expect(parsed.hooks[event]).toBeTruthy()
const cmd = parsed.hooks[event][0].hooks[0].command as string
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(cmd).toMatch(/^if \[ -f /)
expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"')
expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
}
// Managed script body
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
@@ -487,9 +522,9 @@ describe('OpenClaudeHookService-compatible install', () => {
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = parsed.hooks[event][0].hooks[0].command as string
expect(isOpenClaudeManagedCommand(command)).toBe(true)
if (process.platform !== 'win32') {
expect(command).toMatch(/^if \[ -f /)
}
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"')
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
expect(command).not.toContain(tmpHome.replaceAll('\\', '/'))
}
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
@@ -518,7 +553,8 @@ describe('OpenClaudeHookService-compatible install', () => {
})
const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!)
const command = parsed.hooks.StopFailure[0].hooks[0].command as string
expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude')
})
})
+10 -7
View File
@@ -2,6 +2,7 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
buildWindowsAgentHookCurlPostCommand,
readHooksJson,
writeHooksJson,
@@ -29,6 +30,7 @@ import {
getManagedScriptFileName,
getConfigPath,
getManagedCommand,
getManagedLifecycleHook,
getManagedScriptPath,
getPosixManagedScriptFileName,
getRemoteConfigPath,
@@ -37,6 +39,7 @@ import {
getStatusLineScriptFileName,
getStatusLineScriptPath,
getStatusLineSlotState,
hasSameManagedHookInvocation,
removeManagedHooks,
removeManagedStatusLine,
type ClaudeCompatibleHookSettings
@@ -140,7 +143,7 @@ export class ClaudeHookService {
}
// Why: report partial registration instead of a false installed state.
const command = getManagedCommand(scriptPath)
const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings)
const missing: string[] = []
let presentCount = 0
for (const event of CLAUDE_EVENTS) {
@@ -148,7 +151,7 @@ export class ClaudeHookService {
? config.hooks![event.eventName]!
: []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
(definition.hooks ?? []).some((hook) => hasSameManagedHookInvocation(hook, expectedHook))
)
if (hasCommand) {
presentCount += 1
@@ -198,10 +201,10 @@ export class ClaudeHookService {
}
}
const command = getManagedCommand(scriptPath)
const hook = getManagedLifecycleHook(scriptPath, this.options.settings)
let nextConfig = applyManagedHooks(
config,
command,
hook,
getManagedScriptFileName(this.options.settings)
)
writeManagedScript(
@@ -259,9 +262,9 @@ export class ClaudeHookService {
}
}
// Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs.
const command = getRemoteManagedCommand(remoteScriptPath)
const nextConfig = applyManagedHooks(config, command, remoteScriptFileName)
// Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home.
const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath))
const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName)
// Why: write scripts before settings to avoid settings pointing to missing scripts.
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
+54 -11
View File
@@ -1,30 +1,34 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { basename, extname, join, win32 } from 'node:path'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
isPlainObject,
MANAGED_HOOK_TIMEOUT_SECONDS,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsGitBashHookCommand,
type HookCommandConfig,
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
export type ClaudeCompatibleHookSettings = {
configDirName: '.claude' | '.openclaude'
scriptBaseName: 'claude-hook' | 'openclaude-hook'
supportsExecHookArgs: boolean
}
export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
configDirName: '.claude',
scriptBaseName: 'claude-hook'
scriptBaseName: 'claude-hook',
supportsExecHookArgs: true
}
export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
configDirName: '.openclaude',
scriptBaseName: 'openclaude-hook'
scriptBaseName: 'openclaude-hook',
supportsExecHookArgs: false
}
export const CLAUDE_EVENTS = [
@@ -105,18 +109,57 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
}
export function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsGitBashHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
const scriptFileName = basename(scriptPath)
const extension = extname(scriptFileName)
return wrapRuntimeHomeHookCommand(
extension ? scriptFileName.slice(0, -extension.length) : scriptFileName
)
}
export function getManagedLifecycleHook(
scriptPath: string,
settings = CLAUDE_HOOK_SETTINGS
): HookCommandConfig {
if (process.platform !== 'win32' || !settings.supportsExecHookArgs) {
return buildManagedCommandHook(getManagedCommand(scriptPath))
}
return getWindowsManagedLifecycleHook(scriptPath)
}
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
const runtimeScriptPath = win32.join(
'%USERPROFILE%',
'.orca',
'agent-hooks',
win32.basename(scriptPath)
)
// Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console.
return {
type: 'command',
command: win32.join(system32, 'conhost.exe'),
args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
}
}
export function hasSameManagedHookInvocation(
actual: HookCommandConfig,
expected: HookCommandConfig
): boolean {
return (
actual.command === expected.command &&
JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? [])
)
}
export function getRemoteManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
return getManagedCommand(scriptPath)
}
export function applyManagedHooks(
config: HooksConfig,
command: string,
hook: HookCommandConfig,
scriptFileName = getManagedScriptFileName()
): HooksConfig {
const nextHooks = { ...config.hooks }
@@ -127,7 +170,7 @@ export function applyManagedHooks(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [buildManagedCommandHook(command)]
hooks: [hook]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
+1 -1
View File
@@ -489,7 +489,7 @@ describe('CliInstaller', () => {
const registryReader = new WindowsUserPathRegistryReader({
platform: 'win32',
registryLoader: async () => ({
HK: { CU: 0x80000001 },
HK: { CU: 0x80000001, LM: 0x80000002 },
getRegistryKey: () => ({
Path: { name: 'Path', type: 2, value: registryPath }
})

Some files were not shown because too many files have changed in this diff Show More