diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 08ce81990ad..b1fcaf1f36f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,10 +7,22 @@ on: description: Ref to check out (defaults to the calling workflow's ref) required: false type: string + workflow_dispatch: + inputs: + ref: + description: Ref to check out (defaults to the workflow ref) + required: false + type: string + schedule: + # Why: GitHub cron uses UTC; these slots map to 10am and 3pm + # America/Phoenix for the default-branch E2E run. + - cron: '0 17,22 * * *' jobs: - e2e: + build: + name: build e2e app runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout @@ -18,9 +30,77 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} - # Why: electron-vite's globalSetup invokes a full app build, which - # compiles native modules via node-gyp. Mirrors the install step in - # pr.yml's verify job so E2E doesn't hit missing-toolchain errors. + # Why: the E2E build compiles native modules via node-gyp. Mirrors the + # install step in pr.yml's verify job so E2E doesn't hit missing-toolchain + # errors. + - name: Install native build tools + run: sudo apt-get update && sudo apt-get install -y build-essential python3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + # Why: this job runs the same pnpm install path as pr.yml's verify + # job, so it needs the same pinned node-gyp override to avoid pnpm's + # broken bundled gyp_main.py on Linux. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: building once avoids five parallel electron-vite builds inside + # Playwright globalSetup, which otherwise contends for CPU/RAM on OSS + # runners before the sharded tests even start. + - name: Build Electron app for E2E + run: npx electron-vite build --mode e2e + + - name: Upload E2E build output + uses: actions/upload-artifact@v4 + with: + name: e2e-build-out + path: out/ + retention-days: 1 + if-no-files-found: error + + e2e: + name: e2e ${{ matrix.shard_name }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - shard: '1/5' + shard_name: 1-of-5 + - shard: '2/5' + shard_name: 2-of-5 + - shard: '3/5' + shard_name: 3-of-5 + - shard: '4/5' + shard_name: 4-of-5 + - shard: '5/5' + shard_name: 5-of-5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + # Why: pnpm install rebuilds native modules, and those postinstall + # scripts still need the Linux toolchain even though this shard reuses + # the prebuilt Electron output. - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 @@ -55,8 +135,21 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run E2E tests - run: xvfb-run --auto-servernum pnpm run test:e2e + - name: Download E2E build output + uses: actions/download-artifact@v4 + with: + name: e2e-build-out + path: out/ + + # Why: the Electron suite is wall-clock constrained on OSS runners, but + # raising workers on one runner OOMs. Sharding keeps each VM at 4 workers + # while splitting the 91-test headless suite across separate runners. + # SKIP_BUILD makes Playwright globalSetup reuse the single build job's + # artifact instead of starting five concurrent electron-vite builds. + # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron + # launches but never creates a BrowserWindow. + - name: Run E2E tests (${{ matrix.shard_name }}) + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }} # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without @@ -65,7 +158,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-traces + name: playwright-traces-${{ matrix.shard_name }} path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 22b332c1a21..cfee0fe14cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -88,12 +88,3 @@ jobs: - name: Build run: pnpm build - - e2e: - uses: ./.github/workflows/e2e.yml - # Why: check out the PR head directly instead of refs/pull/N/merge. - # The merge ref doesn't exist when the PR has conflicts or GitHub - # hasn't computed the merge commit yet, causing checkout to fail - # for reasons unrelated to the code under test. - with: - ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 4851157bc4e..aa72a96e82a 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -441,112 +441,6 @@ jobs: --generate-notes \ --prerelease="$is_rc" - # Why: the rc.7 incident did not match the original publisher hypothesis. - # Poll both authenticated release state and anonymous atom visibility during - # real cuts so the next diagnosis has proof instead of timing guesses. - monitor-release: - needs: - - cut - - create-release - if: needs.cut.outputs.should_release == 'true' - runs-on: ubuntu-latest - continue-on-error: true - permissions: - contents: read - steps: - - name: Poll release visibility during cut - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.cut.outputs.tag }} - run: | - set -euo pipefail - log_path="$RUNNER_TEMP/release-cut-instrumentation-${TAG}.jsonl" - release_error="$RUNNER_TEMP/release-api-error.txt" - : >"$log_path" - - for attempt in $(seq 1 60); do - timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - release_json="" - release_error_body="" - if releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>"$release_error")"; then - # Why: releases-by-tag cannot inspect the draft we are monitoring. - if ! release_json="$(jq -e -c --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 then .[0] else empty end - ' <<<"$releases_json")"; then - release_json="" - release_error_body="Release $TAG was not found in the draft-aware releases list." - fi - else - release_error_body="$(cat "$release_error")" - fi - - atom_body="$(curl -fsSL --retry 2 --max-time 10 "https://github.com/$GITHUB_REPOSITORY/releases.atom" 2>/dev/null || true)" - tag_in_anon_atom=false - if printf '%s' "$atom_body" | grep -Fq "/releases/tag/$TAG"; then - tag_in_anon_atom=true - fi - - if [[ -n "$release_json" ]]; then - jq -c \ - --arg timestamp "$timestamp" \ - --argjson tag_in_anon_atom "$tag_in_anon_atom" \ - --arg run_id "$GITHUB_RUN_ID" \ - --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ - '{ - timestamp: $timestamp, - tag: .tag_name, - draft: .draft, - prerelease: .prerelease, - published_at: .published_at, - author: .author.login, - asset_count: (.assets | length), - assets: (.assets | map({ - name, - state, - size, - created_at, - updated_at, - uploader: .uploader.login - })), - tag_in_anon_atom: $tag_in_anon_atom, - workflow_run: { - id: $run_id, - attempt: $run_attempt - } - }' <<<"$release_json" >>"$log_path" - - draft="$(jq -r '.draft' <<<"$release_json")" - if [[ "$draft" == "false" && "$tag_in_anon_atom" == "true" ]]; then - break - fi - else - jq -nc \ - --arg timestamp "$timestamp" \ - --arg tag "$TAG" \ - --arg error "$release_error_body" \ - --argjson tag_in_anon_atom "$tag_in_anon_atom" \ - '{ - timestamp: $timestamp, - tag: $tag, - release_api_error: $error, - tag_in_anon_atom: $tag_in_anon_atom - }' >>"$log_path" - fi - - sleep 30 - done - - echo "Wrote $log_path" - - - name: Upload release visibility log - if: always() - uses: actions/upload-artifact@v4 - with: - name: release-cut-instrumentation-${{ needs.cut.outputs.tag }} - path: ${{ runner.temp }}/release-cut-instrumentation-*.jsonl - if-no-files-found: warn - # Why: E2E runs alongside the release for visibility (failures surface as a # red check on the tag), but is NOT in `publish-release`'s needs list. # Releases already take a while and the suite is already a required check diff --git a/.gitignore b/.gitignore index eca545b5345..ce5220229ce 100644 --- a/.gitignore +++ b/.gitignore @@ -70,11 +70,18 @@ design-docs/ # Machine-local agent hook endpoint files may contain auth tokens. /agent-hooks/ -# Local-only design/planning docs (not checked in) -docs/*.md -docs/**/*.md -!docs/README*.md -!docs/**/README*.md +# Local-only design/planning docs (not checked in). +# Durable docs must live in one of the allow-listed locations below. +docs/** +!docs/ +!docs/assets/ +!docs/assets/** +!docs/readme/ +!docs/readme/** +!docs/reference/ +!docs/reference/** +!docs/STYLEGUIDE.md +!docs/configurable-open-in-menu.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index f0979fb2159..f8f0f80f5ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ All changes must consider the SSH use case. Don't assume local-only execution. ## GitHub CLI Usage Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows. +Never commit PR evidence images; attach them to the PR conversation instead. ## Type Declarations: Prefer `.ts` Over `.d.ts` diff --git a/README.md b/README.md index 5dfd755e0c2..3266f273b63 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@

- English · 中文 · 日本語 · 한국어 · Español + English · 中文 · 日本語 · 한국어 · Español

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*). ## Features -- **No login required** — Bring your own Claude Code or Codex subscription. +- **No login required** — Bring your own Claude Code, Codex, or Grok subscription. - **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly. - **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance. - **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca. diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 968901f50d6..430d70801bb 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -36,7 +36,7 @@ for (const platform of PLATFORMS) { outfile: join(outDir, 'relay.js'), // Native addons cannot be bundled — they must exist on the remote host. // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher'], + external: ['node-pty', '@parcel/watcher', 'better-sqlite3'], sourcemap: false, minify: true, define: { diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs new file mode 100644 index 00000000000..c4502752152 --- /dev/null +++ b/config/scripts/ensure-native-runtime.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { release } from 'node:os' +import { basename, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const scriptPath = fileURLToPath(import.meta.url) +const projectDir = resolve(dirname(scriptPath), '../..') +const runtime = readRuntimeArg() + +const NATIVE_MODULES = ['better-sqlite3', 'node-pty'] +const CHILD_CHECK_FLAG = '--check-only' + +if (process.argv.includes(CHILD_CHECK_FLAG)) { + const failures = collectNativeModuleFailures() + if (failures.length > 0) { + for (const failure of failures) { + console.error(`${failure.moduleName}: ${failure.message}`) + } + process.exit(1) + } + process.exit(0) +} + +if (runtime === 'node') { + ensureNodeRuntime() +} else if (runtime === 'electron') { + ensureElectronRuntime() +} else { + console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron') + process.exit(2) +} + +function readRuntimeArg() { + const inline = process.argv.find((arg) => arg.startsWith('--runtime=')) + if (inline) { + return inline.slice('--runtime='.length) + } + + const runtimeIndex = process.argv.indexOf('--runtime') + if (runtimeIndex >= 0) { + return process.argv[runtimeIndex + 1] + } + + return null +} + +function ensureNodeRuntime() { + const initial = runCurrentProcessCheck() + if (initial.ok) { + return + } + + const failedModules = initial.failures.map((failure) => failure.moduleName) + console.warn( + `[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.` + ) + printCheckError(initial) + runPnpm(['rebuild', ...failedModules]) + + const final = runCurrentProcessCheck() + if (!final.ok) { + console.error( + `[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.` + ) + printCheckError(final) + process.exit(1) + } +} + +function ensureElectronRuntime() { + const initial = runElectronCheck() + if (initial.ok) { + return + } + + console.warn( + `[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.` + ) + printCheckError(initial) + runNodeScript(['config/scripts/rebuild-native-deps.mjs']) + + const final = runElectronCheck() + if (!final.ok) { + console.error( + `[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.` + ) + printCheckError(final) + process.exit(1) + } +} + +function runCurrentProcessCheck() { + const failures = collectNativeModuleFailures() + if (failures.length === 0) { + return { ok: true, failures } + } + return { ok: false, failures } +} + +function runElectronCheck() { + let electronExecutable + try { + electronExecutable = require('electron') + } catch (error) { + return { ok: false, error } + } + + const result = spawnSync(electronExecutable, [scriptPath, CHILD_CHECK_FLAG], { + cwd: projectDir, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1' + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + return { + ok: result.status === 0, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + error: result.error + } +} + +function collectNativeModuleFailures() { + const failures = [] + for (const moduleName of NATIVE_MODULES) { + try { + loadNativeModule(moduleName) + } catch (cause) { + failures.push({ moduleName, message: formatError(cause), cause }) + } + } + return failures +} + +function loadNativeModule(moduleName) { + if (moduleName === 'better-sqlite3') { + const Database = require(moduleName) + // Why: better-sqlite3 defers loading its .node binding until Database is + // constructed, so a plain require() misses Node ABI mismatches. + const db = new Database(':memory:') + db.close() + return + } + + if (moduleName === 'node-pty') { + loadNodePtyNativeModule() + return + } + + require(moduleName) +} + +function loadNodePtyNativeModule() { + require('node-pty') + + const { loadNativeModule } = require('node-pty/lib/utils') + const nativeName = getNodePtyNativeModuleName() + // Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a + // terminal is created, so require('node-pty') alone can miss ABI mismatches. + loadNativeModule(nativeName) +} + +function getNodePtyNativeModuleName() { + if (process.platform !== 'win32') { + return 'pty' + } + + return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty' +} + +function getWindowsBuildNumber() { + const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release()) + return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 +} + +function runPnpm(args) { + const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + const result = spawnSync(command, args, { + cwd: projectDir, + stdio: 'inherit', + shell: false + }) + + if (result.error || result.status !== 0) { + console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`) + if (result.error) { + console.error(formatError(result.error)) + } + process.exit(result.status ?? 1) + } +} + +function runNodeScript(args) { + const result = spawnSync(process.execPath, args, { + cwd: projectDir, + stdio: 'inherit' + }) + + if (result.error || result.status !== 0) { + console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`) + if (result.error) { + console.error(formatError(result.error)) + } + process.exit(result.status ?? 1) + } +} + +function printCheckError(result) { + for (const failure of result.failures ?? []) { + console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`) + } + if (result.error) { + console.warn(`[native-runtime] ${formatError(result.error)}`) + } + if (result.stderr?.trim()) { + console.warn(result.stderr.trim()) + } + if (result.stdout?.trim()) { + console.warn(result.stdout.trim()) + } + if ( + result.status != null && + !result.error && + !result.stderr?.trim() && + !result.stdout?.trim() && + result.status !== 0 + ) { + console.warn(`[native-runtime] Native check exited with status ${result.status}.`) + } +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error) +} + +function formatRuntimeLabel(value) { + if (value === 'electron') { + return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim() + } + return `Node ${process.versions.node}` +} diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index c3ac1b049a5..8530461fa1d 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -1,6 +1,14 @@ -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' @@ -14,6 +22,162 @@ delete process.env.ELECTRON_RUN_AS_NODE const require = createRequire(import.meta.url) const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const STABLE_NAME_FLAG = '--stable-name' +const rawForwardedArgs = process.argv.slice(2) +// Why: keep an escape hatch for tools that key off Electron's stock app name. +// The flag is runner-only and must not leak into Chromium/electron-vite. +const useStableElectronName = + process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG) +const forwardedRaw = rawForwardedArgs.filter((arg) => arg !== STABLE_NAME_FLAG) +if (useStableElectronName) { + process.env.ORCA_DEV_STABLE_NAME = '1' +} + +function readGitValue(args) { + try { + const value = execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() + return value || null + } catch { + return null + } +} + +function lastBranchSegment(value) { + return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value +} + +function formatDevInstanceLabel(branch, worktreeName) { + if (branch && worktreeName) { + if (branch === worktreeName || lastBranchSegment(branch) === worktreeName) { + return worktreeName + } + return `${worktreeName} @ ${branch}` + } + return branch || worktreeName || null +} + +function createDockTitle(branch, label) { + return `Orca: ${branch || label || 'dev'}` +} + +function seedDevInstanceIdentityEnv() { + const branch = + process.env.ORCA_DEV_BRANCH || + readGitValue(['symbolic-ref', '--quiet', '--short', 'HEAD']) || + readGitValue(['rev-parse', '--short', 'HEAD']) + const worktreeName = process.env.ORCA_DEV_WORKTREE_NAME || path.basename(repoRoot) + const label = process.env.ORCA_DEV_INSTANCE_LABEL || formatDevInstanceLabel(branch, worktreeName) + const identitySeed = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot + const dockTitle = process.env.ORCA_DEV_DOCK_TITLE || createDockTitle(branch, label) + + process.env.ORCA_DEV_REPO_ROOT ||= repoRoot + process.env.ORCA_DEV_INSTANCE_KEY ||= identitySeed + if (branch) { + process.env.ORCA_DEV_BRANCH ||= branch + } + if (worktreeName) { + process.env.ORCA_DEV_WORKTREE_NAME ||= worktreeName + } + if (label) { + // Why: parallel `pn dev` runs need a stable origin label for window titles, + // Dock names, and automation sessions without re-running git in Electron. + process.env.ORCA_DEV_INSTANCE_LABEL ||= label + } + process.env.ORCA_DEV_DOCK_TITLE ||= dockTitle +} + +function setPlistValue(plistPath, key, value) { + execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath]) +} + +function sanitizeBundleIdPart(value) { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'dev' + ) +} + +function sanitizeMacAppBundleName(value) { + return ( + Array.from(value, (char) => { + const code = char.charCodeAt(0) + return code < 32 || code === 127 || char === '/' || char === '\\' ? '-' : char + }) + .join('') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) || 'Orca' + ) +} + +function prepareMacDevElectronApp() { + if (process.platform !== 'darwin') { + return + } + + const sourceAppPath = path.join(repoRoot, 'node_modules', 'electron', 'dist', 'Electron.app') + const electronPackagePath = path.join(repoRoot, 'node_modules', 'electron', 'package.json') + if (!existsSync(sourceAppPath)) { + return + } + + let electronVersion = null + try { + electronVersion = JSON.parse(readFileSync(electronPackagePath, 'utf8')).version ?? null + } catch {} + + const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev' + const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot + const bundleLayoutVersion = 'dock-title-app-filename-v2' + const hash = createHash('sha1') + .update( + `${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}` + ) + .digest('hex') + .slice(0, 12) + const distDir = path.join(repoRoot, 'out', 'electron-dev', hash) + // Why: macOS Dock hover uses the bundle's filesystem display name for + // electron-vite's direct binary launch path, even when Info.plist is patched. + const appBundleName = `${sanitizeMacAppBundleName(title)}.app` + const appPath = path.join(distDir, appBundleName) + const markerPath = path.join(distDir, 'orca-dev-electron-app.json') + const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}` + const expectedMarker = JSON.stringify( + { title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion }, + null, + 2 + ) + + if (existsSync(markerPath) && existsSync(appPath)) { + try { + if (readFileSync(markerPath, 'utf8') === expectedMarker) { + process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron') + return + } + } catch {} + } + + rmSync(distDir, { recursive: true, force: true }) + mkdirSync(distDir, { recursive: true }) + cpSync(sourceAppPath, appPath, { recursive: true }) + + const plistPath = path.join(appPath, 'Contents', 'Info.plist') + setPlistValue(plistPath, 'CFBundleName', title) + setPlistValue(plistPath, 'CFBundleDisplayName', title) + setPlistValue(plistPath, 'CFBundleIdentifier', bundleId) + + // Why no re-sign: dev launches execute the copied Electron binary directly, + // and Electron's framework bundle is ambiguous to codesign when deep-signing + // an already-built distribution. Avoid blocking `pn dev` on local signing. + writeFileSync(markerPath, expectedMarker, 'utf8') + process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron') +} function getDevUserDataPath() { if (process.env.ORCA_DEV_USER_DATA_PATH) { @@ -72,6 +236,11 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') { prepareDevCliWrapper() } +seedDevInstanceIdentityEnv() +if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') { + prepareMacDevElectronApp() +} + // Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down // the full child tree without depending on a real electron-vite install. const electronViteCli = @@ -82,7 +251,6 @@ const electronViteCli = // without manual port juggling. Pick a best-effort deterministic port per // worktree; falls back to a probe sweep if the deterministic pick or its // neighbors are busy (multiple worktrees may share a machine). -const forwardedRaw = process.argv.slice(2) function isPortFree(port) { return new Promise((resolve) => { const srv = net.createServer() @@ -131,6 +299,9 @@ const userPassedPort = forwardedRaw.some( // Why: --help/--version exit immediately; binding a probe socket and printing // a debug-port line would be noise. const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version') +if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) { + console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}`) +} let forwardedExtras = [] if (!userPassedPort && !isHelpOrVersion) { const envPortRaw = process.env.REMOTE_DEBUGGING_PORT diff --git a/config/scripts/run-internal-dev-setup.mjs b/config/scripts/run-internal-dev-setup.mjs new file mode 100644 index 00000000000..6bd3733dc0f --- /dev/null +++ b/config/scripts/run-internal-dev-setup.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { accessSync, constants, existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +function isExecutable(filePath, platform, access = accessSync) { + if (platform === 'win32') { + return true + } + + try { + access(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +function quoteWindowsArg(value) { + return `"${value.replace(/"/g, '""')}"` +} + +function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) { + if (platform === 'win32') { + spawn( + env.ComSpec || 'cmd.exe', + ['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`], + { + stdio: 'inherit', + windowsVerbatimArguments: true + } + ) + return + } + + spawn(setupPath, [worktreePath], { + stdio: 'inherit' + }) +} + +export function runInternalDevSetup({ + env = process.env, + cwd = process.cwd(), + platform = process.platform, + exists = existsSync, + access = accessSync, + spawn = spawnSync +} = {}) { + const setupPath = env.ORCA_INTERNAL_DEV_SETUP?.trim() + if (!setupPath || !exists(setupPath) || !isExecutable(setupPath, platform, access)) { + return 0 + } + + // Why: this hook is an optional local accelerator; failures should not block + // creating a worktree or running the normal dependency install. + spawnOptionalSetup(spawn, setupPath, env.ORCA_WORKTREE_PATH || cwd, platform, env) + + return 0 +} + +if (process.argv[1] && resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) { + process.exit(runInternalDevSetup()) +} diff --git a/config/scripts/run-internal-dev-setup.test.mjs b/config/scripts/run-internal-dev-setup.test.mjs new file mode 100644 index 00000000000..b00073156fa --- /dev/null +++ b/config/scripts/run-internal-dev-setup.test.mjs @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { runInternalDevSetup } from './run-internal-dev-setup.mjs' + +describe('runInternalDevSetup', () => { + it('does nothing when ORCA_INTERNAL_DEV_SETUP is unset', () => { + const spawn = vi.fn() + + expect(runInternalDevSetup({ env: {}, exists: vi.fn(), spawn })).toBe(0) + expect(spawn).not.toHaveBeenCalled() + }) + + it('does nothing when the configured setup path does not exist', () => { + const spawn = vi.fn() + + expect( + runInternalDevSetup({ + env: { ORCA_INTERNAL_DEV_SETUP: '/tmp/missing' }, + exists: () => false, + spawn + }) + ).toBe(0) + expect(spawn).not.toHaveBeenCalled() + }) + + it('skips non-executable setup paths on POSIX', () => { + const spawn = vi.fn() + + expect( + runInternalDevSetup({ + env: { ORCA_INTERNAL_DEV_SETUP: '/tmp/setup' }, + platform: 'linux', + exists: () => true, + access: () => { + throw new Error('EACCES') + }, + spawn + }) + ).toBe(0) + expect(spawn).not.toHaveBeenCalled() + }) + + it('runs the optional setup with the worktree path and ignores its exit status', () => { + const spawn = vi.fn(() => ({ status: 1 })) + + expect( + runInternalDevSetup({ + env: { + ORCA_INTERNAL_DEV_SETUP: '/tmp/setup', + ORCA_WORKTREE_PATH: '/tmp/worktree' + }, + platform: 'linux', + exists: () => true, + access: () => undefined, + spawn + }) + ).toBe(0) + expect(spawn).toHaveBeenCalledWith('/tmp/setup', ['/tmp/worktree'], { + stdio: 'inherit' + }) + }) + + it('uses cmd.exe with explicit quoting so .cmd setup shims receive paths with spaces', () => { + const spawn = vi.fn(() => ({ status: 0 })) + + expect( + runInternalDevSetup({ + env: { + ORCA_INTERNAL_DEV_SETUP: 'C:\\tools\\setup.cmd', + ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' + }, + platform: 'win32', + exists: () => true, + spawn + }) + ).toBe(0) + expect(spawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/d', '/s', '/c', 'call "C:\\tools\\setup.cmd" "C:\\repo\\worktree"'], + { + stdio: 'inherit', + windowsVerbatimArguments: true + } + ) + }) +}) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4235c3b8ceb..00000000000 --- a/docs/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Orca CLI Docs - -Keep this folder focused on the durable references for Orca's public CLI and runtime model. - -## Keep These Docs - -- `orca-cli-focused-v1-status.md` - - What is actually implemented and intentionally in scope now. -- `orca-cli-v1-spec.md` - - The public CLI contract, selector grammar, JSON envelope, and command semantics. -- `orca-runtime-layer-design.md` - - Why the runtime layer exists and which boundaries it owns. -- `orca-cli-bundled-distribution.md` - - How the bundled desktop-app distribution and PATH registration model works. - -## Why The Folder Is Small - -Earlier design and implementation work produced several planning and evaluation docs. -Those were useful while the feature was taking shape, but they were intentionally removed -once the implementation converged so future readers are not forced to choose between -multiple overlapping sources of truth. diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index b41f9f61a74..034d5cb23c1 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -57,6 +57,7 @@ For diff status, file-tree decorations, and the changes view, use the git decora | `--git-decoration-renamed` | Renamed | | `--git-decoration-untracked` | Untracked | | `--git-decoration-copied` | Copied | +| `--git-decoration-ignored` | Ignored by git | Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention. diff --git a/docs/assets/issue-1920/fix.png b/docs/assets/issue-1920/fix.png new file mode 100644 index 00000000000..625a12aa372 Binary files /dev/null and b/docs/assets/issue-1920/fix.png differ diff --git a/docs/assets/issue-1920/reproduction.png b/docs/assets/issue-1920/reproduction.png new file mode 100644 index 00000000000..4116b5c15e8 Binary files /dev/null and b/docs/assets/issue-1920/reproduction.png differ diff --git a/docs/claude-auth-runtime.md b/docs/claude-auth-runtime.md deleted file mode 100644 index 2bef762b847..00000000000 --- a/docs/claude-auth-runtime.md +++ /dev/null @@ -1,36 +0,0 @@ -# Claude Runtime Auth Switching - -Orca switches Claude Code accounts by materializing a selected managed account into Claude's shared runtime auth surfaces. The runtime surfaces are: - -- `.claude/.credentials.json` -- macOS scoped Keychain credentials for the active `CLAUDE_CONFIG_DIR` -- macOS legacy `Claude Code-credentials` -- `.claude.json` `oauthAccount` - -## Core Invariants - -1. Never write a user/runtime surface unless Orca can prove it owns the current value on that surface. -2. Treat each credential surface independently. File, scoped Keychain, and legacy Keychain can each be owned, external, missing, or unknown. -3. `oauthAccount` metadata is restored or cleared only when metadata itself matches Orca's last managed value, or when a credential surface has already proved the current runtime state belongs to the managed account being cleaned up. -4. Invalid or unparsable runtime config is unknown, not null. Unknown config must be preserved. -5. Missing managed account records are unknown. Orca clears the active selection but does not mutate runtime auth without account identity proof. -6. Missing managed credentials for an existing account can be cleaned up using the account record identity. Only surfaces whose current credentials match that account are restored or cleared. -7. Read-back of refreshed tokens must evaluate all runtime credential candidates and persist only a single unambiguous managed-account match. - -## Snapshot Policy - -Before entering managed mode from system mode, Orca captures the system-default runtime state. On restore, the snapshot is only applied to surfaces whose current value still equals Orca's managed value, except for missing-managed-credential recovery where account identity is the proof. - -Snapshots are schema-validated before use. Invalid snapshots are deleted and treated as absent. - -When recapturing while the credentials file still equals the managed account, Orca preserves any previous snapshot value for Keychain surfaces that still equal the managed credentials. This prevents a failed restore followed by restart from recapturing managed Keychain values as system defaults. - -## Read-Back Policy - -Claude can refresh OAuth tokens in any runtime credential surface. Orca reads all available candidates, filters out stale or ambiguous matches, then chooses the freshest accepted candidate. Cold-start read-back is conservative: credentials must be newer than the matched managed account. Warm read-back rejects only metadata-proven older credentials, allowing equal-expiry token rotation. - -## Failure Policy - -Keychain reads during snapshot capture must succeed for both active services on macOS; otherwise Orca aborts managed entry. Best-effort Keychain reads are acceptable for token read-back because another surface may still contain a fresh candidate. - -Add-account cleanup must restore/delete the legacy active Keychain item before reporting success. If cleanup fails, the login is treated as failed so Orca does not silently leave the user's legacy Claude state pointing at the captured account. diff --git a/docs/configurable-open-in-menu.md b/docs/configurable-open-in-menu.md new file mode 100644 index 00000000000..9b96c5d36e0 --- /dev/null +++ b/docs/configurable-open-in-menu.md @@ -0,0 +1,126 @@ +# Configurable Open In Menu + +## Current behavior (code-checked) + +- `WorktreeOpenInMenu` renders exactly two entries: `VS Code` and platform file manager (`Finder`/`File Explorer`/`File Manager`). +- `openWorktreePath()` blocks local launches for remote/server context via `isLocalPathOpenBlocked(...)` before IPC. +- Renderer calls `window.api.shell.openInExternalEditor(path)` with one argument only. +- Preload/API types expose `openInExternalEditor(path: string)` only. +- Main IPC `shell:openInExternalEditor` always launches hardcoded `code` (via `resolveCliCommand('code')`), validates absolute+exists path, and returns `{ ok: false, reason: 'not-absolute' | 'not-found' | 'launch-failed' }` on failure. +- Settings persistence currently shallow-merges most fields. Only `notifications` and `telemetry` are deep-merged in main; renderer deep-merges `notifications`, `telemetry`, and `voice` locally. +- `settings:set` returns the merged settings object, but the renderer currently ignores that return value and applies its own optimistic merge. + +## Goal + +Add configurable extra editor launchers (Cursor, Zed, custom) to the worktree sidebar `Open in` submenu while preserving: + +- VS Code as fixed default entry. +- existing local-path blocking for remote/SSH/server contexts. +- existing path validation and failure-to-toast behavior. + +## Non-goals + +- No remote editor launching. +- No command existence checks during settings editing. +- No shell template expansion, env interpolation, per-launcher cwd overrides, or argv parsing. +- No removal/configuration of the built-in VS Code row. + +## Data model + +Add to `GlobalSettings`: + +- `openInApplications?: OpenInApplication[]` +- `type OpenInApplication = { id: string; label: string; command: string }` + +Default in `getDefaultSettings()`: + +- `openInApplications: []` + +Why optional in type but present in defaults: keeps backward compatibility with older persisted files while giving runtime code a stable default after merge. + +## Normalization contract + +Implement one shared normalizer in `src/shared/` and use it in both renderer update path and main persistence update path. It must run in main before persistence. + +Rules: + +- trim `label`/`command`. +- drop rows with empty `label` or empty `command`. +- drop rows with duplicate `id` (keep first). +- if `id` is missing/blank, generate one (e.g. `crypto.randomUUID()` in renderer before save). +- cap length (e.g. 8). + +Do not dedupe by `command`: users may intentionally keep separate labels for the same command with different wrappers/scripts later. + +Main remains source of truth. Renderer normalization is UX only; main normalization must always run. + +Also normalize on load (not only on `settings:set`) so externally edited/stale persisted rows are repaired on startup. + +## IPC and launch behavior + +Change signature across shared preload surface and main handler: + +- `openInExternalEditor(path: string, command?: string)` + +Main launch behavior: + +- if `command` is missing/blank after trim, fall back to `code`. +- otherwise resolve and launch that command token (same resolution path currently used for `code` via `resolveCliCommand`). +- keep existing `validateLocalPathTarget(...)` path checks. +- keep `getSpawnArgsForWindows(command, [path])` path for Windows. +- keep detached spawn semantics and `launch-failed` mapping. + +Constraint to document in UI copy: command is not shell-parsed. `cursor --new-window` is treated as a binary name and will fail. Users must provide an executable command (or wrapper script) only. + +Important: this is not “free”. Every call site and type layer (`preload/index.ts`, `preload/api-types.ts`, renderer callers, `shell.test.ts`) must be updated together. + +## Menu behavior + +`WorktreeOpenInMenu` order: + +1. `VS Code` (fixed) +2. configured `openInApplications` +3. file manager + +Each configured row invokes `openInExternalEditor(worktreePath, app.command)`. + +Remote/local guard stays exactly where it is now (`openWorktreePath`) so all rows (including file manager) remain blocked consistently in remote contexts. + +## Settings UI + +Add General section: `Open In Menu`. + +- static note: VS Code is always included. +- presets: add Cursor (`cursor`) and Zed (`zed`). +- editable rows for label/command. +- remove row. +- add custom row. + +Search indexing: add entries in `general-search.ts` so settings search can discover this section. + +## Consistency and concurrency + +- Multi-window: renderer has a `settings:changed` listener, but main currently emits that event only for View > Appearance toggles. `settings:set` does not broadcast generic updates. So edits to `openInApplications` in one window are not guaranteed to appear live in another until reload/fetch. +- Renderer optimistic merge is temporary UI state only. Main write result is authoritative and may normalize away invalid rows. Use the `settings:set` return value to rebase local state immediately after each write. +- Concurrent edits are last-write-wins at the field level (`openInApplications` array replaced wholesale). There is no compare-and-swap/version guard. +- External file mutation of `orca-data.json` is only observed on app restart; no live file watch exists. + +## Edge cases + +- Empty/whitespace command or label -> dropped by normalizer. +- Missing `openInApplications` in persisted settings -> treated as `[]` via defaults merge. +- Duplicate IDs (including hand-edited config) -> first survives, later rows dropped. +- Command strings with spaces/flags (e.g. `cursor --foo`) -> fail at spawn unless provided via wrapper executable. +- Launcher not in PATH or non-executable -> `launch-failed` and existing toast. +- Path exists but is a file (not directory) still passes current validation and will be passed to launcher/file manager; this is existing behavior. +- Relative/non-existent worktree path -> existing `not-absolute`/`not-found` error flow. +- Remote/server worktree -> blocked before IPC regardless of configured launchers. + +## Tests required + +- `shell.test.ts`: optional command path, blank command fallback to `code`, Windows spawn arg path, failure mapping. +- `WorktreeOpenInMenu.test.tsx`: configured rows render in order, command forwarded, remote guard still blocks all targets. +- settings normalization tests (shared normalizer + persistence update path): trim, drop invalid rows, cap, duplicate-id behavior. +- settings normalization tests: missing/blank id handling and generated-id stability on edit. +- `settings:set`/renderer integration test: renderer applies authoritative returned settings, not only optimistic local merge. +- General settings search entries include new section keywords. diff --git a/docs/design/ssh-remote-workspace-sync-plan.md b/docs/design/ssh-remote-workspace-sync-plan.md deleted file mode 100644 index 7b3e9929453..00000000000 --- a/docs/design/ssh-remote-workspace-sync-plan.md +++ /dev/null @@ -1,198 +0,0 @@ -# SSH Remote Workspace Sync Plan - -Planning note for refreshing PR #1690 against current `main`. This is not a merge plan for #1690; current `main` already owns same-client SSH PTY persistence through durable remote PTY leases, relay grace reconnect, explicit expired-session behavior, and provider cleanup semantics. - -## Product Boundary - -Orca should support an opt-in mode for an SSH target where terminal workspace chrome for that target is stored on the remote host and can be restored by another Orca client connecting to the same target. - -In scope: - -- Per-SSH-target remote workspace snapshots: terminal tabs, split layouts, active worktree/tab state, and remote PTY IDs needed to reattach live relay PTYs. -- Device presence for clients currently connected to the same synced target. -- Cross-client PTY reattach when the remote relay still owns the PTY. -- Conflict-safe snapshot writes with revisions and stale-write handling. -- Packaged app relay asset lookup and deployment reliability. - -Out of scope: - -- A general cloud sync service. -- Syncing local repos, local PTYs, local browser runtime state, credentials, secrets, shell history, or local filesystem paths. -- Guaranteed survival after the remote relay/host process exits. -- Automatic agent recovery as core PTY persistence. - -Keep explicit/user-driven: - -- Enabling sync per SSH target. -- Terminating remote sessions through an explicit destructive action. Normal disconnect detaches only. -- Resuming expired Claude/Codex agent sessions. Show an affordance using stored metadata; do not auto-inject resume commands into a shell. -- Resolving true conflicts that cannot be merged by target/worktree scope. - -## Gap Analysis - -Already covered on current `main`: - -- Same-client SSH PTY survival across close/quit/reconnect through `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId`, durable `SshRemotePtyLease` records, relay grace reconnect, and renderer deferred reattach. -- Explicit expired-session behavior: failed `pty.attach` marks leases expired and the renderer starts from the expired state rather than silently pretending the old process survived. -- Provider absence and relay teardown semantics: disconnect detaches, terminate/remove is destructive, and provider registration/ownership are centralized in `SshRelaySession`/`pty.ts`. -- Relay-side PTY buffering, attach, shutdown, and grace timers. - -#1690 adds beyond that: - -- Relay-side `workspace.*` RPCs and JSON snapshot storage on the remote host. -- A target-scoped session filter and scoped merge so one target's snapshot does not overwrite unrelated local/remote workspaces. -- Remote workspace IPC (`remoteWorkspace:*`) and preload API. -- Renderer hydration of remote snapshots and event-driven application of `workspace.changed`. -- Device presence polling and status UI. -- Multi-client relay dispatcher/socket support so more than one Orca client can talk to the same relay daemon. -- Resize ownership logic so background clients do not resize a shared remote PTY. -- Packaged relay asset lookup through `process.resourcesPath`/`extraResources`. -- Agent resume metadata tied to pane keys. This remains deferred for this implementation. - -Now redundant or risky from #1690: - -- Do not copy the renderer terminal restore changes wholesale. They overlap directly with recently stabilized `reconnectPersistedTerminals`, deferred SSH session IDs, split-leaf PTY mappings, and expired-session handling. -- Do not replace same-client persistence with relay snapshots. Local workspace session remains the source of truth for classic SSH targets and for non-synced targets. -- Do not default sync to on for existing or new targets until product decides retention/privacy behavior. -- Do not use unlimited relay lifetime as an implicit consequence of sync without a visible setting and cleanup story. -- Do not auto-run agent resume commands after an expired PTY. - -## Fresh Architecture - -Use two separate layers: - -1. **Local session persistence remains authoritative for this Orca install.** It continues to write normal workspace sessions and durable SSH PTY leases exactly as current `main` does. -2. **Remote workspace sync is an opt-in target-level overlay.** When enabled and connected, Orca pushes only that target's scoped workspace slice to the relay and hydrates only that target's slice from the relay. - -Data ownership: - -- Main process owns target configuration, active SSH sessions, remote workspace IPC, snapshot revision checks, ID translation, and old-relay compatibility handling. -- Relay owns synced snapshot storage under a per-user remote directory, keyed by a sanitized namespace derived from stable target identity. -- Renderer owns local Zustand workspace state and applies remote snapshots only through a guarded hydration path. -- PTY lifetime remains owned by the relay and current `SshRemotePtyLease`/PTY ownership maps. Remote snapshots store PTY IDs as references, not as process ownership. - -Remote snapshot shape: - -- `namespace`, `revision`, `updatedAt`, `schemaVersion`, `clientId`, and `session`. -- `session` is an explicit v1 projection, not raw `WorkspaceSessionState`. -- v1 includes only terminal/worktree state, keyed by stable remote worktree path. Import/export translates between remote worktree paths and local `repoId::path` IDs at the main/renderer boundary. -- Editor/browser chrome, browser URL history, browser profile IDs, and global active connection IDs are excluded from v1 to avoid syncing local-install identifiers or privacy-sensitive local state. -- Additive fields are okay; wrong-typed core fields must fall back safely. - -Sync flow: - -- On connect for sync-enabled target: register providers, fetch worktrees, fetch `workspace.get`, translate remote path IDs to local worktree IDs, scope-merge into local state, then let existing terminal reattach paths consume the resulting PTY IDs. -- Local state remains authoritative immediately after startup. A remote snapshot is applied only if this target has no local dirty state newer than the last synced revision, or the user explicitly refreshes from remote. -- On local session write: if workspace session is ready and the target was hydrated, export only that target's terminal/worktree slice using remote worktree paths and `workspace.patch` with the last known revision. -- On stale revision: if both local and newer remote touched the same worktree since the base revision, set conflict state. Otherwise preserve out-of-scope/untouched remote state, merge local touched worktrees, and retry once. -- On relay `workspace.changed`: ignore self-originated events, queue per target, apply only newer revisions, and avoid triggering a resize unless there is trusted local foreground intent. - -Failure semantics: - -- Old relay without `workspace.*`: keep classic SSH behavior, mark sync "unavailable until reconnect/update", and do not fail SSH connection. -- Remote snapshot parse failure: ignore the remote snapshot, leave local session intact, and show sync error for that target. -- Patch failure: keep local session as-is and retry on next session write or reconnect. -- Expired PTY referenced by remote snapshot: use the current expired-session path; do not silently replace with a live shell unless the existing UI path already does that intentionally. -- Explicit disconnect keeps remote PTYs according to current detach semantics. `Terminate remote sessions` kills PTYs. Target removal is destructive after confirmation and must clear or tombstone that target's synced PTY references. - -## Phased Implementation Plan - -1. **Relay packaging and compatibility PR** - - Add relay `extraResources` packaging and `getLocalRelayCandidates(platform)` lookup using `process.resourcesPath`. - - Keep current dev path and `ORCA_RELAY_PATH`. - - Add tests for packaged candidate order and missing package diagnostics. - -2. **Target setting and main IPC skeleton PR** - - Add `remoteWorkspaceSyncEnabled` and bounded `remoteWorkspaceSyncGracePeriodSeconds` to `SshTarget`. - - Default to false unless product explicitly chooses otherwise. - - Add preload/main `remoteWorkspace:*` IPC behind feature availability. - - No renderer hydration yet; verify old targets normalize cleanly. - -3. **Relay workspace snapshot RPC PR** - - Add relay `workspace.get`, `workspace.patch`, and `workspace.presence`. - - Store snapshots atomically under a user-private remote directory. - - Include namespace sanitization, revisions, schema version, and stale-revision responses. - - Wire old-relay method-not-found handling in main as "sync unavailable", not connection failure. - -4. **Projected session filtering/merge PR** - - Implement target-scoped session extraction in main using repo `connectionId`. - - Export/import terminal workspace state through a path-keyed projected schema. - - Implement merge that replaces only known in-scope worktrees and preserves out-of-scope remote state. - - Cover terminal tabs, split layouts, active IDs, `remoteSessionIdsByTabId`, and last-visited timestamps. Defer editor/browser chrome. - -5. **Renderer guarded hydrate/push PR** - - Fetch synced snapshots only after target worktrees are loaded. - - Apply snapshots through a remote-hydration guard and per-target queue. - - Push snapshots from the existing debounced session writer only for hydrated, connected, sync-enabled targets. - - Reuse existing `reconnectPersistedTerminals`; only feed it scoped remote session IDs. - - Do not enable live cross-client PTY attach unless the multi-client dispatcher and resize ownership from phase 6 are also present. - -6. **Multi-client relay + resize ownership PR** - - Extend relay dispatcher/socket bridge to support independent client frame state. - - Broadcast notifications to all clients, but keep request/response routing per client. - - Add foreground/local-intent resize ownership so background hydration or non-focused clients do not resize shared PTYs. - - Treat mobile fit locks and current PTY locks as higher priority than desktop resize. - -7. **Presence and status UI PR** - - Add lightweight presence polling and status display for sync-enabled connected targets. - - Show sync phase: idle, pulling, pushing, synced, conflict, error, unavailable/offline. - - Follow `docs/STYLEGUIDE.md` tokens and existing status bar patterns. - -8. **Agent resume metadata polish PR** - - Persist provider/session/cwd metadata as recovery hints only. - - Add an explicit "Resume Claude/Codex session" action when a synced PTY expired. - - Avoid auto-sending commands on mount or after snapshot hydration. - -## Test Plan - -Unit: - -- Target normalization preserves existing targets and defaults sync off. -- Namespace derivation is stable and sanitization rejects path traversal/control chars. -- Relay snapshot read/write handles missing, corrupt, stale, and atomic-write cases. -- Target-scoped filter excludes local repos and other SSH targets. -- Projected snapshot import/export translates stable remote worktree paths to local `repoId::path` IDs. -- Scoped merge preserves out-of-scope remote worktrees and deletes in-scope records when the remote snapshot says they are gone. -- Stale revision retry preserves edits from the newer remote snapshot. -- Old relay `-32601` maps to sync unavailable. -- Packaged relay candidate lookup searches `process.resourcesPath` and dev paths. -- Resize ownership gates remote resize on focused/local user intent. - -Integration: - -- Current same-client persistence still passes: close/quit/reconnect restores SSH PTYs without remote workspace sync enabled. -- Sync-enabled target push writes only that target's session slice. -- Cross-device simulation with two mux clients: client A pushes, client B receives `workspace.changed`, hydrates, and attaches the same relay PTY. -- Two clients patch concurrently: one stale response, one retry, no out-of-scope data loss. -- Explicit disconnect detaches but preserves leases; terminate sessions kills remote PTYs and marks leases terminated. -- Relay restart or expired grace marks PTY references expired and does not auto-resume agents. -- Provider-registration wait, if still needed, waits only for restored SSH reattach and does not mask normal spawn failures. - -E2E/manual: - -- Packaged macOS/Linux/Windows app can deploy the relay from packaged resources. -- Old running relay continues classic SSH behavior and shows sync unavailable until reconnected with a new relay. -- Two Orca clients on different machines connect to the same target, open a shared remote workspace, and verify tab/layout sync. -- Resize a focused terminal on one client while another client is backgrounded; full-screen TUI dimensions follow only the focused client. -- Mobile/desktop mixed client does not fight PTY dimensions when mobile fit lock is active. -- Passphrase-protected targets defer reconnect without losing remote session IDs. - -## Risks And Decisions - -Decisions before implementation: - -- Default: should remote workspace sync be opt-in off for all targets? Recommendation: yes. -- Retention: is `0` unlimited relay lifetime acceptable, or should sync use a large bounded default with visible cleanup? -- Namespace identity: include `configHost`, host, port, and username. Label changes do not affect namespace. -- Snapshot scope: v1 is terminal + layout + active worktree/tab only. Editor/browser comes later with explicit allowlists and translation rules. -- Conflict UX: retry once automatically, then surface "Refresh from remote" / "Overwrite remote" choices. -- Agent resume UX: explicit action location in expired terminal state or status toast. - -Main risks: - -- Cross-device resize fights can corrupt TUI layout; ship ownership gates with the first cross-client PTY attach. -- Snapshot merge bugs can delete unrelated local or remote workspace state; keep target filtering/merge isolated and heavily tested. -- Unlimited remote relay lifetime can leave processes running unexpectedly; make retention visible and terminate explicit. -- Old relays may live across app updates; every new RPC must fail open. -- Renderer hydration can trigger existing session writers and feedback loops; use hydration guards and per-target revision tracking. -- Remote snapshots contain path/workspace metadata on the SSH host; treat as local-to-remote-user data and document the storage path. diff --git a/docs/gh-work-item-drawer-cache-flash.md b/docs/gh-work-item-drawer-cache-flash.md deleted file mode 100644 index 9d8a0cf4735..00000000000 --- a/docs/gh-work-item-drawer-cache-flash.md +++ /dev/null @@ -1,132 +0,0 @@ -# GitHub Work Item Drawer: Fix Reopen Flash - -## Symptom - -After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears. - -## Verified root cause - -`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`). - -Current sequence on close/reopen of the same item: - -1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`. -2. Reopen renders once with `details === null`. -3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`. -4. Second render shows cached content. - -With sheet animation, that first empty paint is visible as a flash. - -## Refactor direction - -Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render. - -This is not optional if we want reopen to paint cached content on first render. - -## Required cache-store changes - -Add a store subscription layer around `workItemDetailsCache`: - -- `subscribeWorkItemDetailsCache(listener)` -- `notifyWorkItemDetailsCache()` - -Call `notifyWorkItemDetailsCache()` after every actual cache mutation: - -- `touchWorkItemDetailsCache` -- `invalidateWorkItemDetailsCacheForKey` -- `invalidateWorkItemDetailsCacheByMatch` - -Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data. - -## `useSyncExternalStore` contract details - -Use: - -```ts -const cachedEntry = useSyncExternalStore( - subscribeWorkItemDetailsCache, - () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined) -) -``` - -Important constraints: - -- `getSnapshot` must return a referentially stable value when store data has not changed. -- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object). -- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops). - -## Component state changes - -Remove component-local `details/loading/error` state for work-item details. - -Derive view state from `cachedEntry`: - -- `details = cachedEntry?.details ?? null` -- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details` -- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null` - -Keep stale-on-error behavior by preserving `details` in cache while setting `error`. - -## Effect responsibilities after refactor - -The main effect should: - -- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`) -- decide freshness and in-flight dedupe -- launch fetch and write results/errors/pending back into cache - -The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details. - -## Optimistic comment race and merge rules - -`appendOptimisticComment` must write through cache only (no local `setDetails`). - -Rules: - -- push comment into `optimisticCommentsRef` -- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify -- derive rendered comments from cached details plus optimistic refs - -Dependency pitfall: - -- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo` -- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway - -This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps. - -## Invalidation while drawer is open - -When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted: - -- subscription notifies immediately -- render sees `cachedEntry === undefined` -- UI falls back to loading/empty state and effect triggers refetch - -This is expected and preferred over showing stale state. - -## Fetch race constraints (must keep) - -Keep current protections: - -- per-key in-flight dedupe via `entry.pending` -- generation guard to block stale writes after invalidation -- stale-on-null handling (`result === null` must not wipe existing valid cached details) - -Add one missing consistency rule: - -- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation. - -## Scope of deletion - -Delete only the work-item-detail local state and call sites: - -- `useState` (`details`) -- `useState` (`loading`) for this data path -- `useState` (`error`) for this data path -- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path - -Do not touch unrelated loading/error state in other subviews. - -## Expected result - -Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative. diff --git a/docs/gh-work-item-drawer-cache.md b/docs/gh-work-item-drawer-cache.md deleted file mode 100644 index 8e95a48a6ef..00000000000 --- a/docs/gh-work-item-drawer-cache.md +++ /dev/null @@ -1,118 +0,0 @@ -# GitHub Work Item Drawer: Cache & Latency - -## Problem - -`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again. - -The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota. - -## What is true today - -- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization. -- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway. -- The issue details path is currently multi-call: -1. `getWorkItem` (`/issues/:n` or PR fallback). -2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`). -3. `getWorkItemParticipants` (GraphQL participants). -4. `getMentionParticipants` (GraphQL user hydration for visible authors). - -So “3 spawns” understates the real hot path. - -## Goals - -- Reopen latency should be near-instant for recently viewed items. -- Cold issue-open latency should drop by reducing `gh` command count. -- Correctness must survive local mutations, repo/source switches, and multi-window usage. - -## Non-goals - -- No disk-persistent cache in this iteration. -- No attempt to replace PR files/diff path with GraphQL. - -## Design - -### 1. Renderer SWR cache with paint-first reads - -Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by: - -`repoPath + issueSourcePreference + type + number` - -Cache value: - -- `details` -- `fetchedAt` -- `pending?: Promise` -- `error?: string` - -Behavior: - -1. On open, render cached `details` immediately when present (do not clear to `null`). -2. If entry age <= `FRESH_MS` (30s), skip fetch. -3. If stale or missing, fetch in background and replace cache + UI when resolved. -4. On fetch failure with cached data, keep stale data visible and show non-blocking error state. -5. On fetch failure without cached data, show blocking error state. - -### 2. In-flight dedupe in renderer - -Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise. - -This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions. - -### 3. Explicit invalidation rules - -“Background refetch is authoritative” is necessary but not sufficient. - -Invalidate (or patch + mark stale) on successful local mutations: - -- issue state/labels/assignees/body edits -- new comments/reactions -- PR review comment create/resolve - -Scope: - -- per-item key in current window -- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too - -Also invalidate on context switches: - -- `repoPath` change -- issue source preference change (`origin`/`upstream`/`auto`) -- sign-out/account change - -For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header. - -### 4. Main-process issue fetch collapse (GraphQL-first) - -Do not claim “one call returns everything” unless we actually ship and verify it. - -Feasible single GraphQL issue query fields: - -- issue body -- labels -- assignees -- participants -- comments(first: N) with author login + avatarUrl + body + createdAt + url - -Limits and required fallbacks: - -- GraphQL pagination still applies (`first: 100`). More comments require paging. -- Some comment authors can be null/ghost; renderer must keep existing fallback behavior. -- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path. -- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it. - -PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope. - -## Edge cases this design must handle - -- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it. -- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry. -- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry. -- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays). -- Unauthorized/404 should not overwrite valid cached data with empty shells. - -## Rollout - -1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior. -2. Add mutation-driven invalidation and cross-window invalidation event. -3. Implement GraphQL-first issue details with strict fallback. -4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after. diff --git a/docs/mobile-prefer-renderer-scrollback.md b/docs/mobile-prefer-renderer-scrollback.md deleted file mode 100644 index 1c9f1a254d2..00000000000 --- a/docs/mobile-prefer-renderer-scrollback.md +++ /dev/null @@ -1,651 +0,0 @@ -# Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data - -## Problem - -When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a `claude`-then-shell session in the `edit-issues` worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt. - -The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts. - -## Where data lives today - -Two separate xterm.js instances exist for every connected PTY: - -1. **Renderer xterm** — owned by `TerminalPane` in the renderer process. Created when the user mounts the pane. Listens to `pty:data` IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal. - -2. **Runtime headless emulator** — `HeadlessEmulator` in `src/main/runtime/orca-runtime.ts`. Created lazily on first `runtime.onPtyData(ptyId, …)` call. 5,000-row scrollback. Used to serve `terminal.read`, `terminal.subscribe`, `lastAgentStatus`, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get. - -| Consumer | Reads from | Lifetime | -|---|---|---| -| Desktop pane | Renderer xterm | Pane mount → unmount | -| Mobile (`terminal.subscribe`) | Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit | -| `terminal.read` (CLI / agent) | Headless emulator | Runtime startup → PTY exit | -| `lastAgentStatus` | Headless emulator (via OSC detection on `onPtyData`) | Runtime startup → PTY exit | - -The two xterms are fed by the same provider stream but are "born" at different moments: - -- The renderer xterm gets hydrated on attach via cold-restore data (`result.coldRestore.scrollback`) or warm reattach (`result.snapshot`) in `pty-connection.ts`, plus all live bytes since. -- The headless emulator only sees bytes that flow through `runtime.onPtyData` since *runtime* startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload. -- Commit `8a5ea4b7` added `seedHeadlessTerminal(ptyId, data, size?)` which runs from `ipc/pty.ts` on `provider.spawn` and seeds the emulator from `result.snapshot` / `result.coldRestore.scrollback`. That helps daemon-restored sessions but **does not help when the daemon's persisted state is itself near-empty** — the `edit-issues` reproduction has `checkpoint.json` with `scrollbackAnsi: ""` because the daemon checkpoint only stores the visible screen, not real scrollback. - -So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay. - -## Goal - -Mobile, CLI, and `lastAgentStatus` should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must: - -- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes). -- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon). -- Not introduce reflow / SGR drift between desktop and mobile. -- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path. -- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/`terminal.read`. - -## Non-goals - -- **Mobile-only-after-relaunch (Scenario A).** A user who relaunches Orca and opens mobile *without* ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A. -- Persisting full scrollback to disk (the "option 2" architecture). -- Unifying the renderer and headless emulators into a single main-process service. -- Changing mobile's xterm-side replay logic. Today it correctly handles either source. -- Changing the read-priority order in `serializeTerminalBufferFromAvailableState`. Headless stays primary; renderer stays the no-headless fallback. - -## Proposed change - -When the runtime first sees `pty:data` for a `ptyId` that already has a renderer pane registered (detected via a new `ptyController.hasRendererSerializer(ptyId)` predicate, backed by the `serializersByPtyId` registry in `pty-buffer-serializer.ts`), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it **before** writing the live byte that just arrived. Subsequent `pty:data` calls for the same `ptyId` skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts. - -This is structurally the same operation `seedHeadlessTerminal` already does on `provider.spawn` from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch. - -After hydration, all consumers (`terminal.read`, `terminal.subscribe`, `lastAgentStatus`, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC. - -### System context — before - -``` - ┌─────────────────────────────────────────────┐ - │ Renderer process │ - │ │ - │ TerminalPane → xterm.js (50k rows) │ - │ ▲ │ - │ │ replayIntoTerminal(coldRestore. │ - │ │ scrollback) on pane mount │ - │ │ + live pty:data writes │ - └─────┼───────────────────────────────────────┘ - │ - pty:data│IPC - │ - ┌─────┴───────────────────────────────────────┐ - │ Main process │ - │ │ - │ ipc/pty.ts (provider.spawn) │ - │ ├─→ seedHeadlessTerminal(snapshot) │ - │ │ ↓ │ - │ │ HeadlessEmulator (5k rows) ───┐ │ - │ │ ▲ │ │ - │ │ │ trackHeadlessTerminalData│ │ - │ │ │ on every onPtyData │ │ - │ └─→ runtime.onPtyData(...)────────┘ │ - │ ▼ │ - │ serializeHeadless │ - │ (primary read source) │ - │ │ │ - │ ▼ │ - │ mobile / CLI / lastStatus │ - └─────────────────────────────────────────────┘ - ▲ - pty:data│from daemon - │ - ┌─────┴───────────────────────────────────────┐ - │ Daemon │ - │ checkpoint.json (visible screen only) │ - └─────────────────────────────────────────────┘ - -Bug: when daemon checkpoint is near-empty but the renderer xterm has -accumulated a rich live buffer, mobile reads from the headless emulator -which only saw bytes since runtime startup — and so sees less than desktop. -``` - -### System context — after - -``` - ┌─────────────────────────────────────────────┐ - │ Renderer process │ - │ │ - │ TerminalPane → xterm.js (50k rows) │ - │ │ ▲ │ - │ │ │ unchanged hydration path │ - │ │ └─── live pty:data writes │ - │ │ │ - │ │ ptyController.serializeBuffer(ptyId) │ - │ │ called ONCE per (ptyId, runtime) │ - │ │ on first onPtyData │ - │ ▼ │ - └─────┼───────────────────────────────────────┘ - │ - pty:data│IPC + one-time serialize round-trip - │ - ┌─────┴───────────────────────────────────────┐ - │ Main process │ - │ │ - │ onPtyData(ptyId, data) (first time) │ - │ ├─→ if ptyController.serializeBuffer │ - │ │ returns non-empty: │ - │ │ hydrateHeadlessFromRenderer → │ - │ │ seedHeadlessTerminal(rendered) │ - │ │ mark hydrated │ - │ ├─→ trackHeadlessTerminalData(data) │ - │ │ (chained via writeChain after seed) │ - │ │ │ - │ HeadlessEmulator (5k rows, now seeded │ - │ with renderer's view) │ - │ ↓ │ - │ serializeHeadless (UNCHANGED PRIMARY) │ - │ ↓ │ - │ mobile / CLI / lastAgentStatus / read │ - └─────────────────────────────────────────────┘ - -Read priorities are unchanged. Headless emulator is still the primary -source of truth; renderer is consulted exactly once, at hydration time, -not per consumer or per subscribe. -``` - -### Ordering invariant (DO NOT REORDER) - -In `onPtyData(ptyId, data, at)`, the call sequence is: - -```ts -this.agentDetector?.onData(ptyId, data, at) -this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE -this.trackHeadlessTerminalData(ptyId, data) // ← this line -``` - -**`maybeHydrateHeadlessFromRenderer` MUST be invoked before `trackHeadlessTerminalData`.** If reordered, `trackHeadlessTerminalData`'s lazy-create branch will populate `headlessTerminals` with a fresh empty emulator first, and `maybeHydrateHeadlessFromRenderer`'s `!headlessTerminals.has(ptyId)` precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in `orca-runtime.ts`. - -A `// DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md` comment is mandatory at this call site. - -### Pseudocode - -```ts -// Per-PTY hydration tracker. Key absent = idle (next byte may attempt). -// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably -// finished; 'skipped' is no longer used — instead we leave the key absent -// when the precondition fails so a later byte can retry. -private headlessHydrationState = new Map() - -onPtyData(ptyId: string, data: string, at: number): void { - this.agentDetector?.onData(ptyId, data, at) - this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data) - // …existing OSC / tail / leaf bookkeeping unchanged -} - -private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise { - if (this.headlessHydrationState.has(ptyId)) return - if (this.headlessTerminals.has(ptyId)) { - // Live bytes already arrived for this ptyId — re-seeding now would - // duplicate them. Mark done; never retry. (Daemon-snapshot seeding - // is handled separately: see "Cooperation with seedHeadlessTerminal" - // — that path is gated on `!hasRendererSerializer(ptyId)` so it does - // not pre-empt this hydration when a renderer pane is mounted.) - this.headlessHydrationState.set(ptyId, 'done') - return - } - // Why: leave the state absent (not 'done') when there's no renderer - // serializer or controller. The pane may mount one byte later; the - // next onPtyData call gets another chance. This is the contract change - // motivated by Open Question P2-2. - if (!this.ptyController?.serializeBuffer) return - if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered - - this.headlessHydrationState.set(ptyId, 'pending') - - // Why: eagerly create the headless state at PTY dims so concurrent live - // writes from trackHeadlessTerminalData chain onto the same writeChain. - // This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803); - // without it, a live byte arriving during the serializeBuffer await would - // lazy-create a separate state that we'd later overwrite, dropping the - // live byte. - const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), - writeChain: Promise.resolve() - } - this.headlessTerminals.set(ptyId, state) - - // Why: append the renderer-fetch + seed-write to the chain. Live writes - // that arrive before the chain head resolves are queued behind it via - // trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern. - state.writeChain = state.writeChain.then(async () => { - try { - const rendered = await this.ptyController!.serializeBuffer!(ptyId, { - scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, - altScreenForcesZeroRows: true - }) - if (!rendered || rendered.data.length === 0) { - this.headlessHydrationState.set(ptyId, 'done') - return - } - // Why: resize to the renderer's reported dims before writing the seed - // so the serialized layout reflows correctly. Then resize back to the - // PTY's current dims after the seed lands so subsequent live writes - // use the right cell grid. - state.emulator.resize(rendered.cols, rendered.rows) - await state.emulator.write(rendered.data) - const ptyDims = this.getTerminalSize(ptyId) - if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { - state.emulator.resize(ptyDims.cols, ptyDims.rows) - } - // Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2 - // title bytes, so extracting from rendered.data would always be null. - // applySeededAgentStatus runs detectAgentStatusFromTitle and writes - // leaf.lastAgentStatus only when the result is non-null; it MUST NOT - // call resolveTuiIdleWaiters or deliverPendingMessages because seed- - // derived status reflects historical state. - if (rendered.lastTitle) { - this.applySeededAgentStatus(ptyId, rendered.lastTitle) - } - } catch { - // Hydration is best-effort. Live writes continue via the same - // writeChain that this catch-arm leaves intact. - } finally { - this.headlessHydrationState.set(ptyId, 'done') - } - }) -} -``` - -**Eager-state ordering invariant.** Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered `onPtyData`) is enqueued onto `state.writeChain` BY `trackHeadlessTerminalData` AFTER `maybeHydrateHeadlessFromRenderer` returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves. - -**Resize-during-hydration note.** `resizeHeadlessTerminal` (orca-runtime.ts:828) deliberately bypasses `writeChain` and calls `state.emulator.resize(...)` directly — that's existing behavior we preserve. If a `pty:resize` IPC arrives during the hydration window (between `headlessTerminals.set` and `seedPromise` resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration. - -The cap and alt-screen rule on the renderer side mirror `serializeHeadlessTerminalBuffer`: 1000-row limit on rehydration payload, force `scrollbackRows = 0` when the renderer xterm reports `buffer.active.type === 'alternate'`. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed. - -### IPC contract changes - -Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep: - -- `src/preload/index.ts` — the `pty:serializeBuffer:request` payload extends to `{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }`. Renderer-side type for the listener callback widens accordingly. -- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts the opts object and forwards it on the IPC `webContents.send` payload. Snapshot-shape validation widens to accept an optional `lastTitle: string`. Existing 750 ms timeout unchanged. -- `src/main/runtime/orca-runtime.ts` — the `RuntimePtyController.serializeBuffer?` signature widens to `(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>`. A new optional `hasRendererSerializer?(ptyId): boolean` lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against `serializersByPtyId` in `pty-buffer-serializer.ts`). -- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — the registered `SerializeFn` widens to `(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`. The pane's serializer reads the opts: if `altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate'`, call `pane.serializeAddon.serialize({ scrollback: 0 })`; otherwise `pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })`. The response payload includes `lastTitle: lastTitleByPtyId.get(ptyId)?.title` (omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated. -- `serializeTerminalBufferFromAvailableState` (the renderer-fallback read path in `orca-runtime.ts:843`) forwards `scrollbackRows` so the fallback's depth matches the primary path. **It does NOT pass `altScreenForcesZeroRows: true`** — see "alt-screen flag scoping" below. - -**alt-screen flag scoping (only on hydration path).** `altScreenForcesZeroRows: true` is passed ONLY by `maybeHydrateHeadlessFromRenderer` (the hydration path). The read-fallback path (`serializeTerminalBufferFromAvailableState`) passes `altScreenForcesZeroRows: false` (or omits the flag, default false). Reasoning: - -- **Hydration:** wants the *normal* buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session. -- **Read fallback:** is invoked by `terminal.read` / `terminal.subscribe` for an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today. - -Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape: - -```ts -// In serializeTerminalBufferFromAvailableState (read-fallback): -rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { - scrollbackRows: opts.scrollbackRows, - altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content -}) ?? Promise.resolve(null)) -``` - -### Renderer-side prerequisite - -Today, `registerPtySerializer(ptyId, …)` is only called inside `handleReattachResult` (`pty-connection.ts:571`). Fresh spawns — the dominant case — never register, so `hasRendererSerializer(ptyId)` would always return false and hydration would no-op. The renderer change required: - -- Move (or duplicate) the `registerPtySerializer` call so it fires for **both** reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing `pane`, `deps.pendingWritesRef`, `deps.replayingPanesRef`) into a `registerPaneSerializerFor(ptyId)` helper and call it from `handleReattachResult` AND from `startFreshSpawn`'s `.then(spawnedPtyId => …)` once a `ptyId` is known. -- The helper accepts the new `opts` arg and threads it into `pane.serializeAddon.serialize({ scrollback: … })` per the IPC contract above. - -**Cleanup contract & StrictMode safety.** Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal: - -1. **`if (disposed) return` guard at the top of every post-spawn registration call** (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but its `pty:spawn` IPC may have already resolved by the time `disposed` flips to true. Without the guard, the disposed first mount would call `registerPaneSerializerFor` after teardown — replacing the live registration from the second mount or registering a closure over a torn-down `pane.terminal`. - -2. **Unregister via ownership token, not unconditional delete.** `serializersByPtyId` becomes `Map`. Each call to `registerPtySerializer` mints a fresh `owner` symbol and returns an unregister closure that **only deletes the entry when the current entry's `owner === thisOwner`**. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xterm `onTitleChange` IDisposable that was installed at register time. - - ```ts - export function registerPtySerializer(ptyId, fn): () => void { - const owner = Symbol(ptyId) - serializersByPtyId.set(ptyId, { fn, owner }) - ensureSerializerListener() - return () => { - const current = serializersByPtyId.get(ptyId) - if (current?.owner === owner) { - serializersByPtyId.delete(ptyId) - } - } - } - ``` - -3. **Spawn-rejection cleanup is mandatory.** The renderer MUST call `pty:clearPendingPaneSerializer(paneKey, gen)` from the catch-arm of `pty:spawn` AND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey. - -4. **Pre-signal applies to BOTH fresh-spawn AND reattach paths.** `declarePendingPaneSerializer(paneKey)` is mandatory on both — mirroring the `registerPaneSerializerFor(ptyId)` requirement. Reattach paths today already register the serializer in `handleReattachResult`, but the pre-signal must also fire on the reattach path so the cooperation gate in `pty:spawn` behaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one. - -The `unregisterSerializer()` call is wired into `onDataDisposable.dispose` so the registration is torn down on pane unmount, regardless of which path registered it. - -### Renderer pre-signal handshake - -The cooperation gate (`ipc/pty.ts` skipping `seedHeadlessTerminal` when a renderer serializer is registered) has a fundamental timing problem on **fresh spawns**: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until `pty:spawn` resolves. So at the moment the cooperation gate runs (immediately after `provider.spawn` returns inside `pty:spawn`'s handler), `runtime.hasRendererSerializerFor(ptyId) === false` for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case. - -The renderer does, however, know its **paneKey** before spawn: every pane has a stable `ORCA_PANE_KEY` env var that is part of `args.env` on `pty:spawn`. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet: - -1. **Pre-signal IPC with generation token.** Renderer calls `pty:declarePendingPaneSerializer(paneKey)` BEFORE invoking `pty:spawn`. Main process maintains `pendingByPaneKey: Map` keyed by paneKey. On each pre-signal, main mints a fresh monotonic `gen` value (incrementing a module-level `genSeq` counter) and overwrites the entry, then returns `gen` to the renderer. The renderer awaits this IPC (capturing `gen`), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time `pty:spawn` runs. - - ```ts - let genSeq = 0 - const pendingByPaneKey = new Map() // paneKey -> generation - - function declarePending(paneKey: string): number { - const gen = ++genSeq - pendingByPaneKey.set(paneKey, gen) - return gen // returned to renderer, echoed back on settle/clear - } - - function settlePending(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey) - } - - paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen)) - ``` - - Why a generation counter: today's design hooks `pendingByPaneKey.delete(paneKey)` into `clearProviderPtyState` via `paneKeyTeardownListeners`. If the OLD PTY's `clearProviderPtyState` fires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: the `paneKeyTeardownListeners` wiring captures the generation at the time the old PTY was REGISTERED (via the existing `registerPty` call site) and the teardown only fires `settlePending` with that generation. If mount-2 has already replaced the entry with a new gen, `pendingByPaneKey.get(K) !== oldGen` and the teardown is a no-op. - -2. **Cooperation gate consults the pre-signal.** Inside `pty:spawn`, after `provider.spawn` returns, the gate becomes: - ```ts - const paneKey = args.env?.ORCA_PANE_KEY - const rendererPreSignaled = - typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 - && pendingByPaneKey.has(paneKey) - const rendererAlreadyRegistered = - runtime?.hasRendererSerializerFor?.(result.id) ?? false - if (!rendererPreSignaled && !rendererAlreadyRegistered) { - // No renderer is or will be authoritative for this PTY — daemon-snapshot - // seed runs as today. - runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */) - } - ``` - The `rendererAlreadyRegistered` branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The `rendererPreSignaled` branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when `pendingByPaneKey.has(paneKey)` is true *regardless of generation*; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. **Invariant:** fresh-spawn always relies on the pre-signal branch (`hasRendererSerializerFor(ptyId)` is necessarily false at gate-time since the renderer hasn't received `ptyId` yet); the registered branch only fires for reattach where the pane mounted before spawn returned. - -3. **Settle / clear (echo gen back).** After the spawn IPC resolves and the renderer calls `registerPtySerializer(ptyId, …)`, it calls `pty:settlePaneSerializer(paneKey, gen)` to remove the pending intent (echoing the `gen` value it captured from declarePending). The handler runs `settlePending(paneKey, gen)` which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of `pty:registerPaneSerializer`" alternatives work too — the design picks the explicit `settle` IPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also calls `pty:clearPendingPaneSerializer(paneKey, gen)` (sibling handler with the same `settlePending` semantics) to drop the pending intent. - -**Sequence diagram (fresh spawn, happy path):** - -``` -Renderer Main / ipc-pty.ts Runtime - │ │ │ - │ pty:declarePendingPaneSerializer │ │ - │ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │ - │ ◀──────────────────────── ack(gen) ──│ │ - │ │ │ - │ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │ - │ │ provider.spawn(...) → result │ - │ │ pendingByPaneKey.has(paneKey)│ - │ │ === true → SKIP │ - │ │ seedHeadlessTerminal │ - │ │ │ - │ ◀──────────────────────────── ptyId ──│ │ - │ │ │ - │ registerPtySerializer(ptyId, fn) │ │ - │ pty:settlePaneSerializer(paneKey, │ │ - │ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │ - │ │ if gen matches → delete │ - │ │ │ - │ │ first onPtyData(ptyId,data)──▶│ - │ │ │ maybeHydrateFromRenderer - │ │ │ (headlessTerminals empty, - │ │ │ hasRendererSerializer true - │ │ │ → IPC round-trip → seed) -``` - -**Renderer-side call shape.** The renderer threads `gen` through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time: - -```ts -// In pty-connection.ts startFreshSpawn / handleReattachResult: -const gen = await window.api.pty.declarePendingPaneSerializer(paneKey) -try { - const ptyId = await window.api.pty.spawn(args) - registerPaneSerializerFor(ptyId) - await window.api.pty.settlePaneSerializer(paneKey, gen) -} catch (err) { - await window.api.pty.clearPendingPaneSerializer(paneKey, gen) - throw err -} -``` - -**Correctness properties:** - -1. **Pre-signal arrives before `pty:spawn`.** The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron `ipcRenderer.invoke`/`send` ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before `pty:spawn`'s handler runs. -2. **Missing paneKey degrades to today's behavior.** If `args.env?.ORCA_PANE_KEY` is absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false, `rendererAlreadyRegistered` is also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake. -3. **Pre-signal without settle (renderer crashes between spawn and registerPtySerializer).** The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first `onPtyData` falls through `hasRendererSerializer(ptyId) === false` and hydration's precondition leaves the state map absent. `trackHeadlessTerminalData` then lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry in `pendingByPaneKey` is cleared whenever a paneKey teardown event fires via the existing `paneKeyTeardownListeners` registry, gated by the generation token.) - -**Files-touched implications:** see "IPC contract changes" and "Files touched" — both `pty:declarePendingPaneSerializer` and `pty:settlePaneSerializer` need preload bindings AND main handlers. - -### Status parity - -Seeding the emulator bypasses `onPtyData`'s OSC-extraction path, so without intervention `leaf.lastAgentStatus` stays whatever it was (typically `null` for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed. - -A previous draft of this design called `extractLastOscTitle(rendered.data)` on the seed payload to recover the title. **That doesn't work**: xterm's `SerializeAddon.serialize()` writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets `terminal.options.title`, but the bytes never appear in `serialize()`'s output. So `extractLastOscTitle` on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel. - -**Approach: ship now via IPC widening, document as best-effort.** The renderer pane already wires `pane.terminal.onTitleChange` (in `pty-connection.ts`) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main: - -1. **Renderer side** (`pty-buffer-serializer.ts`): - - Module-level `lastTitleByPtyId: Map` keyed by the same ownership-token symbol as `serializersByPtyId` (see "Renderer-side prerequisite"). Populated by a wrapper around `pane.terminal.onTitleChange` installed at the same time as `registerPtySerializer`. The entry stores BOTH the IDisposable returned from `pane.terminal.onTitleChange(...)` AND the owner symbol alongside the latest title. - - When the pane unregisters, the `unregisterSerializer` closure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener. - - The IPC response payload widens from `{ data, cols, rows }` to `{ data, cols, rows, lastTitle?: string }`. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt). - -2. **Main side** (`ipc/pty.ts`, `orca-runtime.ts`): - - `requestSerializedBuffer` snapshot-shape validation accepts `lastTitle?: string`. - - `RuntimePtyController.serializeBuffer(ptyId, opts?)` returns `Promise<{ data, cols, rows, lastTitle?: string } | null>`. - - `maybeHydrateHeadlessFromRenderer` calls `applySeededAgentStatus(ptyId, rendered.lastTitle)` only when `rendered.lastTitle` is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs `detectAgentStatusFromTitle` and only writes `leaf.lastAgentStatus` when the result is one of `'idle' | 'working' | 'permission'`. - -3. **`applySeededAgentStatus(ptyId, title)`** (new helper in `orca-runtime.ts`). Behavior: - - If `title == null` or empty, no-op. - - Look up the leaf for `ptyId` via the existing pty→leaf mapping. Run `detectAgentStatusFromTitle(rendered.lastTitle)` (the helper from `src/shared/agent-detection.ts`). If the result is non-null, set `leaf.lastAgentStatus = result`. If null (title is not a recognizable agent title), leave `lastAgentStatus` untouched — the next live OSC byte will populate it through the normal onPtyData path. - - **MUST NOT** call `resolveTuiIdleWaiters(leaf)` or `deliverPendingMessages(leaf)`. The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return. - - **Why** (mandatory comment in the helper definition): `// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data.` - - **Rationale for routing through `detectAgentStatusFromTitle`:** we mirror the live path (`onPtyData` already uses `detectAgentStatusFromTitle`) so seeded and live paths produce the same union value, and downstream `leaf.lastAgentStatus === 'idle'` consumers (orchestration `tui-idle` waiters, pending-message gating) keep working. `leaf.lastAgentStatus` is typed `AgentStatus | null` where `AgentStatus = 'working' | 'idle' | 'permission'`; assigning the raw title string would be type-incorrect. - -This delivers status parity for the dominant case via the `lastTitle` field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated `lastTitleByPtyId` long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave `lastAgentStatus` as `null`; that's acceptable since `lastAgentStatus` is documented best-effort and the next live OSC title will populate it through the normal `onPtyData` path. - -**Status parity / accepted limitations.** Side effect of seeding `lastAgentStatus`: if the seed sets status to `'idle'`, the next live OSC byte that re-emits the same `'idle'` title will see `prevStatus === 'idle'` and the existing transition gate (`agentStatus === 'idle' && prevStatus !== 'idle'`) skips. Orchestration `tui-idle` waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into `lastSeededAgentStatus` (informational, not gating) vs `lastAgentStatus` (gates transitions). - -### Files touched - -- `src/main/runtime/orca-runtime.ts` — add `headlessHydrationState`, `maybeHydrateHeadlessFromRenderer`, wire into `onPtyData` (with mandatory `// DO NOT REORDER` comment per "Ordering invariant"). Widen `RuntimePtyController.serializeBuffer?` to `(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>` and add `hasRendererSerializer?(ptyId)`. Add `applySeededAgentStatus(ptyId, title)` helper that writes `leaf.lastAgentStatus` only — must NOT call `resolveTuiIdleWaiters` or `deliverPendingMessages` (mandatory "Why" comment in the helper definition). Add `hasRendererSerializerFor(ptyId)` runtime-service-level method that delegates to `ptyController.hasRendererSerializer` (consumed by `ipc/pty.ts`'s cooperation gate). Forward `scrollbackRows` from `serializeTerminalBufferFromAvailableState` into the renderer-fallback call (do NOT pass `altScreenForcesZeroRows: true` on this path — see "alt-screen flag scoping"). -- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts opts and forwards them on the `pty:serializeBuffer:request` IPC payload. Wire `hasRendererSerializer` through a renderer-registry getter. **Cooperate with hydration:** when a renderer serializer is registered for the spawning PTY *or* the renderer pre-signaled intent for `args.env.ORCA_PANE_KEY`, skip seeding from `result.snapshot` / `result.coldRestore.scrollback` so the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. **Add three new IPC handlers** with consistent paneKey validation (`typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256` — invalid input is rejected before mutating state): `pty:declarePendingPaneSerializer(paneKey): Promise` (renderer → main, before `pty:spawn`) mints a fresh generation `gen`, stores `pendingByPaneKey.set(paneKey, gen)` in a module-level `pendingByPaneKey: Map`, and returns `gen` to the renderer; `pty:settlePaneSerializer(paneKey, gen)` (renderer → main, after `registerPtySerializer`) runs `settlePending(paneKey, gen)` which deletes only if the stored gen still matches; `pty:clearPendingPaneSerializer(paneKey, gen)` (renderer → main, on spawn-rejection / unmount-before-settle) has the same `settlePending` semantics. Hook `paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))` so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.) -- `src/preload/index.ts` — extend the `pty:serializeBuffer:request` listener payload type to include `opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }`. Add three new bindings under `pty`: `declarePendingPaneSerializer(paneKey: string): Promise` (returns the `gen` token), `settlePaneSerializer(paneKey: string, gen: number): Promise`, and `clearPendingPaneSerializer(paneKey: string, gen: number): Promise`, all forwarded as `ipcRenderer.invoke('pty:declarePendingPaneSerializer', …)` / `'pty:settlePaneSerializer'` / `'pty:clearPendingPaneSerializer'` so the renderer can `await` them and rely on main-side ordering before issuing `pty:spawn` / after `registerPtySerializer`. The renderer captures `gen` from declare and threads it through every settle/clear call. -- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — widen `SerializeFn` to `(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`; default to today's full-buffer behavior when opts is absent. Convert `serializersByPtyId: Map` to `Map` and have `registerPtySerializer` mint a fresh owner symbol per call, returning an unregister closure that deletes only when `current?.owner === thisOwner` (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel `lastTitleByPtyId: Map` populated by a wrapper around `pane.terminal.onTitleChange`, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from `lastTitleByPtyId` to populate `lastTitle` in the IPC response. -- `src/renderer/src/components/terminal-pane/pty-connection.ts` — register the serializer for **fresh spawns** as well as reattach. Extract the registration body into a shared `registerPaneSerializerFor(ptyId)` helper called from both `handleReattachResult` AND the post-spawn callback in `startFreshSpawn`. Thread opts (scrollback cap, alt-screen flag) into `pane.serializeAddon.serialize`. Add `if (disposed) return` guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest `pane.terminal.onTitleChange` value in module state alongside the serializer registration so the serializer's IPC response can include `lastTitle` (see "Status parity"). Call `const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)` BEFORE `pty:spawn` on every fresh-spawn path (capturing the returned generation token), and `settlePaneSerializer(paneKey, gen)` AFTER `registerPaneSerializerFor(ptyId)` succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call `clearPendingPaneSerializer(paneKey, gen)` — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing `onDataDisposable.dispose` hook (now using the ownership-token contract from "Renderer-side prerequisite"). -- `src/main/runtime/scrollback-limits.ts` (new) — main-process module exporting `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000`. Imported by both `src/main/runtime/rpc/methods/terminal.ts` and `src/main/runtime/orca-runtime.ts`. (The constant is main-process-only; promoting it to `src/shared/` would expose a runtime cap to the renderer for no benefit.) -- `src/main/runtime/orca-runtime.test.ts` — add hydration unit tests (see "Test plan"). -- `src/main/runtime/mobile-subscribe-integration.test.ts` — extend with cross-process hydration scenario. - -No changes to: -- `pty:data` batching, daemon adapter. -- `serializeTerminalBufferFromAvailableState` priority order (headless still primary; renderer still no-headless fallback). -- Mobile RPC method signatures (`terminal.subscribe`, `terminal.resizeForClient`). -- Mobile WebView / `TerminalWebView.tsx`. -- Persistence (`HistoryManager`, `checkpoint.json` shape). - -## Why this is structurally sound - -The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane: - -1. **It hydrates on attach.** `pty-connection.ts` calls `replayIntoTerminal(pane, …, coldRestore.scrollback)` and writes `snapshot` immediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not. - -2. **It has a bigger buffer.** 50,000 rows vs 5,000. - -3. **It is updated on the same IPC tick as the visible UI.** No staleness vs what the user sees. - -Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing `seedHeadlessTerminal(provider.spawn snapshot)` path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, `terminal.read`, and `lastAgentStatus` see — the renderer is consulted exactly once per `(ptyId, runtime)` pair. - -This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve. - -## Data flow paths - -### Happy path — renderer mounted, hydration succeeds - -``` -T0 user opens desktop pane. - - Fresh spawn: pty-connection.ts calls - const gen = await pty:declarePendingPaneSerializer(paneKey) - BEFORE pty:spawn, then awaits both. Main mints a fresh gen and - records the pending intent under it. After spawn resolves with - ptyId, registerPaneSerializerFor(ptyId) + - pty:settlePaneSerializer(paneKey, gen) clear the intent (only if - the stored gen still matches). On spawn-rejection or unmount- - before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs - instead (mandatory — see "Renderer-side prerequisite" item 3). - - Reattach: registerPaneSerializerFor(ptyId) runs in - handleReattachResult once the ptyId is known. - Either way, ipc/pty.ts's cooperation gate sees that the renderer - is or will be authoritative for this PTY (via either - hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey) - and SKIPS the daemon-snapshot seedHeadlessTerminal call. -T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows) - → ipc/pty.ts forwards to runtime.onPtyData -T2 onPtyData: maybeHydrateHeadlessFromRenderer - - hasRendererSerializer(ptyId) === true → state: absent → pending - - serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows} - - on resolve: HeadlessEmulator built at rendered.cols/rows, - applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data) - - resize emulator to PTY dims if they diverge from rendered dims -T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain, - so the live byte is appended on top of the seeded buffer — order - preserved. -T4 hydration state → done. Subsequent onPtyData calls skip the guard. -T5 user opens mobile → terminal.subscribe → serializeHeadless returns - seeded buffer + everything since. Mobile == desktop, and - leaf.lastAgentStatus is populated from rendered.lastTitle - (sourced from pane.terminal.onTitleChange, NOT from the seed payload — - SerializeAddon does not round-trip OSC titles). -``` - -### Nil path — Scenario A: no renderer ever mounts - -``` -T0 user relaunches Orca, opens mobile only. -T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = "" - (daemon checkpoint had scrollbackAnsi: "" — visible screen only) - or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId) - is false (no pane mounted), so the cooperation gate does NOT skip: - seedHeadlessTerminal runs as today. -T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer - - hasRendererSerializer(ptyId) is false → state map left absent. - (Pane could still mount later in this runtime; we'd retry then.) -T3 trackHeadlessTerminalData populates from live bytes; emulator - already contains the daemon-snapshot seed. -T4 Mobile sees daemon's visible-screen snapshot + live bytes since. - KNOWN LIMITATION; out of scope for this design. -``` - -### Error path — renderer throws or unmounts mid-hydration - -``` -T0–T1 same as happy path through `pending` state. -T2 ptyController.serializeBuffer rejects (renderer disposed, - channel closed, IPC timeout, etc.). - - catch swallows the error. - - hydration state → done (we do not loop on every byte). -T3 Live bytes continue to populate the emulator via - trackHeadlessTerminalData (which lazily creates the headless - state at PTY dims). We lose the seed for this runtime but do - not block live data flow. -``` - -The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before `pending`) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine. - -### SSH note - -SSH-backed PTYs flow through the same `runtime.onPtyData` entry point as local and daemon-backed PTYs (see `ipc/pty.ts` provider listener — all providers call `runtime.onPtyData(id, data, at)` uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its `registerPtySerializer` registration is identical to the local case, and the runtime sees `hasRendererSerializer(ptyId) === true` regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior. - -### Concurrent path — live data arrives during hydration - -The renderer round-trip is asynchronous; live `pty:data` will continue to arrive before `serializeBuffer` resolves. The pattern from `seedHeadlessTerminal` already handles this: every emulator write is appended to `state.writeChain`, so: - -``` -writeChain: Promise.resolve() - → seed write ← scheduled by maybeHydrateHeadlessFromRenderer - → live byte 1 ← scheduled by trackHeadlessTerminalData - → live byte 2 - → live byte N -``` - -`emulator.write` is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write *before* it returns control to `onPtyData`, which then schedules the live write. - -## Performance - -`ptyController.serializeBuffer(ptyId)` does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50–200 ms for a full 50k-row buffer. - -The cost profile is materially different from the rejected priority-flip approach: - -| Approach | Renderer round-trip frequency | -|---|---| -| Priority flip (rejected) | Once per `terminal.subscribe` AND `terminal.resizeForClient` | -| Hydrate-on-first-touch (this) | Once per `(ptyId, runtime)` pair, ever | - -For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once *for hydration* per `(ptyId, runtime)` pair. The existing renderer-fallback inside `serializeTerminalBufferFromAvailableState` is preserved — it can still hit the renderer on `terminal.read` / `terminal.subscribe` when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow. - -There is no per-keystroke cost; `trackHeadlessTerminalData` is unchanged. - -## Reflow asymmetry - -Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync. - -This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same `handleMobileSubscribe` synchronous block. - -## Risks - -1. **Live data arrives during pending hydration.** Mitigated by chaining through `state.writeChain`, exactly as `seedHeadlessTerminal` already does. Order is preserved. - -2. **Renderer pane unmount mid-round-trip.** Caught by `try/catch`; the guard advances to `done` so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in `trackHeadlessTerminalData`. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is. - -3. **Wire-payload size on hydration.** Capped at 1000 rows from day one via the shared `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` constant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-row `DEFAULT_SCROLLBACK` (in `HeadlessEmulator`) is comfortably above the 1k hydration cap; if a future change raises the hydration cap, `HeadlessEmulator`'s default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator. - -4. **Renderer xterm is *behind* the runtime.** Theoretically possible if `pty:data` is delivered to the runtime before the renderer (depends on dispatch order in `ipc/pty.ts`). The renderer's xterm uses an asynchronous `write(callback)`, so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes. - -5. **Hydration races registerPty.** If `onPtyData` fires before `runtime.registerPty` has been called for this PTY, `getTerminalSize` may return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seed `resize` call — at most one reflow, and only when PTY and renderer dims actually diverge. - -6. **PTY-size vs renderer-dim divergence (resize-after-seed reflow).** Under mobile-fit overrides, or in the brief window before a `pty:resize` lands on the main process, the renderer's xterm dims and the runtime's `ptySizes` entry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator at `rendered.cols / rendered.rows`, then resize to PTY-size after the seed lands. See Pseudocode. **At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow** (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that. - -7. **Note: writes are flushed before serialize.** The renderer-side serializer (`pty-buffer-serializer.ts:registerPtySerializer`) flushes `pendingWritesRef` via `replayIntoTerminalAsync` before calling `pane.serializeAddon.serialize()`, so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime. - -8. **`pendingWritesRef` double-write window (severity-adjusted P2).** When a pane is hidden at the moment bytes arrive, the renderer-side `pendingWritesRef` buffers them while `runtime.onPtyData` simultaneously routes the same bytes into the headless emulator via `trackHeadlessTerminalData`. The renderer's serializer flushes `pendingWritesRef` before serializing, so the seed payload includes those bytes. If hydration runs after `trackHeadlessTerminalData` has already written the same bytes, the headless emulator ends up with a duplicated stretch. - - **Mitigation: the P0-A2 pre-signal handshake makes this rare in practice.** With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal — `trackHeadlessTerminalData`'s lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed via `state.writeChain`. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into `pendingWritesRef`, AND (c) hydration runs after `trackHeadlessTerminalData` has already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design. - -## Test plan - -### Unit (`orca-runtime.test.ts`) - -- Hydration runs on the *first* `onPtyData` for a `ptyId` when `hasRendererSerializer` is true and `serializeBuffer` returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte. -- Subsequent `onPtyData` calls do **not** re-trigger hydration after a successful run. Mock `serializeBuffer` and assert it was called exactly once across N `onPtyData` invocations for the same ptyId. -- "Exactly once" claim is qualified: the renderer round-trip happens once per `(ptyId, runtime)` for hydration. The existing `serializeTerminalBufferFromAvailableState` renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on `terminal.read` / `terminal.subscribe` paths. -- When `ptyController` is null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt. -- When `hasRendererSerializer(ptyId)` returns false on the first byte but true on a later byte, hydration **does** run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2. -- When `ptyController.serializeBuffer` rejects, the guard advances to `done` (no infinite retry), the emulator is created lazily by `trackHeadlessTerminalData`, live bytes still write through. -- Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several `onPtyData` calls, await `writeChain`, assert the final emulator content has the seed prefix followed by the live bytes in correct order. -- The 1000-row cap is applied to the renderer-sourced hydration: mock `serializeBuffer` to return a payload representing >1000 rows, assert the requested option bag includes `scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS`. -- Alt-screen flag is forwarded: assert the request includes `altScreenForcesZeroRows: true`. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xterm `buffer.active.type`.) -- **Status parity (lastTitle field):** mock `serializeBuffer` to return `{ data, cols, rows, lastTitle }` where `lastTitle` is a recognizable agent title. Fire `onPtyData`. Assert `leaf.lastAgentStatus` is one of `'idle' | 'working' | 'permission'` (whatever `detectAgentStatusFromTitle` returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when `lastTitle` is absent / undefined OR `detectAgentStatusFromTitle(lastTitle)` returns `null` (unrecognizable title), assert `leaf.lastAgentStatus` is left unchanged from its prior value (no overwrite). -- **Multi-PTY concurrent hydration:** fire `onPtyData` for ptyA and ptyB before either `serializeBuffer` resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique `requestId`). -- **Re-entrant `onPtyData`:** when `agentDetector.onData` synchronously triggers another `onPtyData` for the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes. -- **Cooperation with `seedHeadlessTerminal`:** when a renderer serializer is registered for `ptyId`, the spawn-time call from `ipc/pty.ts` skips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today. -- **Fresh-spawn timing race (P0-A2 pre-signal handshake):** simulate the renderer pre-signaling intent for paneKey via `const gen = await pty:declarePendingPaneSerializer(paneKey)`, then call the `pty:spawn` handler with `args.env.ORCA_PANE_KEY === paneKey`. Assert `seedHeadlessTerminal` is **NOT** invoked even though `hasRendererSerializerFor(result.id)` is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then `registerPtySerializer(ptyId, …)` and `pty:settlePaneSerializer(paneKey, gen)`. Fire `onPtyData(ptyId, liveByte)`. Assert `maybeHydrateHeadlessFromRenderer` runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via `writeChain`. -- **Pre-signal without settle (renderer crash):** simulate `pty:declarePendingPaneSerializer(paneKey)` followed by `pty:spawn` (gate skips daemon seed) but the renderer never calls `registerPtySerializer` or `settlePaneSerializer`. Fire `onPtyData`. Assert hydration's preconditions leave the state map absent, `trackHeadlessTerminalData` lazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event. -- **Pre-signal preserved across paneKey reuse during teardown:** simulate (a) old PTY for paneKey K is exiting, its `paneKeyTeardownListener` is queued (captured at registration time with `gen1`). (b) Mount-2 calls `pty:declarePendingPaneSerializer(K)` and stores generation `gen2` (`gen2 > gen1`). (c) The queued teardown for the old PTY runs; the listener invokes `settlePending(K, gen1)` and SKIPS the delete because `pendingByPaneKey.get(K) === gen2`. (d) Mount-2 calls `pty:spawn`; the cooperation gate matches (`pendingByPaneKey.has(K) === true`) and seed is suppressed. Assert daemon-snapshot seed did NOT run. -- **Test imports for `scrollback-limits.ts`:** tests in `orca-runtime.test.ts` and `mobile-subscribe-integration.test.ts` import `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` from `../scrollback-limits` so cap-assertion lines match the production constant (no hardcoded `1000`). -- **Status parity (real `SerializeAddon` round-trip):** in a renderer-side unit test (using a real `xterm` `Terminal` + `SerializeAddon`), write OSC 0/1/2 title bytes (e.g., `\x1b]0;some-title\x07`) into the terminal, await the `onTitleChange` listener, then call `serializeAddon.serialize()`. **Assert the serialize output does NOT contain the OSC title bytes** — this codifies the limitation that motivated the `lastTitle` field. Then, in a runtime-side unit test, mock the renderer-IPC response to include `{ data, cols, rows, lastTitle }` where `lastTitle` is a known agent title, fire `onPtyData`, and assert `applySeededAgentStatus` was called with that title and `leaf.lastAgentStatus` is the corresponding `'idle' | 'working' | 'permission'` value returned by `detectAgentStatusFromTitle` afterward. -- **`applySeededAgentStatus` does not resolve waiters:** with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call `applySeededAgentStatus(ptyId, title)` where `title` is a recognizable agent title. Assert `leaf.lastAgentStatus` matches `detectAgentStatusFromTitle(title)` (one of `'idle' | 'working' | 'permission'`), AND assert `resolveTuiIdleWaiters` and `deliverPendingMessages` were **NOT** invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths. - -### Integration (`mobile-subscribe-integration.test.ts`) - -- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the `scrollback` frame's `serialized` field includes the seeded prefix. -- Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.) - -### Manual - -On a real device, reproduce the original `edit-issues` post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows. - -## Open questions - -- **Hydration trigger point.** Three plausible options: (a) first `onPtyData`, (b) `runtime.registerPty`, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and **retryable within a runtime**. The state map is left absent (not `done` or `skipped`) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case is `headlessTerminals.has(ptyId)` (live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire from `pty-connection.ts → ipc/pty.ts → runtime`. - -- **Cooperation with `seedHeadlessTerminal`.** Today `seedHeadlessTerminal` writes to the headless emulator on `provider.spawn` from `result.snapshot` / `result.coldRestore.scrollback`. The daemon's `result.snapshot` is the visible-screen-only ANSI from `checkpoint.json` — almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first and `headlessTerminals.has(ptyId)` becomes true; the hydration guard would then mark itself `done` and the richer renderer payload is lost. **Decision: gate `seedHeadlessTerminal` on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey.** In `ipc/pty.ts`, before calling `runtime.seedHeadlessTerminal(...)`, check both `runtime.hasRendererSerializerFor(result.id)` AND `pendingByPaneKey.has(args.env?.ORCA_PANE_KEY)` (after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent before `pty:spawn` but cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted *or* about to mount. - -## Rollout - -Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share. - -Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design. diff --git a/docs/mobile-presence-lock.md b/docs/mobile-presence-lock.md deleted file mode 100644 index d328befcaaf..00000000000 --- a/docs/mobile-presence-lock.md +++ /dev/null @@ -1,698 +0,0 @@ -# Mobile Presence Lock for Desktop Terminal - -Design doc for repurposing the existing "phone-fit" banner into a general -presence-based interaction lock between the desktop renderer and mobile -clients sharing a single PTY. - -## Problem - -The desktop and mobile clients share full read/write access to the same -underlying PTY. There is no synchronization between desktop's xterm `onData` -(which calls `provider.write(ptyId, bytes)` via the `pty:write` IPC) and -mobile's `terminal.send` RPC (which also calls `provider.write` through the -runtime). When both parties type at the same time, bytes interleave at the -TTY level — `ls` and `pwd` typed concurrently can produce -`lpwsd` and execute unintended commands. - -The same is true for resize: desktop `pty:resize` and mobile -`terminal.resizeForClient` both reach `provider.resize(ptyId, ...)` without -coordination. The renderer-side `safeFit` has a partial guard, but the -`pty:resize` IPC handler does not check the override server-side. - -A teammate using the original mobile build reported "odd behavior using -laptop + mobile at the same time." The most visible symptom is the desktop -terminal jumping to phone dimensions; the more dangerous symptom is silent -input interleaving. - -## Today's banner - -The existing banner in `TerminalPane.tsx` is keyed on -`getFitOverrideForPty(ptyId)`: - -- It only appears when mobile is in `auto`/`phone` display mode AND a phone - resize actually happened. -- It says "Terminal resized for phone (W×H)" with a "Restore" button that - calls `runtime:restoreTerminalFit(ptyId)`. -- That IPC handler sets the display mode to `desktop` and applies the - display mode, which resizes the PTY back to desktop dims and clears the - override. The banner unmounts. - -In `desktop` display mode (mobile is subscribed but viewing at desktop dims) -there is no override and no banner — the desktop user has no idea mobile is -watching. - -## Goal - -Repurpose the banner into a general presence-based lock with this single -rule: - -> At any moment, each PTY has exactly one _driver_ — desktop, a specific -> mobile client, or nobody. While mobile drives, desktop input and resize -> are dropped; the desktop banner explains why and offers **Take back** to -> hand the floor over. While desktop drives, mobile keystrokes silently -> reclaim the floor (no mobile-side banner — see "Asymmetric UX" below). - -The lock exists at two layers: - -- **Renderer guard** (primary UX): `pty-connection.ts` drops xterm `onData` - and `onResize` while a mobile client is the driver. Banner explains why. -- **Server-side defense** (defense-in-depth): the `pty:write` and - `pty:resize` IPC handlers consult the runtime's driver state and drop - desktop-side calls while a mobile client is the driver. - -## Behavioral model - -### Driver state machine - -Instead of a binary lock, each PTY carries a tagged driver state. There is -exactly one driver per PTY at any moment, and transitions are atomic -(updated and emitted together from the runtime). - -```ts -type DriverState = { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } -``` - -- `idle` — no mobile subscribers; desktop input and resize flow through - normally. (We don't bother distinguishing "no one is here" from "only - desktop is here" since they behave identically; `idle` is the catch-all - for "nothing is currently locking the desktop out.") -- `desktop` — at least one mobile client is subscribed but the desktop has - reclaimed the floor; desktop input and resize flow through. -- `mobile{clientId}` — `clientId` is the mobile actor that most recently - drove this PTY. The banner is mounted on desktop; desktop input and - resize are dropped. - -Invariants: - -- `currentDriver(ptyId)` is always exactly one of the three kinds. -- On the main process, a transition mutates `currentDriver(ptyId)` and - emits `terminalDriverChanged(ptyId, driver)` in the same critical - section. The renderer's mirror in `mobile-driver-state.ts` is updated - only when the IPC arrives — there is a brief IPC-hop window during - which the renderer's `getDriverForPty(ptyId)` returns a stale value. - This is why the server-side `pty:write` / `pty:resize` defenses are - **load-bearing, not redundant**: a desktop keystroke fired during - that window passes the renderer guard and is dropped by the server - guard. -- Only one driver per PTY at any moment. Multiple mobile clients can - _subscribe_ simultaneously (see "Multi-mobile subscriber model"), but - only the most recent mobile actor is the driver. - -### Transitions - -| Current driver | Trigger | Next driver | Side effect | -| -------------- | ---------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | -| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims | -| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims | -| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — | -| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims | -| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — | -| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") | -| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts | -| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto | -| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) | -| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) | -| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims | -| `desktop` | mobile subscribes-fresh with `desktop` | `desktop` (no transition) | inner map updated; no banner | -| `desktop` | last mobile client unsubscribes | `idle` | (banner already unmounted) | - -**Subscribe-in-desktop-mode rule.** A mobile client subscribing in -`displayMode='desktop'` is treated as a passive watch, not a take-floor -gesture. The driver stays at `idle`/`desktop`, so the desktop user is not -interrupted. The instant that client (or any peer) sends input, sets the -display mode to `auto`/`phone`, or sends a resize, the runtime transitions -to `mobile{thatClient}` and the banner appears. This matches the rest of -the design: the lock engages on _interaction_, not on _presence_. - -The protocol is "first-mover wins until the other party acts." Desktop -clicks Take back, mobile types, desktop types again — banner ping-pongs as -each side acts. The `clientId` carried in `mobile{clientId}` is updated -each time a mobile actor takes the floor; this is the wire channel by -which the runtime knows _which_ phone last drove (useful for the -forward-path coordinator described at the bottom of this doc, and for the -multi-mobile semantics described below). - -### Take back and PTY dimensions - -Take back has two sub-cases: - -1. **Mobile was in `phone`/`auto` mode (PTY at phone dims)**. Take back - resizes PTY back to desktop dims (existing behavior of - `applyMobileDisplayMode('desktop')` when `wasResizedToPhone` is true). -2. **Mobile was in `desktop` mode (PTY already at desktop dims)**. Take - back is a pure lock-flag flip; no resize. Today's - `applyMobileDisplayMode('desktop')` already short-circuits the resize in - this case. - -In the symmetric direction, mobile reclaim: - -1. **Mobile is in `phone`/`auto` mode but PTY is at desktop dims** (because - desktop just clicked Take back): mobile reclaim re-applies the phone - resize via `applyMobileDisplayMode(currentMode)`. -2. **Mobile is in `desktop` mode**: pure lock-flag flip; no resize. - -## End-user UX - -| Driver | Overlay (desktop) | Desktop input | Desktop resize | -| ----------- | ------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | -| `idle` | hidden | allowed | allowed | -| `mobile{*}` | loud lock overlay with **Take back** and **Collapse**; collapsed chip keeps **Take back** visible | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side | -| `desktop` | hidden | allowed | allowed | - -Walkthroughs: - -**Phone connects while you're typing.** Overlay appears. PTY may resize to -phone dims (existing). Your next keystroke is dropped. Click Take back to -unlock and (if needed) restore desktop dims. - -**You click Take back.** Overlay gone. You type freely. Mobile stays -connected; mobile sees the desktop-sized terminal because PTY snapped back -(or stayed at desktop dims if mobile was in desktop mode). - -**Mobile types something while you're reclaimed.** Overlay reappears. Your -next keystroke is blocked. PTY may snap back to phone dims (if mobile is in -auto mode). - -**Mobile disconnects.** The active-driver overlay is gone. If the PTY is -still held at phone dimensions, the held-fit Restore overlay remains until -desktop restores or the finite auto-restore timer fires. - -**Multiple panes.** Driver state is per-pane. Phone on pane A doesn't -affect pane B. - -**Text selection / scrollback / copy.** Always allowed. The lock is -keystroke and resize only. - -**Output continues to render.** While the overlay is mounted, terminal -output streams to xterm normally — it is the mobile actor's bytes you -are seeing. Only desktop-side keystrokes and resize are dropped. This -matters because "your input is paused" can read ambiguously; output -flow is unaffected. - -## Asymmetric UX (accepted tradeoff) - -Mobile sees no banner in this PR. The mobile UI is visually unchanged: no -"Desktop is driving" indicator, no analog to the Take back affordance. -This is asymmetric and we are accepting it deliberately: - -- **Smaller mobile UI surface.** Mobile already has limited screen real - estate and a constrained component set; introducing a presence banner - there is a non-trivial design + i18n + dismissibility task that we want - to defer until the lock model has settled in production. -- **Faster ship.** Driver state machine + multi-mobile subscriber fix + - desktop banner is the smallest change that fixes the dangerous collision - (silent input interleaving). Adding a mobile-side banner doubles the UI - scope. -- **Mobile reclaim is naturally signaled.** When a mobile user types - while desktop drives, the runtime flips the driver to - `mobile{thatClient}`, the desktop banner remounts, and (if mobile is in - auto mode) the PTY snaps back to phone dims. The mobile user sees - the pane reflow and their keystrokes appear in the output stream. There - is no silent black-hole condition on mobile that a banner would - resolve. -- **Mobile keystrokes always reclaim implicitly.** The protocol on mobile - is "just type to take the floor." There is no mobile-side button to - surface; a banner with no actionable control is closer to noise than - signal. - -The flip side: a mobile user typing into a stale view _won't_ be told -"hold on, desktop is driving" before their keystroke lands. They just take -the floor. We consider that acceptable because (a) they're explicitly -acting, and (b) the desktop user gets the warning side of the tradeoff -where the risk of unintended action is much higher. - -If usage data later shows mobile users frequently being surprised by -desktop activity, a mobile banner can be added without changing the wire -format — `terminalDriverChanged(ptyId, driver)` already carries everything -needed. - -## Architecture - -### Where the truth lives - -The `mobileSubscribers` map (re-keyed; see "Multi-mobile subscriber model" -below) plus a new `currentDriver: Map` on the runtime -are the source of truth. The renderer learns the driver state via a new -IPC event `terminal-driver-changed(ptyId, driver: DriverState)` emitted -from the runtime through the existing notifier path. The structured -payload is intentional: a binary `locked: boolean` would lose the -`clientId` we need for multi-mobile semantics today and for the unified -write coordinator on the forward path. - -The runtime exposes `getDriver(ptyId): DriverState`, replacing the old -`isLocked(ptyId): boolean`. Server-side `pty:write` / `pty:resize` -defenses check `runtime.getDriver(ptyId).kind === 'mobile'` to drop -desktop-side calls. - -A new renderer module `mobile-driver-state.ts` mirrors -`mobile-fit-overrides.ts`: keyed by ptyId, supports -`getDriverForPty(ptyId): DriverState` and a subscribe-style change -listener. The `TerminalPane` banner mounts when -`getDriverForPty(ptyId).kind === 'mobile'`, and `pty-connection`'s -`onData`/`onResize` guards drop input/resize under the same predicate. - -### Why not reuse `getFitOverrideForPty`? - -The fit override only fires when the PTY was actually resized -(`mode='auto'` and `wasResizedToPhone=true`). It misses the -desktop-mode case where mobile is subscribed but no resize happened. The -driver state is broader than the fit override. - -### Why a new IPC event vs extending `terminal-fit-override-changed`? - -Cleaner separation. The fit override is about dimensions; the driver -state is about ownership. They happen to overlap in the auto/phone -subcase, but conflating them long-term ties future work on either to the -other. - -### Why a structured payload vs `locked: boolean`? - -The runtime needs to know _which_ mobile client most recently drove (for -multi-mobile semantics today and for the future write coordinator). A -structured `DriverState` payload makes that information first-class on -the wire instead of smuggling it through a side channel. The cost is ~70 -LoC over a binary lock; the payoff is a symmetric UX foundation and a -future-proof wire format. - -## Multi-mobile subscriber model - -The `mobileSubscribers` map is today shaped `Map` and -each `terminal.subscribe` call overwrites the previous subscriber for that -ptyId. That overwrite is fine for a binary `has(ptyId)` presence check but -breaks the moment we treat presence as a real set: phone B subscribing -silently evicts phone A, so phone A unsubscribing or its WS dropping looks -like "no mobile clients" even while phone B is still on the line. The -driver state machine sits directly on top of presence, so soundness here -is a prerequisite, not a polish. - -We rekey to `Map>` and update each -callsite: - -- **`handleMobileSubscribe(ptyId, clientId, subscriber)`** — get-or-create - the inner map, then `inner.set(clientId, subscriber)`. Do not overwrite - peer clients. If the inner map was empty before insert, this is the - first subscriber for the PTY; emit the `idle → mobile{clientId}` driver - transition. -- **`handleMobileUnsubscribe(ptyId, clientId)`** — `inner.delete(clientId)`, - then if the inner map is now empty, delete the outer entry, run the - existing restore-resize logic, and emit the `* → idle` driver - transition. If the inner map is non-empty, do **not** fire restore / - driver-change; peers still have the floor. -- **`applyMobileDisplayMode(ptyId)`** — iterate the inner map. The - restore-resize semantics need a single representative subscriber (the - desktop dims to restore _to_ are stored on the subscriber record). Pick - the earliest by subscribe time so the restore target is stable as - later phones come and go. The display mode itself is per-PTY runtime - state, not per-client. - - **Active phone-fit dim selection (multi-mobile).** When two or more - mobile clients subscribe with different viewports (e.g., iPhone + - iPad), the PTY can only be at one phone-fit size at a time. Rule: - **the most recent mobile actor's viewport wins**. This matches the - driver state machine — whoever last took the floor (`mobile{X}`) - also dictates the active phone-fit dims. When that client - unsubscribes, the next-most-recent surviving subscriber's viewport - wins; on the last client leaving, the inner map empties and we run - the existing restore back to the earliest-recorded desktop dims. We - do not use `min(cols, rows)` across subscribers because that - produces no clear principal — multiple TUIs are forced to render - for an aggregate viewport that nobody actually has, which is worse - than picking a single owner. Most-recent-actor is consistent with - the rest of the protocol (whoever acted last has the floor) and is - cheap to reason about. - - **Subscriber record extension.** "Most-recent actor" and - "earliest-by-subscribe-time" both require timestamps that today's - Subscriber record does not carry. Extend it with two fields: - - ```ts - interface Subscriber { - clientId: string - viewport: { cols: number; rows: number } - wasResizedToPhone: boolean - previousCols: number | null - previousRows: number | null - subscribedAt: number // ms since epoch, set on insert - lastActedAt: number // ms since epoch, init = subscribedAt; - // updated on every mobileTookFloor for this client - } - ``` - - - `applyMobileDisplayMode(ptyId)` for active phone-fit dims: - iterate the inner map, pick `argmax(lastActedAt)`, use that - subscriber's `viewport`. - - For restore-to-desktop semantics on last-client-leaves: pick - `argmin(subscribedAt)` **among subscribers with non-null - `previousCols`/`previousRows`** and use that subscriber's - `previousCols/Rows` as the restore target. Desktop-mode - subscribers carry `previousCols/Rows = null` by design (the - existing `handleMobileSubscribe` short-circuits dim capture for - desktop-mode joins to avoid capturing a stale full-width size), - so they are not viable restore targets. If no surviving - subscriber has captured dims, fall back to - `lastRendererSizes.get(ptyId)` (the desktop renderer's most - recent reported size) — same fallback the existing first-insert - capture path uses. - - Both operations are O(n) over the inner map, which is bounded by - the number of concurrently subscribed mobile clients (typically 1, - rarely 2-3). No indexing needed. - -- **`isMobileSubscriberActive(ptyId)`** — returns `true` iff the inner map - is non-empty. -- **Driver state for the mobile side** stores the `clientId` of the most - recent mobile actor. Whenever any mobile client sends - input/resize/setDisplayMode/subscribes-fresh, that client becomes the - `currentDriver` (`mobile{thatClient}`). Phone B taking over from phone A - is an internal mobile→mobile transition: no banner change, but the - recorded `clientId` updates so the runtime always knows who is the - authoritative mobile actor. - -The semantic upshot is that the desktop banner is governed by "is the -inner map non-empty AND has the desktop not reclaimed?" rather than the -old single-slot heuristic, and Take back / mobile-reclaim correctly -ping-pong even when two phones are on the same PTY. - -## Implementation - -Files touched: - -1. `src/main/runtime/orca-runtime.ts` — rekey `mobileSubscribers` to - `Map>`, add `currentDriver` map, - `getDriver(ptyId)` getter, transition methods, notifier hook. Update - `handleMobileSubscribe` / `handleMobileUnsubscribe` / - `applyMobileDisplayMode` / `isMobileSubscriberActive` per the - multi-mobile model above. **Extend `onPtyExit` cleanup** to - `currentDriver.delete(ptyId)` and emit `terminalDriverChanged(ptyId, -{ kind: 'idle' })` so any banner mounted on the dead pane unmounts. - Without this, a dead PTY's last driver state lingers and the - renderer banner could persist on a dead pane until tab teardown. -2. `src/main/runtime/orca-runtime.ts` interface `RuntimeNotifier` — add - `terminalDriverChanged(ptyId, driver: DriverState)`. -3. `src/main/window-manager.ts` (or wherever `RuntimeNotifier` is wired) — - forward to renderer via `webContents.send('terminal-driver-changed', …)`. -4. `src/preload/index.ts` + `src/preload/api-types.ts` — expose the new - event with the structured `DriverState` payload. -5. `src/main/ipc/runtime.ts` — add `runtime:reclaimTerminal` IPC (or - extend `runtime:restoreTerminalFit`) which drives the - `mobile{*} → desktop` transition. -6. `src/main/runtime/rpc/methods/terminal.ts` — drive the - `* → mobile{clientId}` transition in `terminal.send`, - `terminal.resizeForClient`, `terminal.setDisplayMode`, and the - subscribe handler (with the subscribe-in-desktop-mode exception - above). - - **Wire-format changes required.** The driver state machine tags - the active mobile actor with `clientId`, so every mobile RPC method - that can take the floor must carry the caller's identity. Today - only `terminal.subscribe` and `terminal.resizeForClient` do; we - extend the others. - - **Canonical identity shape.** Use the existing - `terminal.subscribe` shape: `client: { id: string; type: 'mobile' -\| 'desktop' }` (with `type` optional). `TerminalResizeForClient` - keeps its grandfathered top-level `clientId: string` field for - backward compatibility — implementers may add an aliased nested - `client.id` setter for consistency at the call site, but the wire - shape is preserved. - - New fields on existing schemas: - - `TerminalSend` gains `client: { id: string; type?: 'mobile' \| -'desktop' }` (optional for backward compatibility — falls back - to "the most recent mobile actor" when absent). - - `TerminalSetDisplayMode` gains the same `client` field. - - `TerminalUnsubscribe` gains `client: { id: string }` so the - server can derive the composite cleanup key (see below); the - existing `subscriptionId` field is kept and remains the primary - lookup, with `client.id` used only when the caller passed a - bare-handle `subscriptionId`. - - **Subscribe-side composite key.** Change `subscriptionId` from - `params.terminal` to `${params.terminal}:${params.client.id}` so - two phones subscribing to the same terminal handle do not evict - each other via `registerSubscriptionCleanup`. Without this fix, - the multi-mobile rekey to `Map>` - would be silently defeated at the RPC subscription layer (phone - B's subscribe runs phone A's cleanup → A's data listener tears - down → A's `handleMobileUnsubscribe` fires). - - **Unsubscribe-side composite key.** The mobile RPC client today - emits `terminal.unsubscribe` with `params: { subscriptionId: -stream.params.terminal }` — the bare handle. With the subscribe - side now keying by composite, a bare-handle unsubscribe will miss - in `subscriptionCleanups.get(bareHandle)` and silently no-op, - leaking the data listener and leaving `mobileSubscribers` - populated forever (banner stuck, driver never returns to `idle`). - Fix: the mobile RPC client emits `{ subscriptionId: -${terminal}:${clientId}, client: { id: clientId } }`, and the - server's `cleanupSubscription` uses whichever the caller sent — - if both `subscriptionId` and `client.id` are present and the - caller passed only `terminal` in `subscriptionId`, the server - reconstructs the composite key from `client.id`. Belt-and-braces - so a stale older mobile build still cleans up correctly. - - This is a coordinated client/server wire-format change. The - mobile app must ship in lockstep; if a stale mobile client emits - bare-handle unsubscribe to a new server, the server's - reconstruction path catches it. If a new mobile client emits - composite-handle unsubscribe to a stale server, the stale server - ignores `client.id` and the composite key fails to match — but - that direction is moot because the stale server has not adopted - composite keys on subscribe either. No client newer than server, - so this is safe. - -7. `src/main/ipc/pty.ts` — defense in depth: drop `pty:write` and - `pty:resize` calls when - `runtime.getDriver(id).kind === 'mobile'`. **Preserve the existing - `runtime.isResizeSuppressed()` short-circuit at the top of the - `pty:resize` handler** — the new driver-state guard is _in - addition to_, not in place of, the suppression window. The two - guards have different purposes: `isResizeSuppressed()` blocks the - safeFit cascade after a take-back transition (preventing - collateral resize corruption of background panes), while the - driver-state check blocks desktop-side resizes whenever mobile is - driving. Both must apply. -8. `src/renderer/src/lib/pane-manager/mobile-driver-state.ts` — new - renderer store mirroring `mobile-fit-overrides.ts`. Exposes - `getDriverForPty(ptyId): DriverState` and a change listener. -9. `src/renderer/src/components/terminal-pane/pty-connection.ts` — guard - `onData` and `onResize` on - `getDriverForPty(ptyId).kind === 'mobile'`. -10. `src/renderer/src/components/terminal-pane/TerminalPane.tsx` — switch - banner to consume driver state, update copy, rename Restore → Take - back. - -### Server-side detail - -```ts -// orca-runtime.ts -private currentDriver = new Map() - -getDriver(ptyId: string): DriverState { - return this.currentDriver.get(ptyId) ?? { kind: 'idle' } -} - -private setDriver(ptyId: string, next: DriverState): void { - this.currentDriver.set(ptyId, next) - this.notifier?.terminalDriverChanged(ptyId, next) -} - -reclaimTerminalForDesktop(ptyId: string): void { - if (!this.isMobileSubscriberActive(ptyId)) return - // Snap PTY back to desktop dims if currently at phone dims. - // applyMobileDisplayMode is a no-op resize when already at desktop dims. - this.setMobileDisplayMode(ptyId, 'desktop') - this.applyMobileDisplayMode(ptyId) - this.setDriver(ptyId, { kind: 'desktop' }) -} - -private mobileTookFloor(ptyId: string, clientId: string): void { - // If mobile is in phone/auto mode, re-apply phone resize on the first - // hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops - // for resize. - const prev = this.getDriver(ptyId) - if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId) - this.setDriver(ptyId, { kind: 'mobile', clientId }) -} - -handleMobileUnsubscribe(ptyId: string, clientId: string) { - const inner = this.mobileSubscribers.get(ptyId) - if (!inner) return - inner.delete(clientId) - if (inner.size > 0) return // peer mobile clients still drive - this.mobileSubscribers.delete(ptyId) - // Drive transition fires synchronously so the desktop banner - // unmounts immediately. The PTY restore runs inside the existing - // 300ms pendingRestoreTimers debounce — see "Restore-debounce - // window" under Edge cases. - this.setDriver(ptyId, { kind: 'idle' }) - // Existing restore-resize logic (300ms debounced timer) runs here. -} -``` - -The mobile-took-floor path is invoked from the RPC method handlers (not -from `sendTerminal` directly) so that the driver flip is bound to -deliberate mobile actions, not to internal runtime calls. - -### Renderer guards - -```ts -// pty-connection.ts onData -if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') { - return // Mobile is driving this PTY; banner explains. -} -// existing transport.sendInput(data) - -// pty-connection.ts onResize -// Why: keep both predicates. getFitOverrideForPty fires synchronously -// in the renderer the moment safeFit runs; getDriverForPty arrives via -// IPC and lags by one round-trip. Removing the fit-override predicate -// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides -// was added to prevent. -if ( - currentPtyId && - (getFitOverrideForPty(currentPtyId) || getDriverForPty(currentPtyId).kind === 'mobile') -) { - return -} -``` - -### Overlay copy - -The lock UI is a full-pane overlay (not a thin banner) so users cannot -miss it. The driving state can be collapsed to a corner chip while the -session is being watched; the held-fit state stays loud because there is -no live output to monitor (see `MobileDriverOverlay.tsx`). - -Driving state, expanded: - -- Eyebrow: `Mobile is driving this terminal` -- Title: `Your keyboard is paused` -- Body: `Output below is being typed from your phone. Take back to resume typing on the desktop, or collapse to keep watching.` -- Buttons: `Collapse`, `Take back` - -Driving state, collapsed (corner chip): `● Mobile driving [ Take back ]`. -Clicking the label re-expands. - -Held-at-phone-fit (no active mobile subscriber, PTY still at phone dims): - -- Eyebrow: `Held at phone size` -- Title: `This terminal is sized for your mobile app` -- Body: `The session is still being held at the dimensions your phone last reported. Restore to use it on your desktop.` -- Button: `Restore desktop size` - -There is no auto-unlock-on-WS-silence and no liveness probing; the overlay -stays up until the desktop user takes back / restores, until mobile actor -sends input (which keeps mobile as driver), or until the last mobile -subscriber disconnects. - -## Edge cases - -- **Mobile drops network**. WS doesn't immediately know. The inner - subscriber map retains the entry for ~30s until ping timeout. Banner - stays. Take back still works during this window — it transitions to - `desktop` immediately. When mobile eventually reconnects with - `terminal.subscribe`, that client takes the floor again - (`desktop → mobile{thatClient}`). -- **Two phones on same PTY**. The inner map carries both subscribers. - Driver state is `mobile{whicheverActedLast}`. Phone A unsubscribing - while phone B is still on does _not_ drop the banner; only the last - client leaving the inner map transitions to `idle`. -- **Pre-locked input in flight**. `transport.sendInput` is - fire-and-forget; there is no in-flight queue to drain. The first - dropped keystroke is on the renderer side, which is good enough. -- **Take back while phone is offline**. Works (transitions to `desktop`). - When phone reconnects with a fresh subscribe, driver flips to - `mobile{thatClient}`. -- **Display mode toggle from mobile**. `terminal.setDisplayMode` is - treated as mobile interaction → that client takes the floor. Aligns - with "any deliberate mobile action takes the floor." -- **Subscribe from a fresh client (reconnect with new clientId)**. The - subscribe path runs the `desktop → mobile{thatClient}` (or - `idle → mobile{thatClient}`) transition. A mobile reconnect takes back - the floor from desktop — correct semantic since the user actively - reopened the mobile view. -- **Held-fit window on last-subscriber-leaves**. When the last mobile - subscriber unsubscribes, the driver transitions to `idle` - synchronously. The active-driver overlay swaps to the held-fit Restore - overlay while the phone-fit override remains. With the default - indefinite hold, that Restore overlay stays up until desktop restores; - with finite auto-restore, it stays up until the timer restores the PTY - back to desktop dims. This keeps the "why is my terminal phone-sized?" - explanation visible even after the mobile driver is gone. - -## Tests - -- `orca-runtime.test.ts` (driver state machine): - - Every transition row in the table above has a unit test asserting - `(prev, trigger) → (next, side effect, emitted event payload)`. - - `getDriver` defaults to `{ kind: 'idle' }` for unknown ptyIds. - - `terminalDriverChanged` is emitted exactly once per state change and - carries the structured `DriverState` payload (not a boolean). -- `orca-runtime.test.ts` (multi-mobile subscriber sequencing): - - Phone A subscribes (`auto`) → driver is `mobile{A}`, banner emit - fires. - - Phone A subscribes (`desktop`) from `idle` → driver stays `desktop` - (subscribe-in-desktop-mode does not take the floor); inner map - has A. - - Phone B subscribes (`auto`) while A is still on (`auto`) → driver - flips to `mobile{B}` (subscribe-fresh-with-auto/phone counts as - take-floor); inner map has both subscribers; active phone-fit - dims switch to B's viewport. - - Phone B unsubscribes while A is still on → driver remains mobile; - active phone-fit dims revert to A's viewport (next-most-recent - actor); banner stays up; no `idle` transition. - - Phone A unsubscribes (last client leaves) → driver transitions to - `idle`, banner unmounts, PTY restores to earliest-recorded - desktop dims. - - `applyMobileDisplayMode` picks the **most-recent-actor's** - viewport for active phone-fit dims and the - **earliest-by-subscribe-time** desktop dims for restore. - - Mobile sets `displayMode` to `desktop` from `mobile{*}` does _not_ - transition to `desktop` automatically (existing setDisplayMode - semantics stand) — but the runtime's _driver_ transition rule for - `desktop → desktop` (mode change to desktop while already in - desktop driver) is a no-op. -- `terminal.test.ts` (subscriptionId per-client keying): - - Phone A subscribes to terminal handle `T`, phone B subscribes to - the same handle `T` → A's data listener still receives bytes - after B's subscribe (subscriptionId is `${T}:${clientId}` so - `registerSubscriptionCleanup` does not collide). - - Regression: prior to the fix, the second subscribe ran the first - subscribe's cleanup and tore down phone A's stream. -- `pty-connection.test.ts`: - - `onData` is dropped while `getDriverForPty(id).kind === 'mobile'`. - - `onData` is delivered when driver flips to `desktop` or `idle`. - - `onResize` is dropped while driver is `mobile`. -- `pty.test.ts` (or new `pty-driver-state.test.ts`): - - `pty:write` IPC is dropped when `runtime.getDriver(id).kind === -'mobile'`. - - `pty:resize` IPC is dropped under the same predicate. - -## Out of scope - -- Mobile yanking desktop tab focus via `terminal.focus`. -- Cold-restore ack from mobile-only attach. -- Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above). - -These are independent collisions tracked separately. - -## Forward path - -The natural next step (not in this PR) is unifying both writers — the -desktop renderer's `pty:write` IPC and the mobile RPC's `terminal.send` -— into a single coordinator that queues bytes through a runtime-owned -write path. The driver state machine cleanly supports this: the -coordinator can use `currentDriver(ptyId)` as the admission predicate -(only the current driver's bytes are dequeued), and the -`mobile{clientId}` payload tells it which queue head to drain when a -specific phone is the active actor. Today's PR keeps two independent -write paths and uses driver state purely as a drop-filter; the same -state machine becomes the scheduling input on the forward path without -a wire-format change. - -## Rollout - -Single PR. No feature flag needed; behavior is strictly additive (locks -an existing collision surface) and the existing banner UX continues to -work under the new driver-state model with sharper copy. diff --git a/docs/mobile-shared-client-per-host.md b/docs/mobile-shared-client-per-host.md deleted file mode 100644 index e9a8cee4d19..00000000000 --- a/docs/mobile-shared-client-per-host.md +++ /dev/null @@ -1,406 +0,0 @@ -# Single Shared RPC Client per Host (Mobile) - -Design doc for collapsing the per-screen WebSocket connection model into a -single shared `RpcClient` per paired host, owned by a React context that -sits above the route tree. - -## Problem - -The mobile app today opens **one WebSocket per screen per host**: - -| Screen | Connections per host | -|---|---| -| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) | -| Host detail (`app/h/[hostId]/index.tsx`) | +1 | -| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 | -| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 | -| Pair confirm (briefly) | +1 | - -A user actively browsing one host typically holds **3–4 simultaneous -sockets** to the desktop runtime. Each call to `connect()` runs its own -E2EE handshake, allocates an ephemeral keypair, and runs an independent -reconnect loop with exponential backoff. - -This causes three observable problems: - -1. **Stuck-connecting / reconnecting for minutes.** The desktop's - `MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who - navigates rapidly accumulates stale sockets faster than they can be - reaped by TCP keepalive (which can take 60–300s on default systems - for half-open connections from a phone leaving Wi-Fi range or - backgrounding). Once the cap is hit, new sockets are rejected with WS - close code `1013 Maximum connections reached`. The mobile reconnect - loop does not recognize 1013 as terminal — it retries with backoff, - each retry also dropped, until enough stale sockets are reaped. - Result: a screen that should connect in <1s is stuck for 1–5 minutes. -2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')` - awaits `waitForConnected()`. If the session-screen client is in - `connecting`/`reconnecting` state because its socket lost the cap - race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires. - The user sees nothing happen. -3. **Triple cost on every cold-start.** Three E2EE handshakes, - three Curve25519 keypair generations, three subscription - re-registrations on every app launch. On low-end Android, this - visibly delays first paint by hundreds of milliseconds. - -The architecture also wastes server resources: each socket carries its -own E2EE channel, its own subscription set, its own driver-state-machine -client identity (cf. `docs/mobile-presence-lock.md`). The server already -has logic to reconcile multi-socket-per-token tear-down -(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because* -this design forced the question; with a single client per host, it becomes -unnecessary. - -## Today's transport ownership - -Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`: - -``` -mobile/app/pair-confirm.tsx # one-shot during pairing -mobile/app/pair-scan.tsx # one-shot during pairing -mobile/app/index.tsx # N (one per paired host) -mobile/app/h/[hostId]/index.tsx # 1 (host detail) -mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session) -mobile/app/h/[hostId]/accounts.tsx # 1 (accounts) -``` - -Each owns a `useRef` and calls `client.close()` from a -cleanup function. The home screen additionally maintains a -`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host -client survives across navigation events. - -This pattern works for *correctness* — every cleanup eventually closes -its socket — but it breaks under three real-world conditions: - -1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup - `client.close()` runs after a new screen has already opened a fresh - socket to the same host. Two sockets briefly coexist for the same - token, multiplied across screens. -2. **Network drops.** A backgrounded/locked phone on a flaky network - leaves sockets half-open. The server doesn't get a FIN; cleanup - relies on TCP keepalive timing. Meanwhile the foreground app, on - resume, opens fresh sockets. The half-open ones eat the cap until - reaped. -3. **Hot reload during dev.** Each Metro hot reload fires a new render - tree without unmounting the old, so connections leak. - -## Goal - -> A paired host has at most **one active WebSocket** at any time, owned -> by a context provider above the route tree. All screens for that host -> share that client. The pair flows are the only places that create -> short-lived clients (and they explicitly close those after pairing -> completes). - -This is the architectural fix to the symptoms above. Combined with the -two recently-shipped hotfixes (token cache + stable `useEffect` -dependency on home screen), this completes the connection-lifecycle -work. - -## Design - -### Layered ownership - -``` -RootLayout () -└── routes - └── // mounts when route has hostId - ├── h/[hostId]/ // host detail - ├── h/[hostId]/session/[worktreeId]/ // session - └── h/[hostId]/accounts/ // accounts -``` - -Two providers, layered: - -1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host, - keyed by `hostId`. Lifecycle: opens on first request for that - host's client, holds open until app shutdown OR until the host is - removed (`removeHost(hostId)` triggers explicit close). Reuses - existing `loadHosts()` cache from the recently-merged - `host-store.ts` work. -2. **`HostScopedClientGate` (per host)** — a thin route-layout - component placed at `app/h/_layout.tsx`. Reads `hostId` from - route params, requests the client for that host from the root - provider, exposes it via context to descendants, and renders a - loading state until the client reaches `connected`. Guarantees - every descendant screen sees the same client instance for that - host — no per-screen `connect()` calls. - -The home screen (`app/index.tsx`) lives outside `HostScopedClientGate` -since it spans all hosts; it consumes the root provider directly via -a multi-host hook (see API below). - -### API - -```ts -// New file: mobile/src/transport/client-context.tsx -type RpcClientContext = { - // Get-or-open. Returns the singleton client for hostId; opens it - // lazily on first call, reuses it for all subsequent callers. Never - // returns null (returns a placeholder client in 'connecting' state - // if open hasn't completed). - getClient: (hostId: string) => RpcClient - // Connection state for a given host (driven by client.onStateChange). - useHostState: (hostId: string) => ConnectionState - // Useful for the home screen which renders all hosts at once. - useAllClients: () => Array<{ hostId: string; client: RpcClient }> -} - -export const RpcClientProvider: React.FC<{ children: React.ReactNode }> - -export const useHostClient: (hostId: string) => { - client: RpcClient - state: ConnectionState -} -``` - -Internal store (single `useRef` in the provider): - -```ts -type StoreEntry = { - client: RpcClient - state: ConnectionState - refCount: number // number of active screens holding this client - closeTimer: NodeJS.Timeout | null -} -const store = useRef(new Map()) -``` - -### Lifecycle rules - -1. **Open on first read.** First `getClient(hostId)` call for a host - reads the host record (uses cached `loadHosts()`), then calls - `connect()` and stores the client. Subsequent calls return the - cached entry. -2. **Idle close timer.** When `refCount` drops to 0 (all screens for - that host unmounted), schedule a 30-second close timer. If a screen - for the same host mounts within 30s, cancel the timer. Otherwise, - close the client and remove from the store. - - Why 30s: covers fast tab-switching and back-navigation without - keeping idle sockets forever. Tunable based on observed behavior. -3. **Forced close on host removal.** `removeHost(hostId)` from - `host-store.ts` calls into the provider to close the client - immediately and delete the store entry. -4. **App backgrounded.** No special action — let TCP keepalive and - server-side reaping handle it. Reconnect happens on foreground. -5. **App foregrounded.** Trigger a `getState()` poll on every - non-closed entry; if any are in `disconnected` (TCP died while - backgrounded), the existing reconnect loop handles it. No new - client allocations. - -### Public surface for screens - -Each screen replaces: - -```ts -// Before -const [client, setClient] = useState(null) -const [connState, setConnState] = useState('disconnected') - -useEffect(() => { - let rpcClient: RpcClient | null = null - void (async () => { - const hosts = await loadHosts() - const host = hosts.find((h) => h.id === hostId) - if (!host) return - rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) - setClient(rpcClient) - })() - return () => { - rpcClient?.close() - } -}, [hostId]) -``` - -with: - -```ts -// After -const { client, state } = useHostClient(hostId) -``` - -Total LoC reduction across screens: ~150 lines. - -### Pair flow exception - -`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()` -directly with **explicit `client.close()`** after the test request returns. -Reason: the host record doesn't yet exist in `loadHosts()` during pairing, -so the provider has nothing to look up. The pair flow's client is a -short-lived transient that delivers `getStatus()` once and then dies. - -After `saveHost()` succeeds, the user is navigated away; the next time -they enter `/h/[hostId]/...`, the provider opens a fresh client through -the normal path. - -### Streaming subscription handling - -The home screen's `accounts.subscribe` stream and the session screen's -terminal subscriptions remain owned by their respective screens — the -provider doesn't manage subscriptions, only the underlying transport. -Each screen's effect calls `client.subscribe(...)` and stores the -returned unsubscribe function. On unmount, the screen unsubscribes -(returns to the existing per-screen pattern, just over a shared -transport). The transport's `subscribe()` already correctly multiplexes -multiple listeners on one WebSocket via the `id` field. - -### State propagation - -`useHostState(hostId)` returns the live `ConnectionState`. The provider -maintains a per-host `useState` keyed by hostId; the `client.onStateChange` -listener is wired once at client creation and updates the corresponding -state slot. `useHostState` reads from this state via `useSyncExternalStore` -or a context selector — the choice is mostly preference; in this -codebase, given the small state shape, a simple `useContext + useMemo` -of the matching slot is fine. - -## Migration - -Step-by-step, each step independently shippable: - -1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet. - Wire into `app/_layout.tsx`. Existing screens unchanged. -2. **Migrate session screen** (highest-risk, most-used). Replace - per-screen `connect()` with `useHostClient`. Test connection - behavior, terminal create/delete, scrollback hydration. -3. **Migrate host detail and accounts screens.** Same pattern. -4. **Migrate home screen.** Replace `clientsRef` with - `useAllClients()`. The home screen's per-host streaming - subscriptions move into a hook that runs per-host. -5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize - the gate and remove duplicated loading-state logic. -6. **Delete legacy code.** Remove the dead `connect()` import paths - from each screen. Codepoint reduction. -7. **Remove server-side `hasOtherConnections` complexity** in a - follow-up: with one socket per token, the multi-socket reconciliation - in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies. - This is a desktop-side cleanup PR done after mobile rolls out. - -Each step is tested in isolation; rollback per step is trivial. - -## Risks - -### R1: Connection loss while screens are mounted - -**Risk.** Today, when a screen unmounts due to network loss, its -client closes and reopens on remount. Under the new design, a -network-loss-during-screen-mounted means the client lives but is in -`reconnecting` state. - -**Mitigation.** The existing `RpcClient` already handles this — its -internal reconnect loop runs invisibly. Screens already render based -on `connState === 'connected'`, so they stay in their `connecting` -UI until the loop succeeds. No regression. - -### R2: One bad host poisons the singleton - -**Risk.** If the client for one host is wedged in `reconnecting` due -to a desktop-side issue, all screens for that host inherit the wedged -state. Under the per-screen design, navigating to a different screen -gave a fresh client with a chance to connect cleanly. - -**Mitigation.** "Force reconnect" affordance: a button on the host -detail "Connection issues" UI calls -`provider.forceReconnect(hostId)` — close + reopen the client. Users -who hit a stuck state get a one-tap recovery without uninstalling. -Implemented as part of step 2. - -### R3: Idle close timer races - -**Risk.** A user navigates from session → home → back to session -within 35 seconds. The 30s idle timer fires between hops and closes -the client; the back-navigation has to wait for a fresh handshake. - -**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at -mount time. As long as the consumer holds a reference to the client, -the timer is paused. Standard refcount pattern. - -### R4: Memory / state leak on rapid host removal - -**Risk.** User removes a host while a screen for it is mounted. -The screen's reference is now dangling. - -**Mitigation.** `removeHost(hostId)` triggers an explicit close + -delete from the store. Screens already handle `auth-failed` / -`disconnected` states (the client transitions to one of them on -forced close). Add a navigation-bounce in those states so the screen -returns to the host list. - -### R5: Pair-flow socket leaks through provider - -**Risk.** If pair-confirm crashes mid-handshake before its explicit -`close()`, the socket leaks. - -**Mitigation.** Independent of the provider — same risk exists today. -Add a try/finally + cleanup useEffect in pair-confirm. - -### R6: Provider re-initialization on hot reload (dev only) - -**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly -spawning new clients while old ones are still in the store. - -**Mitigation.** On provider mount, scan the store for entries whose -clients report `closed` state and prune. Acceptable dev-only friction. - -## Test Plan - -### Unit / hook tests - -- `useHostClient` returns the same client instance across multiple - consumers for the same hostId. -- `removeHost(hostId)` immediately closes the client (assert via - `client.getState()`). -- Idle close timer: zero refcount → 30s wait → client closed. -- Idle close timer cancellation: zero refcount → 15s wait → - consumer subscribes → no close. - -### Integration / manual - -- Create one host; navigate home → host detail → session → back ×10 - rapidly. Single socket on desktop (verify via desktop debug log). -- Background app for 5 min; foreground; verify reconnect uses same - client instance, no leak. -- Remove host while session screen mounted; screen bounces back to - home; client closed. -- Force-reconnect button on host detail; client closes and reopens - cleanly. -- Hot reload during development; no socket leak (verify desktop - active-connection count). - -### Regression - -- Terminal create/delete works during normal browse (Bug A from - initial reports — should never recur once cap pressure is removed). -- Scrollback hydration unchanged. -- Phone-fit / driver-lock state machine unchanged - (`docs/mobile-presence-lock.md` invariants hold). - -## Out of scope - -- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth - but not needed once mobile self-limits to one socket per host. -- **Application-level ping/pong.** Worth adding but separate concern; - helps server reap dead sockets faster regardless of how many a single - client opens. -- **iOS share extension or system-wide deep-link integration.** Future - product work. - -## Effort estimate - -3–4 hours including tests and incremental migration. Each step -independently mergeable. - -## References - -- `docs/mobile-presence-lock.md` — driver-state-machine that depends - on per-client identity. Single-client-per-host simplifies but - doesn't break this contract. -- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration - flow. Subscriptions remain per-screen; transport changes are - transparent. -- `mobile/src/transport/rpc-client.ts` — existing `connect()` - implementation; reconnect loop, E2EE handshake, subscription - multiplexing all preserved as-is. -- `src/main/runtime/rpc/ws-transport.ts` — server-side - `MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation - that becomes simpler post-migration. diff --git a/docs/quick-open-gitignored-files.md b/docs/quick-open-gitignored-files.md deleted file mode 100644 index 09c2119be8f..00000000000 --- a/docs/quick-open-gitignored-files.md +++ /dev/null @@ -1,76 +0,0 @@ -# Quick Open Gitignored Files - -## Problem - -Cmd/Ctrl+P Quick Open does not list arbitrary gitignored files in the active workspace. - -- `src/renderer/src/components/QuickOpen.tsx:257` calls `window.api.fs.listFiles(...)`; filtering in the renderer only fuzzy-matches the returned list, so missing files are already absent from the backend result. -- `src/main/ipc/filesystem-list-files.ts:46` uses `buildRgArgsForQuickOpen(...)` for local worktrees, then merges the primary rg pass with `envPass`. -- `src/relay/fs-handler-list-files.ts:37` uses the same shared rg args for SSH worktrees. -- `src/shared/quick-open-filter.ts:252` builds the primary rg pass without `--no-ignore-vcs`, so gitignored files are hidden. -- `src/shared/quick-open-filter.ts:265` builds a second `--no-ignore-vcs` pass, but it is restricted to `.env*` and `**/.env*`. -- `src/shared/quick-open-filter.ts:357` builds the git fallback primary pass with `--exclude-standard`; `src/shared/quick-open-filter.ts:365` only adds `.env*` pathspecs. - -## Root Cause - -Quick Open intentionally mirrors `rg --files --hidden` with gitignore respect, then adds a special-case pass for gitignored `.env*` files. That policy lives in shared code used by local main-process listing, SSH relay listing, and git fallback listing, so non-env ignored files are excluded consistently in every workspace type. - -## Non-goals - -- Do not change right-sidebar text search behavior. -- Do not add a new UI preference or mode switch. -- Do not follow symlinks or broaden traversal outside the authorized workspace root. -- Do not surface heavy generated directories already excluded by Quick Open policy, including `node_modules`, `.git`, `.cache`, `.next`, and other `HIDDEN_DIR_BLOCKLIST` entries. -- Do not change fuzzy ranking, selection, file-opening behavior, or shortcut handling. - -## Design - -1. Replace the `.env*` rg second pass with a general ignored-files pass. - - Keep `--files --hidden --no-ignore-vcs`, hidden-dir blocklist globs, nested-worktree exclude globs, `searchRoot='.'`, and path-separator handling. - - Remove positive `.env*` globs so the second pass is no longer a whitelist. - - Keep the primary pass unchanged. - -2. Rename pass naming from `envPass` to `ignoredPass` in shared/main/relay code. - - This is a behavior change, not cosmetic: the second pass now returns all gitignored candidates. - -3. Replace git fallback `.env*` pass with ignored-files pass. - - Keep primary as `['--cached', '--others', '--exclude-standard', ...]`. - - Use ignored pass as `['--others', '--ignored', '--exclude-standard', ...]`. - - Keep nested-worktree pathspec exclusion semantics (`--`, `.`, excludes when exclude prefixes exist). - - Do not assume both runtimes have identical error semantics today: - - local main fallback currently resolves on most git failures; - - relay fallback rejects on spawn/signal failures. - Preserve each runtime’s current behavior unless explicitly changing it in a separate doc. - -4. Keep post-filters unchanged. - - `shouldIncludeQuickOpenPath` remains final blocklist enforcement. - - `shouldExcludeQuickOpenRelPath` remains nested-worktree correctness backstop. - - Set-based merge still dedupes cross-pass overlap. - -5. Do not change renderer request-lifecycle logic in this doc. - - Current `QuickOpen.tsx` effect cleanup cancels the prior request before the next effect body runs. - - This change is backend listing policy only. - -6. Update tests. - - `quick-open-filter.test.ts`: assert rg ignored pass has `--no-ignore-vcs` and no `.env*` globs; assert git ignored pass has `--others --ignored --exclude-standard` and no `.env*` pathspec whitelist. - - `filesystem-list-files.test.ts`: update pass-detection helpers that currently key off `'**/.env*'`; cover ignored non-env files; keep local fallback’s resolve-on-failure behavior unchanged. - - Add relay coverage in `src/relay/fs-handler.test.ts` (or new focused relay tests) for ignored-pass args and current reject-on-signal behavior. - -## Edge Cases - -- Gitignored files inside `node_modules`, `.git`, `.cache`, `.next`, `.npm`, `.npm-global`, `.gvfs`, and other blocklisted dirs must remain hidden. -- Nested linked worktree paths passed as `excludePaths` must remain excluded from both rg and git passes. -- Local/SSH candidate sets should match when both run the same backend path (rg or git) and complete successfully; timeout/error behavior remains intentionally different today. -- Windows and WSL path normalization must remain unchanged; output still passes through `normalizeQuickOpenRgLine`. -- If rg is unavailable, git fallback should include ignored files only when Git can enumerate them; non-git roots keep existing fallback limits. -- Timeout/signal behavior must not regress into partial false-empty results. -- Keep existing timeout asymmetry unless intentionally changed: local rg/git fallback uses 10s timeouts, relay rg uses 25s. -- `--no-ignore-vcs` also includes files ignored by parent/global excludes; blocklists are the guardrail against accidental heavy trees. - -## Rollout - -1. Update `src/shared/quick-open-filter.ts` types, rg args, git args, and comments. -2. Update local main-process and SSH relay callers/tests for `ignoredPass`. -3. Run focused tests for quick-open filters and list-files (main + relay). -4. Run `pnpm typecheck` and `pnpm lint`. -5. Validate in Electron on local + SSH worktrees with gitignored non-env files and nested linked worktrees. diff --git a/docs/README.es.md b/docs/readme/README.es.md similarity index 67% rename from docs/README.es.md rename to docs/readme/README.es.md index 1147e3b7bc8..2f54e1f459f 100644 --- a/docs/README.es.md +++ b/docs/readme/README.es.md @@ -1,5 +1,5 @@

- Orca Orca + Orca Orca

@@ -9,12 +9,12 @@

- English · 中文 · 日本語 · 한국어 · Español + English · 中文 · 日本語 · 한국어 · Español

El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
+ Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.

@@ -23,7 +23,7 @@

- Captura de Orca + Captura de Orca

## Agentes compatibles @@ -31,8 +31,9 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).

- Claude Code   + Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -46,7 +47,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*). Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   Kimi   @@ -102,7 +103,7 @@ yay -S stably-orca-git Controla tus agentes desde el teléfono.

- Orca de escritorio con la app companion móvil + Orca de escritorio con la app companion móvil

- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) @@ -115,19 +116,19 @@ Controla tus agentes desde el teléfono. Haz clic en cualquier tarjeta para explorar el flujo de trabajo.

- Worktrees en paralelo

Orquestación de worktrees en paralelo
   - Terminales divididas

Terminales divididas de nivel Ghostty


- Modo diseño

Navegador integrado y modo diseño
   - GitHub y Linear nativos

Flujos de GitHub y Linear en Orca


- Cualquier agente CLI

Compatible con cualquier agente CLI
   - Worktrees por SSH

Worktrees remotos por SSH


- Archivos a agentes

Arrastra archivos e imágenes al prompt de un agente
   - Anotar diffs de IA

Anotar diffs generados por IA


- Orca CLI

Automatiza Orca desde la CLI
   - Búsqueda nativa

Búsqueda nativa en los flujos de Orca


- Cambio de cuenta y seguimiento de uso

Cambio de cuenta y seguimiento de uso
   - Previews ricos del repo

Previsualización de Markdown, imágenes, PDFs y documentos del repo


- Divide cualquier cosa

Paneles divididos para agentes, terminales, navegadores y archivos
+ Worktrees en paralelo

Orquestación de worktrees en paralelo
   + Terminales divididas

Terminales divididas de nivel Ghostty


+ Modo diseño

Navegador integrado y modo diseño
   + GitHub y Linear nativos

Flujos de GitHub y Linear en Orca


+ Cualquier agente CLI

Compatible con cualquier agente CLI
   + Worktrees por SSH

Worktrees remotos por SSH


+ Archivos a agentes

Arrastra archivos e imágenes al prompt de un agente
   + Anotar diffs de IA

Anotar diffs generados por IA


+ Orca CLI

Automatiza Orca desde la CLI
   + Búsqueda nativa

Búsqueda nativa en los flujos de Orca


+ Cambio de cuenta y seguimiento de uso

Cambio de cuenta y seguimiento de uso
   + Previews ricos del repo

Previsualización de Markdown, imágenes, PDFs y documentos del repo


+ Divide cualquier cosa

Paneles divididos para agentes, terminales, navegadores y archivos

--- diff --git a/docs/README.ja.md b/docs/readme/README.ja.md similarity index 69% rename from docs/README.ja.md rename to docs/readme/README.ja.md index 35f688e949b..bbc3f64331c 100644 --- a/docs/README.ja.md +++ b/docs/readme/README.ja.md @@ -1,5 +1,5 @@

- Orca Orca + Orca Orca

@@ -9,7 +9,7 @@

- English · 中文 · 日本語 · 한국어 · Español + English · 中文 · 日本語 · 한국어 · Español

@@ -23,7 +23,7 @@

- Orca Screenshot + Orca Screenshot

## 対応するエージェント @@ -31,8 +31,9 @@ Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。

- Claude Code   + Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -46,7 +47,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   Kimi   @@ -102,7 +103,7 @@ yay -S stably-orca-git スマートフォンからエージェントを操作できます。

- Orca デスクトップとモバイル companion アプリ + Orca デスクトップとモバイル companion アプリ

- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) @@ -115,19 +116,19 @@ yay -S stably-orca-git 各タイルをクリックすると、そのワークフローを確認できます。

- 並列ワークツリー

並列ワークツリーのオーケストレーション
   - ターミナル分割

Ghostty クラスのターミナル分割


- デザインモード

組み込みブラウザとデザインモード
   - GitHub と Linear をネイティブに

Orca の GitHub と Linear ワークフロー


- 任意の CLI エージェント

任意の CLI エージェントに対応
   - SSH ワークツリー

SSH 経由のリモートワークツリー


- ファイルをエージェントへ

ファイルや画像をエージェントのプロンプトへドラッグ
   - AI Diff 注釈

AI が生成した Diff への注釈


- Orca CLI

CLI から Orca をスクリプト操作
   - ネイティブ検索

Orca ワークフロー全体のネイティブ検索


- アカウント切り替えと使用量トラッキング

アカウント切り替えと使用量トラッキング
   - リッチなリポジトリプレビュー

Markdown、画像、PDF、リポジトリ文書のプレビュー


- 何でも分割表示

エージェント、ターミナル、ブラウザ、ファイルの分割表示
+ 並列ワークツリー

並列ワークツリーのオーケストレーション
   + ターミナル分割

Ghostty クラスのターミナル分割


+ デザインモード

組み込みブラウザとデザインモード
   + GitHub と Linear をネイティブに

Orca の GitHub と Linear ワークフロー


+ 任意の CLI エージェント

任意の CLI エージェントに対応
   + SSH ワークツリー

SSH 経由のリモートワークツリー


+ ファイルをエージェントへ

ファイルや画像をエージェントのプロンプトへドラッグ
   + AI Diff 注釈

AI が生成した Diff への注釈


+ Orca CLI

CLI から Orca をスクリプト操作
   + ネイティブ検索

Orca ワークフロー全体のネイティブ検索


+ アカウント切り替えと使用量トラッキング

アカウント切り替えと使用量トラッキング
   + リッチなリポジトリプレビュー

Markdown、画像、PDF、リポジトリ文書のプレビュー


+ 何でも分割表示

エージェント、ターミナル、ブラウザ、ファイルの分割表示

--- diff --git a/docs/README.ko.md b/docs/readme/README.ko.md similarity index 68% rename from docs/README.ko.md rename to docs/readme/README.ko.md index 42fabf357ed..1dab6f41351 100644 --- a/docs/README.ko.md +++ b/docs/readme/README.ko.md @@ -1,5 +1,5 @@

- Orca Orca + Orca Orca

@@ -9,12 +9,12 @@

- English · 中文 · 日本語 · 한국어 · Español + English · 中文 · 日本語 · 한국어 · Español

100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
macOS, Windows, Linux에서 사용할 수 있습니다.

@@ -23,7 +23,7 @@

- Orca 스크린샷 + Orca 스크린샷

## 지원 에이전트 @@ -31,8 +31,9 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정되지 않습니다*).

- Claude Code   + Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -46,7 +47,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정 Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   Kimi   @@ -102,7 +103,7 @@ yay -S stably-orca-git 휴대폰에서 에이전트를 제어하세요.

- Orca 데스크톱과 모바일 companion 앱 + Orca 데스크톱과 모바일 companion 앱

- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) @@ -115,19 +116,19 @@ yay -S stably-orca-git 타일을 클릭해 각 워크플로를 살펴보세요.

- 병렬 Worktree

병렬 worktree 오케스트레이션
   - 터미널 분할

Ghostty급 터미널 분할


- 디자인 모드

내장 브라우저와 디자인 모드
   - GitHub 및 Linear 네이티브

Orca의 GitHub 및 Linear 워크플로


- 모든 CLI 에이전트

모든 CLI 에이전트 지원
   - SSH Worktree

SSH를 통한 원격 worktree


- 에이전트로 파일 드래그

파일과 이미지를 에이전트 프롬프트로 드래그
   - AI Diff 주석

AI가 생성한 diff에 주석 달기


- Orca CLI

CLI에서 Orca 스크립팅
   - 네이티브 검색

Orca 워크플로 전반의 네이티브 검색


- 계정 전환 및 사용량 추적

계정 전환 및 사용량 추적
   - 풍부한 리포지토리 미리보기

Markdown, 이미지, PDF, 리포지토리 문서 미리보기


- 무엇이든 분할

에이전트, 터미널, 브라우저, 파일을 위한 분할 패널
+ 병렬 Worktree

병렬 worktree 오케스트레이션
   + 터미널 분할

Ghostty급 터미널 분할


+ 디자인 모드

내장 브라우저와 디자인 모드
   + GitHub 및 Linear 네이티브

Orca의 GitHub 및 Linear 워크플로


+ 모든 CLI 에이전트

모든 CLI 에이전트 지원
   + SSH Worktree

SSH를 통한 원격 worktree


+ 에이전트로 파일 드래그

파일과 이미지를 에이전트 프롬프트로 드래그
   + AI Diff 주석

AI가 생성한 diff에 주석 달기


+ Orca CLI

CLI에서 Orca 스크립팅
   + 네이티브 검색

Orca 워크플로 전반의 네이티브 검색


+ 계정 전환 및 사용량 추적

계정 전환 및 사용량 추적
   + 풍부한 리포지토리 미리보기

Markdown, 이미지, PDF, 리포지토리 문서 미리보기


+ 무엇이든 분할

에이전트, 터미널, 브라우저, 파일을 위한 분할 패널

--- diff --git a/docs/README.zh-CN.md b/docs/readme/README.zh-CN.md similarity index 69% rename from docs/README.zh-CN.md rename to docs/readme/README.zh-CN.md index 855471739c8..131abfe3764 100644 --- a/docs/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -1,5 +1,5 @@

- Orca Orca + Orca Orca

@@ -9,7 +9,7 @@

- English · 中文 · 日本語 · 한국어 · Español + English · 中文 · 日本語 · 한국어 · Español

@@ -23,7 +23,7 @@

- Orca Screenshot + Orca Screenshot

## 支持的智能体 @@ -31,8 +31,9 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。

- Claude Code   + Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -46,7 +47,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。 Codebuff   Continue   Cursor   - Droid   + Droid   GitHub Copilot   Kilocode   Kimi   @@ -102,7 +103,7 @@ yay -S stably-orca-git 用手机控制你的智能体。

- Orca 桌面端与移动 companion 应用 + Orca 桌面端与移动 companion 应用

- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) @@ -115,19 +116,19 @@ yay -S stably-orca-git 点击任意卡片了解对应工作流。

- 并行 Worktree

并行 worktree 编排
   - 终端分屏

Ghostty 级终端分屏


- 设计模式

内置浏览器与设计模式
   - GitHub 与 Linear 原生集成

Orca 中的 GitHub 与 Linear 工作流


- 任意 CLI 智能体

支持任意 CLI 智能体
   - SSH Worktree

通过 SSH 使用远程 worktree


- 拖文件给智能体

将文件和图片拖入智能体提示
   - 标注 AI Diff

标注 AI 生成的 diff


- Orca CLI

从 CLI 脚本化 Orca
   - 原生搜索

贯穿 Orca 工作流的原生搜索


- 账号切换与用量追踪

账号切换与用量追踪
   - 丰富仓库预览

Markdown、图片、PDF 和仓库文档预览


- 任意分屏

为智能体、终端、浏览器和文件分屏
+ 并行 Worktree

并行 worktree 编排
   + 终端分屏

Ghostty 级终端分屏


+ 设计模式

内置浏览器与设计模式
   + GitHub 与 Linear 原生集成

Orca 中的 GitHub 与 Linear 工作流


+ 任意 CLI 智能体

支持任意 CLI 智能体
   + SSH Worktree

通过 SSH 使用远程 worktree


+ 拖文件给智能体

将文件和图片拖入智能体提示
   + 标注 AI Diff

标注 AI 生成的 diff


+ Orca CLI

从 CLI 脚本化 Orca
   + 原生搜索

贯穿 Orca 工作流的原生搜索


+ 账号切换与用量追踪

账号切换与用量追踪
   + 丰富仓库预览

Markdown、图片、PDF 和仓库文档预览


+ 任意分屏

为智能体、终端、浏览器和文件分屏

--- diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 00000000000..619d1588f97 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,16 @@ +# Durable Docs + +Keep this folder for versioned reference docs that are meant to survive past a +single design or implementation pass. + +## What Goes Here + +- Stable reference material. +- Public-facing docs that are not part of the root README. +- Docs that other checked-in files link to. + +## What Stays Out + +Ephemeral design notes, implementation sketches, and planning docs should stay as +local Markdown files under `docs/`. They are ignored by default so they do not get +checked in accidentally. diff --git a/docs/refresh-on-checks-tab.md b/docs/refresh-on-checks-tab.md deleted file mode 100644 index 392b5de0609..00000000000 --- a/docs/refresh-on-checks-tab.md +++ /dev/null @@ -1,165 +0,0 @@ -# Auto-refresh on entering the Checks tab - -## Problem - -The Checks panel fetches on visibility, but those fetches are cache-respecting: - -- `CACHE_TTL = 300_000` for PRs and comments. -- `CHECKS_CACHE_TTL = 60_000` for checks. -- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`. -- Checks polling and the comments effect also call the store without `force`. - -That means entering the Checks tab can render stale PR metadata, a cached `null` -"no PR" result, stale comments, or stale checks. The checks path is partially -protected by a shorter renderer TTL, but the main process also uses `gh api ---cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh -already bypasses these renderer and `gh api` caches for the requests it starts. - -## Goal - -When the user enters the Checks tab, run one freshness check for the active -worktree and force-refresh only when the relevant cache timestamps are older -than a small grace window. "Entering" means: - -- opening the right sidebar while the Checks tab is selected; -- switching from another right-sidebar tab to Checks; -- switching active worktree/repo/branch while Checks is already visible. - -The refresh must cover PR discovery too, including cached `null`, so a PR opened -outside Orca can appear without waiting up to 5 minutes. - -## Non-goals - -- Changing the existing polling cadence while the panel stays visible. -- Refreshing on window focus, network reconnect, or arbitrary background events. -- Changing the manual refresh button. -- Prefetching checks for non-active worktrees. -- Adding cross-renderer or cross-window request coordination. Orca currently has - one live main window; renderer in-flight maps are not a multi-window primitive. - -## Design - -1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by: - - ```ts - const entryKey = isPanelVisible && repo && !isFolder && branch - ? `${activeWorktreeId ?? ''}::${repo.path}::${branch}` - : '' - ``` - - Track the last processed visible `entryKey` in a ref. When `entryKey` is empty, - reset the ref to `''`. When it becomes non-empty and differs from the ref, this - is an entry. This is required; a `prevKey !== currentKey` check that does not - reset on hide would miss closing and reopening the same PR. - -2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in - `ChecksPanel.tsx`. Select only timestamps, not whole cache records: - - ```ts - const prFetchedAt = useAppStore( - (s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined) - ) - const checksFetchedAt = useAppStore( - (s) => prNumber - ? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt - : undefined - ) - const commentsFetchedAt = useAppStore( - (s) => prNumber - ? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt - : undefined - ) - ``` - - Missing PR cache is stale. A cached PR value of `null` is still a PR cache - entry and should be refreshed on entry once outside the grace window. When a - PR number is known, missing checks/comments timestamps are stale. When no PR is - known, checks/comments are not relevant yet. - - Run `handleRefresh()` if the oldest relevant timestamp is missing or older - than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip. - -3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The - manual refresh flow is the right shape: force `fetchPRForBranch`, then if a - PR is returned, force checks and comments for the returned PR. Extend - `fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks` - directly from `handleRefresh`) so the checks request uses - `refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR - refresh completed. This handles PR number changes, cached `null`, and - external force-pushes correctly. - -4. **Reset polling attention state before refresh.** When the entry refresh runs, - set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before - calling `handleRefresh()`. `fetchChecks()` will then write the new signature - from the forced result. - -5. **Do not overstate in-flight behavior.** Current store behavior is: - - - `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR - request and uses a generation guard so the older result cannot overwrite the - newer cache entry. - - `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do - **not** bypass any in-flight request for the same key. If a non-forced poll - is already in flight, entry refresh will join it and may not pass - `noCache: true` to the main process. - - Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a - visible polling race. If strict "entry always bypasses gh cache" semantics are - required later, change checks/comments in-flight maps to track `{ promise, - force, generation }` like PRs. - -## API cost and feasibility - -This is not free. A cold entry refresh can start: - -- one PR lookup (`gh:prForBranch`); -- one checks request (`gh:prChecks`); -- one comments request (`gh:prComments`). - -`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs` -and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr -checks`, which does not use the `--cache` flag. - -`gh:prComments` is heavier than "one GitHub API call": it runs issue comments -REST, review threads GraphQL, and reviews REST in parallel. `noCache` only -removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always -made. - -The 15 s grace window is therefore required, not cosmetic. - -## Edge cases - -- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh - the PR cache entry on tab entry when its timestamp is outside the grace window. -- **First Checks entry after app start.** Only `prCache` and `issueCache` are - persisted. `checksCache` and `commentsCache` start empty, so a known PR should - force checks/comments on entry. -- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the - entry key so same repo/branch switches still count as a new entry. The existing - render-time local reset handles stale title/loading state. -- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing - it again re-evaluates timestamps. The grace window suppresses duplicate calls. -- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll - request instead of forcing a new `noCache` request. This is intentional for now. -- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect - can still force a PR refresh. Entry refresh may also run, but PR generation - guards prevent older PR responses from overwriting newer cache entries. -- **PR head changed externally.** `handleRefresh()` must use the refreshed PR - returned by `fetchPRForBranch` before fetching checks, so checks are requested - with the current `headSha`. -- **Component unmount mid-fetch.** The comments auto-fetch guards stale - responses; the checks fetch path and `handleRefresh()` do not cancel local - setters. This feature should keep that behavior unless tests expose a warning - or stale-state regression. -- **SSH / remote repo.** No path manipulation or platform-specific shortcut code - is needed. The same IPC-backed `gh` path is used. - -## Rollout - -1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in - `ChecksPanel.tsx`. -2. Keep `handleRefresh()` as the single refresh implementation. -3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes, - fresh-within-grace skips, known PR with missing checks/comments refreshes, - hidden panel no-op, and hide/show same PR re-evaluation. -4. Run `pnpm typecheck && pnpm lint`. diff --git a/docs/resource-usage-merge-spec.md b/docs/resource-usage-merge-spec.md deleted file mode 100644 index 46e56612557..00000000000 --- a/docs/resource-usage-merge-spec.md +++ /dev/null @@ -1,387 +0,0 @@ -# Resource Usage popover: merge Resources + Sessions into one view - -## Problem - -The status-bar Resource Usage popover currently has two tabs: - -- **Resources** — per-worktree CPU/memory grouped by repo, sourced from - `MemorySnapshot.worktrees`. Local PTYs only (SSH excluded by design at - `src/main/ipc/pty.ts:832` because remote process trees are not visible - to the local `ps`/`wmic` sweep). -- **Sessions** — flat list of every PTY the daemon tracks (local **and** - SSH), sourced from `pty.listSessions()`. Adds tab-binding info, click- - to-navigate, kill-X per row, and "Kill N orphans". - -The data sources only partially overlap: Sessions can show worktrees -(e.g. `orca/Porpoise`, `orca/Stingray`) that never appear in Resources -because they're SSH-backed. Users see this as a confusing inconsistency -between two adjacent tabs in the same popover. - -The user wants one unified view that: - -1. Lists every PTY the daemon knows about (the union, not the - intersection). -2. Shows CPU/memory **when available** (i.e. for local PTYs) and `—` for - remote ones, rather than silently dropping remote sessions. -3. Keeps tab-binding affordances (click row → navigate, kill X with - confirm dialog) on every row. -4. Surfaces orphans (PTYs not bound to any tab) with the "Kill N - orphans" bulk action. - -## Direction (already decided by user) - -> Merge into one tab, drop the switcher entirely. - -Out of scope: - -- Adding new IPC. The merge uses **only** the data already available: - `MemorySnapshot` and `pty.listSessions()`. -- Changing what the memory collector tracks. SSH PTYs remain unsampled. - The unified view simply shows them with empty metric cells instead of - hiding them. -- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.). - The exact action set today (kill one with confirm, kill orphans, sleep - worktree, restart daemon, kill-all-sessions) is preserved. - -## Data model - -### Inputs - -- `snapshot: MemorySnapshot | null` — local-only CPU/Mem data, grouped - by `worktreeId`. May be `null` while the daemon is unreachable. -- `sessions: DaemonSession[]` — every PTY id the daemon knows, with - `cwd`, `title`, and a sticky `isAlive`-equivalent. -- `tabsByWorktree`, `ptyIdsByTabId`, `runtimePaneTitlesByTabId`, - `workspaceSessionReady` — store-side context used to (a) compute - bound/orphan and (b) resolve human labels. - -### Output: a renderer-local view model - -The merge produces a renderer-local view-model — **not** a widening of -the shared `WorktreeMemory` shape. `src/shared/types.ts` and the -collector are untouched. New types live next to the merge helper: - -```ts -// src/renderer/src/components/status-bar/unifiedRow.ts -type Metric = number | null // null === "no local sample" (e.g. SSH) - -type UnifiedSessionRow = { - sessionId: string - label: string - bound: boolean - tabId: string | null - cpu: Metric - memory: Metric - hasLocalSamples: boolean -} - -type UnifiedWorktreeRow = { - worktreeId: string - worktreeName: string - repoId: string - repoName: string - cpu: Metric - memory: Metric - history: number[] // empty when hasLocalSamples is false - hasLocalSamples: boolean - sessions: UnifiedSessionRow[] -} - -type UnifiedRepoGroup = { - repoId: string - repoName: string - cpu: Metric // null if every child has hasLocalSamples false - memory: Metric - hasRemoteChildren: boolean // drives the "· remote" badge on the row - worktrees: UnifiedWorktreeRow[] -} -``` - -A small adapter inside `mergeSnapshotAndSessions` converts each -`WorktreeMemory` from the shared type into a `UnifiedWorktreeRow` with -numeric metrics and `hasLocalSamples: true`. Synthetic remote rows are -constructed directly with `null` metrics. The existing `MetricPair` and -`Sparkline` callsites in the popover get a thin wrapper that renders -`—` when its input is `null`; nothing in `src/shared/` or `src/main/` -changes. - -### Merge algorithm - -The merge is renderer-only and pure. Inputs in priority order: - -- **Snapshot first.** `snapshot.worktrees` is the authoritative source - for any local PTY's CPU/Mem and for the worktree's identity (it - already carries `worktreeId`, `worktreeName`, `repoId`, `repoName`, - and a `sessions[]` array with per-session metrics). -- **Sessions augments.** `sessions: DaemonSession[]` is the union over - the snapshot — it adds (a) SSH PTYs that the local memory collector - cannot see and (b) any local PTY that registered too late to make - this snapshot's sweep. - -#### Join key (snapshot session ↔ daemon session) - -Both layers identify a session by the **same string**: the PTY id. - -- `SessionMemory.sessionId` (collector) is `pty.sessionId ?? pty.ptyId` - from `pty-registry.ts`. In-process local PTYs spawn with - `args.sessionId === undefined` (`pty.ts:684`), so the registry stores - `sessionId: null` and the fallback `pty.ptyId` is used — which equals - `result.id` from the local provider's spawn, which is also what - `LocalPtyProvider.listProcesses()` returns as `id` - (`local-pty-provider.ts:518-522`). -- For daemon-hosted spawns, `mintPtySessionId(worktreeId)` produces the - `${worktreeId}@@${shortUuid}` form - (`src/main/daemon/pty-session-id.ts`); both the registry and - `pty.listSessions()` carry that exact string as the id. - -Therefore the dedup key is simply `session.id` on both sides. The -merge MUST build `Set` of all `SessionMemory.sessionId`s from -the snapshot and skip any `DaemonSession` whose `id` is already in the -set (so a local session never appears twice — once with metrics, once -as a `—` placeholder). - -#### Worktree resolution for `DaemonSession`s - -When a `DaemonSession` is **not** already accounted for by the -snapshot, we need to bucket it under a worktree group. Try in order: - -1. **Tab-store walk** (existing logic in `SessionsTabPanel`): look up - `ptyIdsByTabId` → `tabId` → `tabsByWorktree` → `worktreeId`. This - resolves any session bound to a live tab in *this* renderer. -2. **Session-id parse**: if the id contains `@@`, take - `id.slice(0, id.lastIndexOf('@@'))` as a candidate `worktreeId`. - This is the convention enforced by `mintPtySessionId` (see - `pty-session-id.ts:5-7` and the symmetric parser in - `daemon-pty-adapter.ts:328-331`). It correctly recovers the - worktreeId for SSH sessions that haven't been bound to a tab in - this Orca instance — which is the user's primary scenario - (`orca/Stingray`, `orca/Sawfish`, etc.). -3. **Unattributed**: if neither resolves, bucket under a synthetic - `unattributed` repo group at the bottom of the list. - -The `repoId` for each new worktree group is recovered the same way the -collector does it (`collector.ts:resolveWorktreeNames`): split the -worktreeId on the first `::`. Resolved repo display name comes from -the renderer-side store (`store.repos[repoId]?.displayName`) when -available; otherwise the bare `repoId` is used. - -#### Step-by-step - -1. Initialize `repos: Map` empty. -2. Insert all `snapshot.worktrees` (if any) into the map, grouped by - their `repoId`. Each worktree carries `hasLocalSamples: true` and - numeric metrics; its sessions inherit numeric metrics. -3. Build `seenSessionIds = new Set(...all session ids in - snapshot.worktrees[].sessions)`. -4. For each `DaemonSession` in `sessions: DaemonSession[]`: - - If `seenSessionIds.has(session.id)`, skip (already merged from - snapshot). - - Resolve `worktreeId` per the three-step rule above. - - If the resolved `worktreeId` is already a worktree group in the - map, append the session to its `sessions[]` with `cpu: null`, - `memory: null`, `hasLocalSamples: false`. (Reachable for SSH - sessions whose worktree happens to match a locally-active - worktree id — rare but possible.) - - Otherwise, create a new worktree group with `cpu: null`, - `memory: null`, `history: []`, `hasLocalSamples: false`, and - append the session. -5. Compute per-repo aggregates: sum `cpu`/`memory` from worktrees with - `hasLocalSamples === true`. If the repo has *any* worktree with - `hasLocalSamples === false`, set `repoHasRemoteChildren: true` (used - by the UI to render a `· remote` badge on the repo header so the - user knows the displayed totals exclude remote worktrees). -6. The Orca app section (Main / Renderer / Other) renders unchanged - below the list. - -The whole pass is O(W + S) where W = snapshot worktrees and S = -sessions. No nested scans. - -### Why this is safe with current data - -- Every existing local session continues to render with the same - numbers. The merge **adds** rows; it does not change values. -- The snapshot rebuilds every poll (10s), so SSH sessions transitioning - to local (or vice versa) flip representation on the next tick. No - reconciliation logic needed. -- Orphan detection still works: a session is "bound" iff its - `sessionId` appears in `boundPtyIds`. Sessions in repos we couldn't - resolve still get the X-kill affordance. - -## UI - -### Trigger (status-bar badge) — unchanged - -Mac/Linux/Windows safe (no platform-specific glyphs introduced). - -### Popover — single panel, fixed height - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Resource Usage ⟳ 🗑 ✕ │ ← header -├─────────────────────────────────────────────────────────────┤ -│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary -├─────────────────────────────────────────────────────────────┤ -│ Name CPU Memory · │ ← sort row -├─────────────────────────────────────────────────────────────┤ -│ ▾ Triton 0.0% 125.5 MB │ -│ Terminal 1 0.0% 63.2 MB ✕│ -│ Terminal 1 0.0% 1.3 MB ✕│ -│ ▾ Stingray ⓘ remote — — │ -│ orca/Stingray — — ✕│ -│ ▾ Sawfish ⓘ remote — — │ -│ orca/Sawfish — — ✕│ -│ ───────────────────────── │ -│ ▸ Orca app 0.5% 829.7 MB │ -└─────────────────────────────────────────────────────────────┘ - ↑ fixed 420px body, owns its own scroll - ↓ orphan bulk-kill pill renders here when N>0 -``` - -The header keeps the icon-only **Restart daemon** (RotateCw) and -**Kill all sessions** (Trash2) buttons in the top-right. - -### Interaction states (4 paths) - -| Scenario | Resource cells | Kill X | Click row | -|-----------------------------|----------------|-------------|--------------| -| Local PTY, bound | numeric | hover only | navigates | -| Local PTY, orphan | numeric | always | no-op | -| Remote PTY, bound | `—` | hover only | navigates | -| Remote PTY, orphan | `—` | always | no-op | - -`—` is rendered as `text-muted-foreground/50`. The worktree row carries -a small `· remote` badge when `hasLocalSamples === false`; the repo row -carries the same badge when `hasRemoteChildren === true` (so users -know the displayed repo totals exclude remote worktrees). - -**`bound` semantics are unchanged from today:** a session is bound iff -`boundPtyIds.has(session.id)` evaluated against this renderer's -`ptyIdsByTabId`. SSH sessions bound to tabs in *another* Orca process -look identical to local orphans here — same as before the merge. - -### Empty / loading / error states - -- Daemon unreachable → existing banner remains, body shows nothing. -- Sessions error AND no snapshot AND zero sessions → "Resource data - unavailable. Restart daemon." -- Snapshot present but zero worktrees AND zero sessions → "Nothing - running right now." -- Loading (no snapshot, no error) → "Loading…". - -### Sort - -Three buttons in the sort row: **Name**, **CPU**, **Memory**. CPU/Mem -sort puts `null` metrics last (stable). Name uses `localeCompare`. -Selection is repo-group aware: sorting by CPU sorts repos by aggregate -CPU and worktrees within a repo by their CPU. - -### Confirm dialogs (unchanged) - -- Per-row kill X: confirm Dialog with copy "Kill this session? Force- - quits ``. Any unsaved work in that pane is lost. This can't be - undone." -- Header Trash2: existing "Kill all sessions" Dialog from - `useDaemonActions`. -- Header RotateCw: existing "Restart daemon" Dialog from - `useDaemonActions`. -- Worktree-level Sleep / Delete: unchanged. - -## Implementation plan - -1. **New helper** `mergeSnapshotAndSessions.ts` (renderer-side, pure - function) that takes `(snapshot, sessions, storeContext)` and - returns the unified `RepoGroup[]` shape above. Unit-testable. -2. **Refactor `ResourceUsageStatusSegment.tsx`**: - - Remove the tab switcher (drop `activeTab` state, both - `'resources'` / `'sessions'` branches, the inline pill component, - and the `Trash2` import for the old switcher). - - Replace the body with a single render path that consumes the - unified `RepoGroup[]` from the helper. - - Keep the existing `WorktreeSection`/`AppSection` patterns — extend - them to render `null` metrics as `—` and to render the kill-X on - each session row (with bound-vs-orphan visibility rule). - - Promote `killConfirm` state and the confirm Dialog from - `SessionsTabPanel` to the segment level since there's only one - panel now. - - Keep `onSessionsChanged` plumbing — call it from kill / kill- - orphans handlers to trigger an immediate `refreshSessions()`. - - Delete the now-unused `SessionsTabPanel` component. -3. **Wire metric formatting** to render `—` when metric is `null`. Use - the `null`-vs-`0` distinction already present in the data model; - don't conflate them. -4. **Migration**: status-bar item id stays `'resource-usage'`. The - `migrateStatusBarItems` helper is unchanged. No persistence - migration needed for this UI-only change. - -## Testing - -- Unit: `mergeSnapshotAndSessions` with synthetic inputs covering: - - All 4 interaction-state paths (local-bound, local-orphan, - remote-bound, remote-orphan). - - **Dedup**: a local session that appears in both the snapshot and - the daemon list is rendered exactly once with numeric metrics. - - **`@@` parse**: an SSH session id `repoX::/path/wtA@@abcd` with no - matching tab resolves to repoId `repoX`, worktreeId - `repoX::/path/wtA`. - - **Tab walk wins over `@@` parse**: a session bound to a tab whose - worktreeId differs from the `@@` prefix uses the tab's worktreeId - (defensive against id-format drift). - - **Repo aggregate excludes remote children**: a repo with one - local (125 MB) and one remote (`null`) worktree reports - `cpu/memory = 125 MB` and `hasRemoteChildren = true`. - - Edge cases: snapshot null, sessions empty, both empty, sessionId - without `@@` and no tab match → falls into Unattributed. -- Manual: - - Open with mixed local + SSH worktrees → both render, SSH rows show - `—` and a `· remote` badge on their worktree and repo headers. - - Kill a remote session via the X → confirm dialog → optimistic - removal so row disappears immediately (does not wait 10s). - - Kill all (header trash) → orphans gone, bound sessions remain. - - Resize window / scroll the body → fixed 420px holds, no reflow. - - Accessibility: tab through rows → kill X focusable; after kill - confirm, focus lands back on the popover content root, not on - ``. Dialog is keyboard-dismissable except while in-flight. - -## Risks & mitigations - -- **Risk**: SSH worktrees the user never opened locally still appear in - Resources (potentially noisy if the daemon retains many idle SSH - sessions). **Mitigation**: bucketing under a `· remote` badge keeps - them visually distinct from local-active groups; sort-by-memory pushes - them to the bottom. If noise becomes a real complaint, a future PR - can add a "hide remote" toggle. -- **Risk**: Some sessions can't be resolved to any worktreeId (e.g. - daemon-internal sessions whose ids don't follow the `@@` convention, - or sessions whose worktreeId resolves to a repo store doesn't know - about). **Mitigation**: synthetic `Unattributed` repo group at the - bottom, which already exists in spirit as `ORPHAN_WORKTREE_ID` in the - memory collector. -- **Risk**: Kill-X on a remote session fires `pty.kill(id)`, which the - IPC layer routes through `ptyOwnership` to the right SSH provider. - Behavior is identical to the old Sessions tab — no new failure mode. -- **Risk: poll-rate skew between snapshot (2s) and sessions (10s).** - After killing an SSH session, the daemon-side `sessions` list can - retain the dead row for up to 10s while the snapshot has already - moved on; conversely a freshly-spawned local PTY shows up in - `sessions` first and is rendered as a remote placeholder for up to - 2s before the next snapshot promotes it. - **Mitigation**: per-row kill optimistically removes the session id - from the renderer's local `sessions` state immediately after the IPC - resolves (in addition to calling `onSessionsChanged()`), so the - killed row never lingers on screen. The new-session-flash is - bounded by `POLL_MS` and acceptable. -- **Risk: focus dropped to `` after kill confirm.** When the - killed session disappears on the next refresh, the per-row X that - had focus is unmounted. With the confirm Dialog now mounted at the - segment level, this regression is more visible. - **Mitigation**: after `runKillConfirmed` resolves, focus the - popover's content root via a stable ref so keyboard users land back - in the list, not on ``. - -## Non-goals - -- No new IPC. -- No collector changes. -- No new persisted UI prefs (the per-tab visibility split is gone, not - replaced with a per-section toggle). -- No remote memory sampling — that's a separate, much bigger project. diff --git a/docs/right-sidebar-header-drag.md b/docs/right-sidebar-header-drag.md deleted file mode 100644 index d1a525491c9..00000000000 --- a/docs/right-sidebar-header-drag.md +++ /dev/null @@ -1,54 +0,0 @@ -# Right Sidebar Header Drag Region - -## Problem - -Right sidebar header blank space does not drag the window. - -- In top mode, the header row (`right-sidebar/index.tsx`, current line ~313) has no `-webkit-app-region: drag`. -- In side mode, the title row (`right-sidebar/index.tsx`, current line ~332) has no `-webkit-app-region: drag`. -- The app already uses draggable titlebar surfaces elsewhere (`.titlebar`, `.titlebar-left` in `main.css`), so this is an inconsistency. - -## Current Behavior (Verified) - -- `ActivityBarButton` renders plain `', + attributes: { class: 'primary', type: 'button' }, + accessibility: { + role: 'button', + accessibleName: 'Submit', + ariaLabel: null, + ariaLabelledBy: null + }, + rectViewport: { x: 10, y: 20, width: 100, height: 40 }, + rectPage: { x: 10, y: 20, width: 100, height: 40 }, + computedStyles: { + display: 'inline-flex', + position: 'static', + width: '100px', + height: '40px', + margin: '0px', + padding: '8px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgba(0, 0, 0, 0)', + border: '0px none', + borderRadius: '0px', + fontFamily: 'Geist', + fontSize: '14px', + fontWeight: '400', + lineHeight: '20px', + textAlign: 'center', + zIndex: 'auto' + } + }, + nearbyText: ['Submit'], + ancestorPath: ['button', 'main'], + screenshot: null, + ...overrides + } +} + +describe('clampGrabPayload', () => { + it('redacts secret-bearing cssClasses outside the attribute path', () => { + const payload = clampGrabPayload( + makeRawPayload({ + target: { + ...(makeRawPayload().target as Record), + cssClasses: 'primary access_token=secret' + } + }) + ) + + expect(payload?.target.cssClasses).toBe('[redacted]') + }) + + it('redacts secret-bearing browser annotation metadata fields', () => { + const payload = clampGrabPayload( + makeRawPayload({ + target: { + ...(makeRawPayload().target as Record), + elementPath: 'main > button[aria-label="access_token=secret"]', + fullPath: 'body > main > button#client_secret', + reactComponents: ' ', + sourceFile: 'src/client_secret/Button.tsx:12:4', + nearbyElements: ['span "api_key=secret"'], + selectedText: 'password=secret', + accessibility: { + role: 'button', + accessibleName: 'access_token=secret', + ariaLabel: 'access_token=secret', + ariaLabelledBy: null + } + } + }) + ) + + expect(payload?.target.elementPath).toBe('[redacted]') + expect(payload?.target.fullPath).toBe('[redacted]') + expect(payload?.target.reactComponents).toBe('[redacted]') + expect(payload?.target.sourceFile).toBe('[redacted]') + expect(payload?.target.nearbyElements).toEqual(['[redacted]']) + expect(payload?.target.selectedText).toBe('[redacted]') + expect(payload?.target.accessibility.accessibleName).toBe('[redacted]') + expect(payload?.target.accessibility.ariaLabel).toBe('[redacted]') + }) + + it('keeps and clamps browser annotation metadata fields', () => { + const payload = clampGrabPayload( + makeRawPayload({ + target: { + ...(makeRawPayload().target as Record), + nearbyElements: Array.from({ length: 10 }, (_, index) => `item-${index}`), + reactComponents: ''.repeat(200), + sourceFile: 'src/Button.tsx:12:4' + } + }) + ) + + expect(payload?.target.nearbyElements).toHaveLength(6) + expect(payload?.target.reactComponents?.length).toBeLessThanOrEqual(512) + expect(payload?.target.sourceFile).toBe('src/Button.tsx:12:4') + }) +}) diff --git a/src/main/browser/browser-grab-payload.ts b/src/main/browser/browser-grab-payload.ts index 0e8a7c2f6a5..46815ed8008 100644 --- a/src/main/browser/browser-grab-payload.ts +++ b/src/main/browser/browser-grab-payload.ts @@ -105,6 +105,26 @@ export function clampGrabPayload(raw: unknown): BrowserGrabPayload | null { return filtered } + const safeMetadataStr = (value: unknown, max: number): string => { + const strValue = safeStr(value, max) + return strValue && containsSecret(strValue) ? '[redacted]' : strValue + } + + const safeNullableMetadataStr = (value: unknown, max: number): string | null => + safeMetadataStr(value, max) || null + + const safeMetadataArray = ( + arr: unknown, + maxEntries: number, + maxEntryLength: number + ): string[] => { + const items = Array.isArray(arr) ? arr : [] + return items + .slice(0, maxEntries) + .map((item) => safeMetadataStr(item, maxEntryLength)) + .filter(Boolean) + } + const safeRect = (r: unknown): BrowserGrabRect => { if (!r || typeof r !== 'object') { return { x: 0, y: 0, width: 0, height: 0 } @@ -136,15 +156,30 @@ export function clampGrabPayload(raw: unknown): BrowserGrabPayload | null { }, target: { tagName: safeStr(target.tagName, 50), - selector: safeStr(target.selector, 500), + selector: safeStr(target.selector, GRAB_BUDGET.selectorMaxLength), + elementPath: safeMetadataStr(target.elementPath, GRAB_BUDGET.pathMaxLength), + fullPath: safeMetadataStr(target.fullPath, GRAB_BUDGET.pathMaxLength), + cssClasses: safeMetadataStr(target.cssClasses, GRAB_BUDGET.cssClassesMaxLength), + nearbyElements: safeMetadataArray( + target.nearbyElements, + GRAB_BUDGET.nearbyElementsMaxEntries, + GRAB_BUDGET.nearbyElementMaxLength + ), + selectedText: safeMetadataStr(target.selectedText, GRAB_BUDGET.selectedTextMaxLength) || null, + isFixed: target.isFixed === true, + reactComponents: safeNullableMetadataStr( + target.reactComponents, + GRAB_BUDGET.reactComponentsMaxLength + ), + sourceFile: safeNullableMetadataStr(target.sourceFile, GRAB_BUDGET.sourceFileMaxLength), textSnippet: clampStr(target.textSnippet, GRAB_BUDGET.textSnippetMaxLength), htmlSnippet: clampStr(target.htmlSnippet, GRAB_BUDGET.htmlSnippetMaxLength), attributes: safeAttributes(target.attributes), accessibility: { - role: safeStr(accessibility?.role) || null, - accessibleName: safeStr(accessibility?.accessibleName) || null, - ariaLabel: safeStr(accessibility?.ariaLabel) || null, - ariaLabelledBy: safeStr(accessibility?.ariaLabelledBy) || null + role: safeNullableMetadataStr(accessibility?.role, 500), + accessibleName: safeNullableMetadataStr(accessibility?.accessibleName, 500), + ariaLabel: safeNullableMetadataStr(accessibility?.ariaLabel, 500), + ariaLabelledBy: safeNullableMetadataStr(accessibility?.ariaLabelledBy, 500) }, rectViewport: safeRect(target.rectViewport), rectPage: safeRect(target.rectPage), diff --git a/src/main/browser/browser-grab-screenshot.ts b/src/main/browser/browser-grab-screenshot.ts index 54e403b13a9..e3343fad88e 100644 --- a/src/main/browser/browser-grab-screenshot.ts +++ b/src/main/browser/browser-grab-screenshot.ts @@ -1,6 +1,24 @@ import type { BrowserGrabRect, BrowserGrabScreenshot } from '../../shared/browser-grab-types' import { GRAB_BUDGET } from '../../shared/browser-grab-types' +const HIDE_BROWSER_GRAB_OVERLAY_SCRIPT = `(function(){ + var g = window.__orcaGrab; + if (g && g.host) g.host.style.display = 'none'; + document.querySelectorAll('[data-orca-browser-annotation-overlay]').forEach(function(el) { + el.setAttribute('data-orca-previous-display', el.style.display || ''); + el.style.display = 'none'; + }); +})()` + +const RESTORE_BROWSER_GRAB_OVERLAY_SCRIPT = `(function(){ + var g = window.__orcaGrab; + if (g && g.host) g.host.style.display = ''; + document.querySelectorAll('[data-orca-browser-annotation-overlay]').forEach(function(el) { + el.style.display = el.getAttribute('data-orca-previous-display') || ''; + el.removeAttribute('data-orca-previous-display'); + }); +})()` + /** * Capture a screenshot of the guest surface and optionally crop it to * the given CSS-pixel rect. @@ -26,20 +44,12 @@ export async function captureSelectionScreenshot( // label don't appear in the screenshot. The overlay is restored after. // Wrapped in try/finally so the overlay is always restored even if // capturePage() throws (e.g., guest destroyed mid-capture). - await guest - .executeJavaScript( - `(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = 'none'; })()` - ) - .catch(() => {}) + await guest.executeJavaScript(HIDE_BROWSER_GRAB_OVERLAY_SCRIPT).catch(() => {}) let image: Electron.NativeImage try { image = await guest.capturePage() } finally { - await guest - .executeJavaScript( - `(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = ''; })()` - ) - .catch(() => {}) + await guest.executeJavaScript(RESTORE_BROWSER_GRAB_OVERLAY_SCRIPT).catch(() => {}) } if (image.isEmpty()) { return null diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index f886a88b266..5e9533133ef 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -22,6 +22,14 @@ function isTerminalTabSwitchChord(input: Electron.Input): boolean { ) } +function isCtrlTabSwitchKey(input: Electron.Input): boolean { + return input.code === 'Tab' && input.control && !input.meta && !input.alt +} + +function isControlKeyRelease(input: Electron.Input): boolean { + return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight') +} + export function setupGuestContextMenu(args: { browserTabId: string guest: Electron.WebContents @@ -221,7 +229,26 @@ export function setupGuestShortcutForwarding(args: { shouldForwardDictationShortcut?: ShouldForwardDictationShortcut }): () => void { const { browserTabId, guest, resolveRenderer, shouldForwardDictationShortcut } = args + let ctrlTabSwitching = false const handler = (event: Electron.Event, input: Electron.Input): void => { + if (isCtrlTabSwitchKey(input)) { + event.preventDefault() + if (input.type === 'keyDown') { + ctrlTabSwitching = true + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) + } + return + } + + if (ctrlTabSwitching && isControlKeyRelease(input)) { + event.preventDefault() + ctrlTabSwitching = false + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyUp') + return + } + if (input.type !== 'keyDown') { return } diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index f637398b633..8b06d790211 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -12,6 +12,7 @@ const { guestSetBackgroundThrottlingMock, guestSetWindowOpenHandlerMock, guestExecuteJavaScriptMock, + guestExecuteJavaScriptInIsolatedWorldMock, guestIsDestroyedMock, guestGetZoomFactorMock, guestCapturePageMock, @@ -25,6 +26,7 @@ const { guestSetBackgroundThrottlingMock: vi.fn(), guestSetWindowOpenHandlerMock: vi.fn(), guestExecuteJavaScriptMock: vi.fn(), + guestExecuteJavaScriptInIsolatedWorldMock: vi.fn(), guestIsDestroyedMock: vi.fn(() => false), guestGetZoomFactorMock: vi.fn(() => 1), guestCapturePageMock: vi.fn(), @@ -53,6 +55,7 @@ function makeGuest(id: number) { off: guestOffMock, openDevTools: vi.fn(), executeJavaScript: guestExecuteJavaScriptMock, + executeJavaScriptInIsolatedWorld: guestExecuteJavaScriptInIsolatedWorldMock, getZoomFactor: guestGetZoomFactorMock, capturePage: guestCapturePageMock, getURL: vi.fn(() => 'https://example.com/') @@ -67,6 +70,7 @@ describe('browserManager grab operations', () => { vi.clearAllMocks() guestIsDestroyedMock.mockReturnValue(false) guestExecuteJavaScriptMock.mockResolvedValue(true) + guestExecuteJavaScriptInIsolatedWorldMock.mockResolvedValue(true) browserManager.unregisterAll() guest = makeGuest(101) @@ -753,11 +757,11 @@ describe('browserManager grab operations', () => { }) expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith( 1, - `(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = 'none'; })()` + expect.stringContaining('__orcaGrab') ) expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith( 2, - `(function(){ var g = window.__orcaGrab; if (g && g.host) g.host.style.display = ''; })()` + expect.stringContaining('__orcaGrab') ) expect(guestExecuteJavaScriptMock).toHaveBeenNthCalledWith(3, 'window.innerWidth') }) diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 99a32897443..716707d62da 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -1108,6 +1108,75 @@ describe('browserManager', () => { expect(rendererSendMock).toHaveBeenNthCalledWith(9, 'ui:hardReloadBrowserPage') }) + it('forwards browser guest Ctrl+Tab keydown and Ctrl release', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const beforeInputHandler = guestOnMock.mock.calls + .filter(([event]) => event === 'before-input-event') + .at(-1)?.[1] as + | ((event: { preventDefault: () => void }, input: Record) => void) + | undefined + + const keyDownPreventDefault = vi.fn() + beforeInputHandler?.( + { preventDefault: keyDownPreventDefault }, + { + type: 'keyDown', + code: 'Tab', + key: 'Tab', + meta: false, + control: true, + alt: false, + shift: false + } + ) + const keyUpPreventDefault = vi.fn() + beforeInputHandler?.( + { preventDefault: keyUpPreventDefault }, + { + type: 'keyUp', + code: 'ControlRight', + key: 'Control', + meta: false, + control: false, + alt: false, + shift: false + } + ) + + expect(keyDownPreventDefault).toHaveBeenCalledTimes(1) + expect(keyUpPreventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', { shiftKey: false }) + expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyUp') + }) + it('cleans up prior guest listeners before re-registering the same tab', () => { const guest = { id: 808, @@ -1220,6 +1289,7 @@ describe('browserManager', () => { on: guestOnMock, off: guestOffMock, openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(true), debugger: { isAttached: debuggerIsAttached, attach: debuggerAttach, @@ -1358,5 +1428,34 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) + + it('installs annotation viewport bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-annotations', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' + }) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index d2f41721e96..caab6977686 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -37,6 +37,11 @@ import { import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' +import { + type BrowserAnnotationViewportBridgeOptions, + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + buildBrowserAnnotationViewportBridgeScript +} from '../../shared/browser-annotation-viewport-bridge' // Why: mobile presets need a touch-capable UA or responsive sites serve the // desktop variant based on UA sniffing. This is the Chrome DevTools default @@ -109,6 +114,7 @@ export class BrowserManager { private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() + private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() @@ -681,6 +687,7 @@ export class BrowserManager { // Why: drop any pending viewport-op chain for this tab so the Map doesn't // retain a resolved promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } unregisterAll(): void { @@ -708,6 +715,7 @@ export class BrowserManager { this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() + this.annotationViewportBridgeOpsByTabId.clear() } getGuestWebContentsId(browserTabId: string): number | null { @@ -956,6 +964,53 @@ export class BrowserManager { } } + async setAnnotationViewportBridge( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions + ): Promise { + const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + } + } + + private async doSetAnnotationViewportBridgeImpl( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + this.webContentsIdByTabId.delete(browserTabId) + this.tabIdByWebContentsId.delete(webContentsId) + return false + } + + try { + // Why: the scroll bridge runs outside the page world so page monkey + // patches cannot read the per-tab token or tamper with bridge state. + await guest.executeJavaScriptInIsolatedWorld( + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], + false + ) + return true + } catch { + return false + } + } + private async doSetViewportOverrideImpl( browserTabId: string, override: BrowserViewportOverride | null diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index 2a4ae803dd1..176f355f1e8 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -119,4 +119,22 @@ describe('buildGuestOverlayScript', () => { // The catch block should return '' not the raw URL expect(script).toContain("return '';") }) + + it('arm script slices text nodes before normalizing bounded text', () => { + const script = buildGuestOverlayScript('arm') + + expect(script).toContain("normalizeText((node.nodeValue || '').slice(0, remaining))") + expect(script).toContain('value = value.slice(start, end)') + expect(script).not.toContain('normalizeText(node.nodeValue ||') + expect(script).not.toContain("(el.textContent || '').trim()") + expect(script).not.toContain('ref.textContent') + }) + + it('arm script walks nearby siblings without materializing sibling arrays', () => { + const script = buildGuestOverlayScript('arm') + + expect(script).toContain('previousElementSibling') + expect(script).toContain('nextElementSibling') + expect(script).not.toContain('Array.from(parent.children)') + }) }) diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index d65579726b5..35fbdb5f0b2 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -68,8 +68,18 @@ const ARM_SCRIPT = `(function() { nearbyTextEntryMaxLength: 200, nearbyTextMaxEntries: 10, htmlSnippetMaxLength: 4096, - ancestorPathMaxEntries: 10 + ancestorPathMaxEntries: 10, + nearbyElementsMaxEntries: 6, + nearbyElementMaxLength: 160, + selectorMaxLength: 700, + pathMaxLength: 900, + cssClassesMaxLength: 500, + selectedTextMaxLength: 500, + sourceFileMaxLength: 500, + reactComponentsMaxLength: 500 }; + var TEXT_NODE_SCAN_LIMIT = 80; + var NEARBY_ELEMENT_SCAN_LIMIT = 80; // --- Safe attribute names --- var SAFE_ATTRS = new Set([ @@ -118,9 +128,96 @@ const ARM_SCRIPT = `(function() { } } + function normalizeText(text) { + return String(text || '').trim().replace(/\\s+/g, ' '); + } + + function getBoundedText(el, max) { + try { + var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + var chunks = []; + var length = 0; + var inspected = 0; + var node = walker.nextNode(); + while (node && length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) { + inspected++; + var separatorLength = chunks.length > 0 ? 1 : 0; + var remaining = max + 20 - length - separatorLength; + if (remaining <= 0) break; + var value = normalizeText((node.nodeValue || '').slice(0, remaining)); + if (value) { + chunks.push(value.slice(0, remaining)); + length += Math.min(value.length, remaining) + separatorLength; + } + node = walker.nextNode(); + } + return clampStr(normalizeText(chunks.join(' ')), max); + } catch (e) { + return ''; + } + } + function getTextSnippet(el) { - var text = (el.textContent || '').trim().replace(/\\s+/g, ' '); - return clampStr(text, BUDGET.textSnippetMaxLength); + return getBoundedText(el, BUDGET.textSnippetMaxLength); + } + + function getSelectedText() { + try { + var selection = window.getSelection ? window.getSelection() : null; + if (!selection || selection.rangeCount === 0) return ''; + var chunks = []; + var length = 0; + var inspected = 0; + for (var i = 0; i < selection.rangeCount && length < BUDGET.selectedTextMaxLength + 20; i++) { + var range = selection.getRangeAt(i); + var walkerRoot = range.commonAncestorContainer; + var walker = document.createTreeWalker( + walkerRoot, + NodeFilter.SHOW_TEXT, + { + acceptNode: function(node) { + if (range.intersectsNode && !range.intersectsNode(node)) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + var node = walkerRoot.nodeType === Node.TEXT_NODE ? walkerRoot : walker.nextNode(); + while ( + node && + length < BUDGET.selectedTextMaxLength + 20 && + inspected < TEXT_NODE_SCAN_LIMIT + ) { + inspected++; + var textNode = node; + var value = textNode.nodeValue || ''; + var separatorLength = chunks.length > 0 ? 1 : 0; + var remaining = BUDGET.selectedTextMaxLength + 20 - length - separatorLength; + if (remaining <= 0) break; + if (value) { + var start = textNode === range.startContainer ? range.startOffset : 0; + var end = textNode === range.endContainer ? range.endOffset : value.length; + if (end > start + remaining) { + end = start + remaining; + } + if (textNode === range.startContainer) { + start = Math.min(start, value.length); + } + value = value.slice(start, end); + value = normalizeText(value); + } + if (value) { + chunks.push(value.slice(0, remaining)); + length += Math.min(value.length, remaining) + separatorLength; + } + node = walker.nextNode(); + } + } + return clampStr(chunks.join(' '), BUDGET.selectedTextMaxLength); + } catch (e) { + return ''; + } } function getHtmlSnippet(el) { @@ -171,14 +268,14 @@ const ARM_SCRIPT = `(function() { var names = []; for (var i = 0; i < parts.length; i++) { var ref = document.getElementById(parts[i]); - if (ref) names.push((ref.textContent || '').trim()); + if (ref) names.push(getBoundedText(ref, 100)); } if (names.length) accessibleName = names.join(' '); } else { // Fall back to text content for buttons/links var tag = el.tagName.toLowerCase(); if (tag === 'button' || tag === 'a' || tag === 'label') { - accessibleName = clampStr((el.textContent || '').trim(), 100); + accessibleName = getBoundedText(el, 100); } else if (el.getAttribute('title')) { accessibleName = el.getAttribute('title'); } else if (el.getAttribute('alt')) { @@ -204,47 +301,162 @@ const ARM_SCRIPT = `(function() { return result; } + function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === 'function') { + return window.CSS.escape(value); + } + return String(value).replace(/[^a-zA-Z0-9_-]/g, function(ch) { + return '\\\\' + ch; + }); + } + + function looksHashy(value) { + return /^[A-Za-z0-9_-]{12,}$/.test(value) && /\\d/.test(value) && /[A-Z]/.test(value); + } + + function getStableClasses(el, maxCount) { + if (!el.classList) return []; + var result = []; + for (var i = 0; i < el.classList.length && result.length < maxCount; i++) { + var cls = el.classList[i]; + if (!cls || cls.length > 60 || containsSecret(cls)) continue; + if (/^css-[a-z0-9]+$/i.test(cls) || looksHashy(cls)) continue; + result.push(cls); + } + return result; + } + + function buildSelectorPart(el) { + var tag = el.tagName.toLowerCase(); + var id = el.id; + if (id && !containsSecret(id)) { + return tag + '#' + cssEscape(id); + } + var classes = getStableClasses(el, 2); + if (classes.length > 0) { + return tag + classes.map(function(cls) { return '.' + cssEscape(cls); }).join(''); + } + return tag; + } + + function isUniqueSelector(selector) { + try { + return document.querySelectorAll(selector).length === 1; + } catch(e) { + return false; + } + } + + function getNthOfTypeSuffix(current) { + var tag = current.tagName; + var index = 1; + var sibling = current.previousElementSibling; + while (sibling) { + if (sibling.tagName === tag) index++; + sibling = sibling.previousElementSibling; + } + if (index > 1) return ':nth-of-type(' + index + ')'; + + sibling = current.nextElementSibling; + while (sibling) { + if (sibling.tagName === tag) return ':nth-of-type(1)'; + sibling = sibling.nextElementSibling; + } + return ''; + } + function buildSelector(el) { var parts = []; var current = el; - while (current && current !== document.body && parts.length < 10) { - var tag = current.tagName.toLowerCase(); - var id = current.id; - if (id) { - parts.unshift(tag + '#' + CSS.escape(id)); - break; - } + while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body && parts.length < 10) { + var part = buildSelectorPart(current); var parent = current.parentElement; - if (parent) { - var siblings = Array.from(parent.children).filter( - function(c) { return c.tagName === current.tagName; } - ); - if (siblings.length > 1) { - var index = siblings.indexOf(current) + 1; - parts.unshift(tag + ':nth-of-type(' + index + ')'); - } else { - parts.unshift(tag); - } - } else { - parts.unshift(tag); + if (parent && !isUniqueSelector(parts.concat([part]).reverse().join(' > '))) { + part += getNthOfTypeSuffix(current); + } + parts.unshift(part); + var selector = parts.join(' > '); + if (isUniqueSelector(selector)) { + return clampStr(selector, BUDGET.selectorMaxLength); } current = parent; } - return parts.join(' > ') || el.tagName.toLowerCase(); + return clampStr(parts.join(' > ') || el.tagName.toLowerCase(), BUDGET.selectorMaxLength); + } + + function buildReadablePath(el) { + var parts = []; + var current = el; + while (current && current !== document.documentElement && parts.length < 6) { + var tag = current.tagName.toLowerCase(); + if (tag === 'html' || tag === 'body') break; + var label = tag; + var aria = current.getAttribute('aria-label'); + var role = current.getAttribute('role'); + var stableClasses = getStableClasses(current, 1); + if (current.id && !containsSecret(current.id)) { + label = '#' + cssEscape(current.id); + } else if (aria && !containsSecret(aria)) { + label = tag + '[aria-label="' + clampStr(aria, 40).replace(/"/g, '\\\\"') + '"]'; + } else if (role && !containsSecret(role)) { + label = tag + '[role="' + clampStr(role, 30).replace(/"/g, '\\\\"') + '"]'; + } else if (stableClasses.length > 0) { + label = '.' + cssEscape(stableClasses[0]); + } + parts.unshift(label); + current = current.parentElement; + } + return clampStr(parts.join(' > '), BUDGET.pathMaxLength); + } + + function buildFullPath(el) { + var parts = []; + var current = el; + while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement && parts.length < 20) { + parts.unshift(buildSelectorPart(current)); + current = current.parentElement; + } + return clampStr(parts.join(' > '), BUDGET.pathMaxLength); } function getNearbyText(el) { var results = []; var parent = el.parentElement; if (!parent) return results; - var siblings = parent.children; - for (var i = 0; i < siblings.length && results.length < BUDGET.nearbyTextMaxEntries; i++) { - if (siblings[i] === el) continue; - var text = (siblings[i].textContent || '').trim().replace(/\\s+/g, ' '); + + function addSiblingText(sibling) { + if (!sibling) return; + var text = getBoundedText(sibling, BUDGET.nearbyTextEntryMaxLength); if (text) { results.push(clampStr(text, BUDGET.nearbyTextEntryMaxLength)); } } + + var inspected = 0; + var previous = el.previousElementSibling; + var next = el.nextElementSibling; + while ( + results.length < BUDGET.nearbyTextMaxEntries && + inspected < NEARBY_ELEMENT_SCAN_LIMIT && + (previous || next) + ) { + if (previous) { + var previousSibling = previous; + previous = previous.previousElementSibling; + inspected++; + addSiblingText(previousSibling); + } + if ( + next && + results.length < BUDGET.nearbyTextMaxEntries && + inspected < NEARBY_ELEMENT_SCAN_LIMIT + ) { + var nextSibling = next; + next = next.nextElementSibling; + inspected++; + addSiblingText(nextSibling); + } + } return results; } @@ -260,9 +472,149 @@ const ARM_SCRIPT = `(function() { return path; } + function getNearbyElements(el) { + var parent = el.parentElement; + if (!parent) return []; + var result = []; + + function addSibling(sibling) { + if (!sibling) return; + if (sibling === el) return; + var rect = sibling.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + var label = sibling.tagName.toLowerCase(); + var stableClasses = getStableClasses(sibling, 1); + if (stableClasses.length > 0) label += '.' + stableClasses[0]; + var text = getBoundedText(sibling, 50); + if (text) label += ' "' + clampStr(text, 50) + '"'; + result.push(clampStr(label, BUDGET.nearbyElementMaxLength)); + } + var inspected = 0; + var previous = el.previousElementSibling; + var next = el.nextElementSibling; + while ( + result.length < BUDGET.nearbyElementsMaxEntries && + inspected < NEARBY_ELEMENT_SCAN_LIMIT && + (previous || next) + ) { + if (previous) { + var previousSibling = previous; + previous = previous.previousElementSibling; + inspected++; + addSibling(previousSibling); + } + if ( + next && + result.length < BUDGET.nearbyElementsMaxEntries && + inspected < NEARBY_ELEMENT_SCAN_LIMIT + ) { + var nextSibling = next; + next = next.nextElementSibling; + inspected++; + addSibling(nextSibling); + } + } + return result; + } + + function isElementFixed(el) { + var current = el; + while (current && current !== document.body) { + var position = window.getComputedStyle(current).position; + if (position === 'fixed' || position === 'sticky') return true; + current = current.parentElement; + } + return false; + } + + function getFiberFromElement(el) { + var keys = Object.keys(el); + for (var i = 0; i < keys.length; i++) { + if (keys[i].indexOf('__reactFiber$') === 0 || keys[i].indexOf('__reactInternalInstance$') === 0) { + try { + return el[keys[i]] || null; + } catch (e) { + return null; + } + } + } + return null; + } + + function getComponentNameFromFiber(fiber) { + if (!fiber) return null; + var type = fiber.type || fiber.elementType; + if (!type || typeof type === 'string') return null; + if (type.displayName || type.name) return type.displayName || type.name; + if (type.render && (type.render.displayName || type.render.name)) { + return type.render.displayName || type.render.name; + } + if (type.type && (type.type.displayName || type.type.name)) { + return type.type.displayName || type.type.name; + } + return null; + } + + function shouldSkipReactName(name) { + if (!name || name.length <= 2) return true; + return /^(Fragment|Root|Routes|Route|Outlet|Provider|Consumer|Profiler|Suspense)$/.test(name) || + /(?:Boundary|BoundaryHandler|Router|Provider|Consumer|Context|Wrapper)$/.test(name) || + /^(Inner|Outer|Client|Server|RSC|Dev|React|Hot)/.test(name); + } + + function cleanSourcePath(path) { + if (!path) return ''; + return String(path) + .replace(/[?#].*$/, '') + .replace(/^turbopack:\\/\\/\\/\\[project\\]\\//, '') + .replace(/^webpack-internal:\\/\\/\\/\\.\\//, '') + .replace(/^webpack-internal:\\/\\/\\//, '') + .replace(/^webpack:\\/\\/\\/\\.\\//, '') + .replace(/^webpack:\\/\\/\\//, '') + .replace(/^turbopack:\\/\\/\\//, '') + .replace(/^https?:\\/\\/[^/]+\\//, '') + .replace(/^file:\\/\\/\\//, '/') + .replace(/^\\([^)]+\\)\\/\\.\\//, '') + .replace(/^\\.\\//, ''); + } + + function getReactMetadata(el) { + try { + var fiber = getFiberFromElement(el); + var components = []; + var sourceFile = null; + var depth = 0; + while (fiber && depth < 35) { + var name = getComponentNameFromFiber(fiber); + if (name && !shouldSkipReactName(name) && components.indexOf(name) === -1 && components.length < 6) { + components.push(name); + } + var source = fiber._debugSource || (fiber._debugOwner && fiber._debugOwner._debugSource); + if (!sourceFile && source && source.fileName && source.lineNumber) { + sourceFile = cleanSourcePath(source.fileName) + ':' + source.lineNumber + + (source.columnNumber !== undefined ? ':' + source.columnNumber : ''); + if (containsSecret(sourceFile)) { + sourceFile = null; + } + } + fiber = fiber.return; + depth++; + } + return { + reactComponents: components.length > 0 + ? clampStr(components.slice().reverse().map(function(c) { return '<' + c + '>'; }).join(' '), BUDGET.reactComponentsMaxLength) + : null, + sourceFile: sourceFile ? clampStr(sourceFile, BUDGET.sourceFileMaxLength) : null + }; + } catch (e) { + return { reactComponents: null, sourceFile: null }; + } + } + // --- Build full payload for an element --- function extractPayload(el) { var rect = el.getBoundingClientRect(); + var react = getReactMetadata(el); return { page: { sanitizedUrl: sanitizeUrl(window.location.href), @@ -277,6 +629,16 @@ const ARM_SCRIPT = `(function() { target: { tagName: el.tagName.toLowerCase(), selector: buildSelector(el), + elementPath: buildReadablePath(el), + fullPath: buildFullPath(el), + cssClasses: containsSecret(el.getAttribute('class') || '') + ? '[redacted]' + : clampStr(el.getAttribute('class') || '', BUDGET.cssClassesMaxLength), + nearbyElements: getNearbyElements(el), + selectedText: getSelectedText() || null, + isFixed: isElementFixed(el), + reactComponents: react.reactComponents, + sourceFile: react.sourceFile, textSnippet: getTextSnippet(el), htmlSnippet: getHtmlSnippet(el), attributes: getSafeAttributes(el), @@ -349,7 +711,7 @@ const ARM_SCRIPT = `(function() { // Build label text var tag = el.tagName.toLowerCase(); var role = el.getAttribute('role'); - var text = (el.textContent || '').trim().replace(/\\s+/g, ' '); + var text = getBoundedText(el, 40); if (text.length > 40) text = text.slice(0, 37) + '...'; var w = Math.round(rect.width); var h = Math.round(rect.height); @@ -420,6 +782,16 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { return; } + function extractSelectedPayload(el) { + try { + return grab.extractPayload(el); + } catch (error) { + grab.cleanup(); + reject(error instanceof Error ? error : new Error('Failed to extract element context')); + return null; + } + } + function onClick(e) { e.preventDefault(); e.stopPropagation(); @@ -432,7 +804,8 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { reject(new Error('cancelled')); return; } - var payload = grab.extractPayload(el); + var payload = extractSelectedPayload(el); + if (!payload) return; // Why: freeze the highlight instead of removing it so the user sees // which element was selected while the copy menu is shown. Teardown // happens later when the renderer calls setGrabMode(false) or re-arms. @@ -456,7 +829,8 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { reject(new Error('cancelled')); return; } - var payload = grab.extractPayload(el); + var payload = extractSelectedPayload(el); + if (!payload) return; grab.freezeHighlight(); resolve({ __orcaContextMenu: true, payload: payload }); } @@ -482,7 +856,13 @@ const FINALIZE_SCRIPT = `(function() { if (!grab) return null; var el = grab.getCurrentElement(); if (!el) return null; - var payload = grab.extractPayload(el); + var payload = null; + try { + payload = grab.extractPayload(el); + } catch (e) { + grab.cleanup(); + return null; + } grab.cleanup(); return payload; })()` @@ -499,7 +879,11 @@ const EXTRACT_HOVER_SCRIPT = `(function() { if (!grab) return null; var el = grab.getCurrentElement(); if (!el) return null; - return grab.extractPayload(el); + try { + return grab.extractPayload(el); + } catch (e) { + return null; + } })()` // --------------------------------------------------------------------------- diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 2ae6a09990c..2baabbd6e98 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -257,4 +257,66 @@ describe('ClaudeUsageStore', () => { expect(summary.estimatedCostUsd).toBeCloseTo(8.07) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo-a' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T15:00:00.000Z', + lastTimestamp: '2026-04-09T15:05:00.000Z', + model: 'claude-sonnet-4-6', + lastCwd: '/workspace/repo-a', + lastGitBranch: 'feature/a', + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + turnCount: 1, + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCacheReadTokens: 200, + totalCacheWriteTokens: 100, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId, + turnCount: 1, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheWriteTokens: 100 + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyClaudeData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-09T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.totalTokens).toBe(1800) + expect(usage.estimatedCostUsd).toBeCloseTo(0.010935) + }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index dbc2add004f..9d3b212989c 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -12,6 +12,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } 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' @@ -19,6 +20,7 @@ import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. @@ -36,6 +38,13 @@ type ClaudeModelPricing = { cacheWriteAboveThreshold?: number } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 const SONNET_LONG_CONTEXT_PRICING = { thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, @@ -298,7 +307,16 @@ export class ClaudeUsageStore { 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. - return getDefaultState() + // 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(), @@ -622,6 +640,123 @@ export class ClaudeUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Claude usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Claude usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.turns += entry.turnCount + acc.inputTokens += entry.inputTokens + acc.outputTokens += entry.outputTokens + acc.cacheReadTokens += entry.cacheReadTokens + acc.cacheWriteTokens += entry.cacheWriteTokens + return acc + }, + { + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + ) + const estimatedCostUsd = estimateCostUsd( + session.model, + totals.inputTokens, + totals.outputTokens, + totals.cacheReadTokens, + totals.cacheWriteTokens + ) + + return { + status: 'known', + provider: 'claude', + model: session.model, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens, + reasoningOutputTokens: null, + totalTokens: + totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens, + estimatedCostUsd, + estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent', + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Claude usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index d1d31db4438..63148e5a2e5 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -82,7 +83,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + buildWindowsAgentHookPostCommand('claude'), 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 50b31ee9f5c..ec15accdfed 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,6 +74,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalCwd: '~', floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', + combinedDiffFileTreeVisibleByDefault: false, notifications: { enabled: true, agentTaskComplete: true, @@ -93,6 +94,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', + visibleTaskProviders: ['github', 'gitlab', 'linear'], defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 21866294248..3c099ce575b 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -720,17 +720,35 @@ export class CodexRuntimeHomeService { } private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + let rawContents: string try { - const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + rawContents = readFileSync(snapshotPath, 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(rawContents) as unknown if ( parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'authJson' in parsed && - (typeof parsed.authJson === 'string' || parsed.authJson === null) + (typeof (parsed as { authJson: unknown }).authJson === 'string' || + (parsed as { authJson: unknown }).authJson === null) ) { return parsed as CodexSystemDefaultSnapshot } + // Why: pre-PR snapshots wrote raw auth.json contents verbatim. Treat any + // valid JSON object without an authJson wrapper as the legacy format so + // upgraders do not lose their system-default auth on first deselect. + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('authJson' in parsed) + ) { + return { authJson: rawContents } + } } catch { return null } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 40532ad3c8e..27a881a9ca5 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -67,6 +67,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings floatingTerminalCwd: '~', floatingTerminalTriggerLocation: 'floating-button', diffDefaultView: 'inline', + combinedDiffFileTreeVisibleByDefault: false, notifications: { enabled: true, agentTaskComplete: true, @@ -86,6 +87,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', + visibleTaskProviders: ['github', 'gitlab', 'linear'], defaultRepoSelection: null, defaultLinearTeamSelection: null, opencodeSessionCookie: '', diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index 65e773d9663..e88d432d790 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -158,6 +158,16 @@ describe('resolveClaudeCommand', () => { expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath) }) + it('finds native Windows claude.exe in user-local bin', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const nativePath = join(root, '.local', 'bin', 'claude.exe') + makeExecutable(nativePath) + + expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe( + nativePath + ) + }) + it('returns the bare command when no filesystem candidate exists', () => { const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) @@ -195,4 +205,12 @@ describe('getVersionManagerBinPaths', () => { expect(paths).toContain(join(root, '.local', 'share', 'pnpm')) expect(paths).not.toContain(join(root, 'Library', 'pnpm')) }) + + it('includes Windows user-local bin for native CLI installers', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root }) + + expect(paths).toContain(join(root, '.local', 'bin')) + expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm')) + }) }) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 2215583ac54..c11cf07ff78 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -98,6 +98,9 @@ function getVersionManagerDirectories( } if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 00686de5971..8f407b130bf 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -654,11 +654,108 @@ describe('CodexUsageStore', () => { sessions: [], dailyAggregates: [], scanState: { - enabled: false, + enabled: true, lastScanStartedAt: null, lastScanCompletedAt: null, lastScanError: null } }) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-10T15:00:00.000Z', + lastTimestamp: '2026-04-10T15:05:00.000Z', + primaryModel: 'gpt-5', + hasMixedModels: false, + primaryProjectLabel: 'Repo', + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + modelBreakdown: [ + { + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + locationModelBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyCodexData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-10T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.cacheReadTokens).toBe(400) + expect(usage.reasoningOutputTokens).toBe(100) + expect(usage.estimatedCostUsd).toBeCloseTo(0.0033) + }) }) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 8699e4f49d1..7c24f9b6b32 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -12,6 +12,7 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../../shared/codex-usage-types' +import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' @@ -19,6 +20,7 @@ import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -32,6 +34,13 @@ type CodexModelPricing = { outputTiers?: TieredPrice[] } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 const MODEL_PRICING: Record = { @@ -104,7 +113,16 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU // Reusing an older cache would silently serve wrong model/session rows // until the next forced rescan, so schema changes must invalidate stale // persisted analytics instead of best-effort patching partial data. - return getDefaultState() + // 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: state.scanState?.enabled ?? defaults.scanState.enabled + } + } } return { ...state, @@ -635,6 +653,154 @@ export class CodexUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'codex', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Codex usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Codex usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.events += entry.eventCount + acc.inputTokens += entry.inputTokens + acc.cachedInputTokens += entry.cachedInputTokens + acc.outputTokens += entry.outputTokens + acc.reasoningOutputTokens += entry.reasoningOutputTokens + acc.totalTokens += entry.totalTokens + return acc + }, + { + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + ) + const scopedModelRows = session.locationModelBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown + const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))] + let estimatedCostUsd = 0 + let hasKnownCost = false + if (scopedModelRows.length > 0) { + for (const modelRow of scopedModelRows) { + const cost = estimateCostUsd( + modelRow.modelKey, + modelRow.inputTokens, + modelRow.cachedInputTokens, + modelRow.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + } else if (!session.hasMixedModels) { + const cost = estimateCostUsd( + session.primaryModel, + totals.inputTokens, + totals.cachedInputTokens, + totals.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + + return { + status: 'known', + provider: 'codex', + model: + modelLabels.length === 1 + ? modelLabels[0] + : session.hasMixedModels + ? 'Mixed models' + : session.primaryModel, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cachedInputTokens, + cacheWriteTokens: null, + reasoningOutputTokens: totals.reasoningOutputTokens, + totalTokens: totals.totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, + estimatedCostSource: hasKnownCost ? 'api_equivalent' : null, + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Codex usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 45c315585d4..447294af058 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -10,6 +10,8 @@ import { readHookTrustEntries, removeHookTrustEntries, upsertHookTrustEntries, + upsertProjectTrustLevel, + upsertProjectTrustLevelInContent, type CodexTrustEntry } from './config-toml-trust' @@ -633,6 +635,76 @@ describe('upsertHookTrustEntries', () => { }) }) +describe('upsertProjectTrustLevel', () => { + it('creates a projects trust block when the config is empty', () => { + expect(upsertProjectTrustLevelInContent('', '/tmp/codex-ws', 'trusted')).toBe( + ['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', ''].join('\n') + ) + }) + + it('updates an existing project block without touching unrelated keys', () => { + const original = [ + 'model = "gpt-5.5"', + '', + '[projects."/tmp/codex-ws"]', + 'notes = "keep"', + 'trust_level = "untrusted"', + '', + '[profiles.default]', + 'sandbox_mode = "workspace-write"', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted') + + expect(updated).toContain('model = "gpt-5.5"') + expect(updated).toContain('[projects."/tmp/codex-ws"]\nnotes = "keep"') + expect(updated).toContain('trust_level = "trusted"') + expect(updated).not.toContain('trust_level = "untrusted"') + expect(updated).toContain('[profiles.default]\nsandbox_mode = "workspace-write"') + }) + + it('adds trust_level to an existing project block that does not have one', () => { + const original = [ + '[projects."/tmp/codex-ws"]', + 'notes = "keep"', + '', + '[other]', + 'value = 1', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted') + + expect(updated).toContain( + ['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', 'notes = "keep"'].join('\n') + ) + expect(updated).toContain('[other]\nvalue = 1') + }) + + it('preserves CRLF endings and escapes the project path in the header', () => { + const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n') + + const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted') + + expect(updated).toContain( + ['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n') + ) + expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"') + }) + + it('writes config.toml and avoids rewriting an already-trusted project', () => { + upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted') + const firstWrite = readFileSync(configPath, 'utf-8') + + rmSync(`${configPath}.bak`, { force: true }) + upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted') + + expect(readFileSync(configPath, 'utf-8')).toBe(firstWrite) + expect(existsSync(`${configPath}.bak`)).toBe(false) + }) +}) + describe('removeHookTrustEntries', () => { it('is a no-op (creates no file) when the config does not exist', () => { removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0']) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 179ce2572a0..4d40183e291 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -56,6 +56,8 @@ export type CodexHookTrustState = { enabled?: boolean } +export type CodexProjectTrustLevel = 'trusted' | 'untrusted' + // Why: matches Codex's canonical_json. Sorts object keys recursively before // SHA-256ing; arrays preserve order. function canonicalize(value: unknown): unknown { @@ -210,6 +212,61 @@ export function upsertHookTrustEntriesInContent( return updated } +export function upsertProjectTrustLevel( + configPath: string, + projectPath: string, + trustLevel: CodexProjectTrustLevel +): void { + const existing = existsSync(configPath) ? readTomlFile(configPath) : '' + const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) + if (updated === existing) { + return + } + writeConfigAtomically(configPath, updated) +} + +export function upsertProjectTrustLevelInContent( + existingContent: string, + projectPath: string, + trustLevel: CodexProjectTrustLevel +): string { + const existing = + existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent + const headerPattern = buildProjectHeaderPattern(projectPath) + const match = headerPattern.exec(existing) + const eol = existing.includes('\r\n') ? '\r\n' : '\n' + const trustLine = `trust_level = "${trustLevel}"` + + if (!match) { + const block = [`[projects."${escapeTomlString(projectPath)}"]`, trustLine].join(eol) + if (existing.length === 0) { + return `${block}${eol}` + } + const separator = existing.endsWith(`${eol}${eol}`) + ? '' + : existing.endsWith(eol) + ? eol + : eol + eol + return `${existing}${separator}${block}${eol}` + } + + const headerLineEnd = match.index + match[0].length + const after = existing.slice(headerLineEnd) + const nextHeaderRel = findNextTableHeader(after) + const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel + const existingBlock = existing.slice(headerLineEnd, blockEnd) + const trustLevelLinePattern = + /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m + if (trustLevelLinePattern.test(existingBlock)) { + return ( + existing.slice(0, headerLineEnd) + + existingBlock.replace(trustLevelLinePattern, trustLine) + + existing.slice(blockEnd) + ) + } + return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` +} + // Why: build the canonical block we own. The two field names mirror what // Codex itself writes when the user approves via /hooks (HookStateToml // fields). `enabled` is plumbed through so an existing user-set @@ -289,6 +346,13 @@ function buildHeaderPattern(key: string): RegExp { ) } +function buildProjectHeaderPattern(projectPath: string): RegExp { + const escapedPath = escapeRegex(escapeTomlString(projectPath)) + return new RegExp( + `(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)` + ) +} + function escapeRegex(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index f7cada61a68..c431b121ce2 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -5,6 +5,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -93,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/codex') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + buildWindowsAgentHookPostCommand('codex'), 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/copilot/hook-service.test.ts b/src/main/copilot/hook-service.test.ts new file mode 100644 index 00000000000..6cc687646d9 --- /dev/null +++ b/src/main/copilot/hook-service.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { spawnSync } from 'child_process' + +import { CopilotHookService } from './hook-service' + +let tmpDir: string +let copilotHome: string +let originalCopilotHome: string | undefined +let originalHome: string | undefined +let originalUserProfile: string | undefined + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-hooks-')) + copilotHome = join(tmpDir, 'copilot-home') + originalCopilotHome = process.env.COPILOT_HOME + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + process.env.COPILOT_HOME = copilotHome + process.env.HOME = tmpDir + process.env.USERPROFILE = tmpDir +}) + +afterEach(() => { + if (originalCopilotHome === undefined) { + delete process.env.COPILOT_HOME + } else { + process.env.COPILOT_HOME = originalCopilotHome + } + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + rmSync(tmpDir, { recursive: true, force: true }) +}) + +function readConfig(): Record { + return JSON.parse(readFileSync(join(copilotHome, 'hooks', 'orca.json'), 'utf-8')) +} + +describe('CopilotHookService', () => { + it('installs a user-level Copilot hook file under COPILOT_HOME', () => { + const service = new CopilotHookService() + + const status = service.install() + const config = readConfig() + + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(copilotHome, 'hooks', 'orca.json')) + expect(config.version).toBe(1) + const hooks = config.hooks as Record + expect(Object.keys(hooks).sort()).toEqual( + [ + 'ErrorOccurred', + 'Notification', + 'PermissionRequest', + 'PostToolUse', + 'PostToolUseFailure', + 'PreCompact', + 'PreToolUse', + 'SessionEnd', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit', + 'subagentStart' + ].sort() + ) + const firstPromptHook = hooks.UserPromptSubmit[0] as Record + expect(firstPromptHook.type).toBe('command') + expect(firstPromptHook.timeoutSec).toBe(5) + if (process.platform === 'win32') { + expect(firstPromptHook.powershell).toContain('agent-hooks') + expect(firstPromptHook.powershell).toContain('copilot-hook.ps1') + expect(firstPromptHook.powershell).toContain('ORCA_COPILOT_HOOK_EVENT') + expect(firstPromptHook.powershell).toContain('UserPromptSubmit') + } else { + expect(firstPromptHook.bash).toContain('if [ -x ') + expect(firstPromptHook.bash).toContain('.orca/agent-hooks/copilot-hook.sh') + expect(firstPromptHook.bash).toContain("ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit'") + } + expect(existsSync(join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.sh'))).toBe( + process.platform !== 'win32' + ) + }) + + it.skipIf(process.platform === 'win32')('writes syntactically valid POSIX commands', () => { + const service = new CopilotHookService() + service.install() + const hooks = readConfig().hooks as Record + + for (const definitions of Object.values(hooks)) { + for (const definition of definitions) { + const bash = (definition as Record).bash + expect(typeof bash).toBe('string') + const result = spawnSync('/bin/sh', ['-n', '-c', bash as string]) + expect(result.status).toBe(0) + } + } + }) + + it('preserves user-authored hooks and sweeps stale managed entries', () => { + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify( + { + version: 1, + hooks: { + UserPromptSubmit: [ + { type: 'command', bash: 'echo user prompt' }, + { type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' } + ], + OldEvent: [{ type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' }] + } + }, + null, + 2 + ) + ) + + const service = new CopilotHookService() + service.install() + const hooks = readConfig().hooks as Record + + expect(hooks.OldEvent).toBeUndefined() + expect(hooks.UserPromptSubmit).toEqual( + expect.arrayContaining([expect.objectContaining({ bash: 'echo user prompt' })]) + ) + expect(hooks.UserPromptSubmit).toHaveLength(2) + }) + + it('forces version 1 in the dedicated Copilot hook file', () => { + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + version: 99, + hooks: {} + }) + ) + + const status = new CopilotHookService().install() + const config = readConfig() + + expect(status.state).toBe('installed') + expect(config.version).toBe(1) + }) + + it('clears disableAllHooks in the dedicated Copilot hook file', () => { + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + version: 1, + disableAllHooks: true, + hooks: {} + }) + ) + + const status = new CopilotHookService().install() + const config = readConfig() + + expect(status.state).toBe('installed') + expect(config.disableAllHooks).toBeUndefined() + }) + + it('reports partial when the dedicated Copilot hook file is disabled', () => { + const service = new CopilotHookService() + service.install() + const configPath = join(copilotHome, 'hooks', 'orca.json') + const config = readConfig() + config.disableAllHooks = true + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const status = service.getStatus() + + expect(status.state).toBe('partial') + expect(status.detail).toBe('Managed Copilot hook file is disabled') + }) + + it('remove deletes only Orca-managed Copilot hooks', () => { + const service = new CopilotHookService() + service.install() + const configPath = join(copilotHome, 'hooks', 'orca.json') + const config = readConfig() + const hooks = config.hooks as Record + hooks.UserPromptSubmit.unshift({ type: 'command', bash: 'echo user prompt' }) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const status = service.remove() + const nextHooks = readConfig().hooks as Record + + expect(status.state).toBe('not_installed') + expect(nextHooks.UserPromptSubmit).toEqual([{ type: 'command', bash: 'echo user prompt' }]) + expect(nextHooks.SessionStart).toBeUndefined() + }) + + it('remove does not create an orca.json file when nothing is installed', () => { + const status = new CopilotHookService().remove() + + expect(status.state).toBe('not_installed') + expect(existsSync(join(copilotHome, 'hooks', 'orca.json'))).toBe(false) + }) + + it('remove leaves nested user hooks untouched when no managed hook is present', () => { + const configPath = join(copilotHome, 'hooks', 'orca.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + const original = JSON.stringify( + { + version: 1, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { type: 'command', command: 'echo nested user hook' }, + { type: 'command', command: 'echo another user hook' } + ] + } + ] + } + }, + null, + 2 + ) + writeFileSync(configPath, original) + + const status = new CopilotHookService().remove() + + expect(status.state).toBe('not_installed') + expect(readFileSync(configPath, 'utf-8')).toBe(original) + }) + + it('returns an error status for malformed JSON', () => { + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync(join(copilotHome, 'hooks', 'orca.json'), '{not json') + + const status = new CopilotHookService().getStatus() + + expect(status).toEqual({ + agent: 'copilot', + state: 'error', + configPath: join(copilotHome, 'hooks', 'orca.json'), + managedHooksPresent: false, + detail: 'Could not parse Copilot hooks/orca.json' + }) + }) +}) diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts new file mode 100644 index 00000000000..ad0bb3005fc --- /dev/null +++ b/src/main/copilot/hook-service.ts @@ -0,0 +1,382 @@ +/* eslint-disable max-lines -- Why: local status/install/remove and SSH remote + install must share the same Copilot event list, script body, and + managed-command matching so local and remote hook behavior cannot drift. */ +import { existsSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + hookDefinitionHasManagedCommand, + readHooksJson, + removeManagedCommands, + wrapPosixHookCommand, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' + +// Why: Copilot's user-level hook files can use VS Code-compatible PascalCase +// names, which match the event vocabulary already normalized by Orca's hook +// server and avoid wrapper-side event remapping. +const COPILOT_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + // Why: GitHub's current reference documents subagentStart with only the + // camelCase payload shape. The wrapper passes the event name separately, so + // Orca can normalize it without depending on a PascalCase payload. + 'subagentStart', + 'SubagentStop', + 'PreCompact', + 'Stop', + 'ErrorOccurred', + 'PermissionRequest', + 'Notification' +] as const + +function getCopilotHome(): string { + const fromEnv = process.env.COPILOT_HOME?.trim() + return fromEnv ? fromEnv : join(homedir(), '.copilot') +} + +function getConfigPath(): string { + return join(getCopilotHome(), 'hooks', 'orca.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function quotePowerShellPath(path: string): string { + return `'${path.replaceAll("'", "''")}'` +} + +function getManagedCommand(scriptPath: string, eventName: string): string { + return process.platform === 'win32' + ? `$env:ORCA_COPILOT_HOOK_EVENT = '${eventName}'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${quotePowerShellPath(scriptPath)}` + : wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) +} + +function getManagedHookDefinition(command: string): HookDefinition { + return process.platform === 'win32' + ? { type: 'command', powershell: command, timeoutSec: 5 } + : { type: 'command', bash: command, timeoutSec: 5 } +} + +function getRemoteManagedHookDefinition(command: string): HookDefinition { + return { type: 'command', bash: command, timeoutSec: 5 } +} + +function definitionHasCurrentCommand(definition: HookDefinition, command: string): boolean { + return ( + definition.command === command || + definition.bash === command || + definition.powershell === command || + (Array.isArray(definition.hooks) && definition.hooks.some((hook) => hook.command === command)) + ) +} + +function definitionsChanged(before: HookDefinition[], after: HookDefinition[]): boolean { + return ( + before.length !== after.length || + after.some((definition, index) => definition !== before[index]) + ) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + "Write-Output '{}'", + // Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...` + // lines so surviving PTYs can refresh to the current Orca server. + 'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {', + ' try {', + ' Get-Content -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT | ForEach-Object {', + " if ($_ -match '^set ([A-Za-z0-9_]+)=(.*)$') {", + " [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')", + ' }', + ' }', + ' } catch {}', + '}', + 'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }', + '$inputData = [Console]::In.ReadToEnd()', + 'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }', + 'try {', + ' $payload = $inputData | ConvertFrom-Json', + ' $body = @{', + ' paneKey = $env:ORCA_PANE_KEY', + ' tabId = $env:ORCA_TAB_ID', + ' worktreeId = $env:ORCA_WORKTREE_ID', + ' hookEventName = $env:ORCA_COPILOT_HOOK_EVENT', + ' env = $env:ORCA_AGENT_HOOK_ENV', + ' version = $env:ORCA_AGENT_HOOK_VERSION', + ' payload = $payload', + ' } | ConvertTo-Json -Depth 100', + " Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/copilot') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body -TimeoutSec 2 | Out-Null", + '} catch {}', + 'exit 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + "printf '{}\\n'", + // Why: Copilot consumes stdout for some hooks, so stdout is emitted before + // endpoint refresh, stdin parsing, or the network POST can fail. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +export class CopilotHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'copilot', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Copilot hooks/orca.json' + } + } + + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const missing: string[] = [] + let presentCount = 0 + let staleManagedPresent = false + for (const eventName of COPILOT_EVENTS) { + const command = getManagedCommand(scriptPath, eventName) + const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] + const hasCurrentCommand = definitions.some((definition) => + definitionHasCurrentCommand(definition, command) + ) + if (hasCurrentCommand) { + presentCount += 1 + } else { + missing.push(eventName) + staleManagedPresent = + staleManagedPresent || + definitions.some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + } + } + + const managedHooksPresent = presentCount > 0 || staleManagedPresent + let state: AgentHookInstallState + let detail: string | null + if (config.disableAllHooks === true && managedHooksPresent) { + state = 'partial' + detail = 'Managed Copilot hook file is disabled' + } else if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0 && !staleManagedPresent) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'copilot', state, configPath, managedHooksPresent, detail } + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'copilot', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Copilot hooks/orca.json' + } + } + + const nextHooks = { ...config.hooks } + const managedEvents = new Set(COPILOT_EVENTS) + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const eventName of COPILOT_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + nextHooks[eventName] = [ + ...cleaned, + getManagedHookDefinition(getManagedCommand(scriptPath, eventName)) + ] + } + + config.version = 1 + delete config.disableAllHooks + config.hooks = nextHooks + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.copilot/hooks/orca.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/copilot-hook.sh` + + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'copilot', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Copilot hooks/orca.json' + } + } + + const nextHooks = { ...config.hooks } + const managedEvents = new Set(COPILOT_EVENTS) + const isManagedCommand = createManagedCommandMatcher('copilot-hook.sh') + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const eventName of COPILOT_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + nextHooks[eventName] = [ + ...cleaned, + getRemoteManagedHookDefinition( + wrapPosixHookCommand(remoteScriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName }) + ) + ] + } + + config.version = 1 + delete config.disableAllHooks + config.hooks = nextHooks + // Why: SSH remotes use POSIX scripts regardless of Orca's local OS. Write + // the script before hooks/orca.json so a partial install cannot point + // Copilot at a missing managed command. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'copilot', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'copilot', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + if (!existsSync(configPath)) { + return this.getStatus() + } + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'copilot', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Copilot hooks/orca.json' + } + } + + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + let changed = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + changed = changed || definitionsChanged(definitions, cleaned) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (!changed) { + return this.getStatus() + } + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const copilotHookService = new CopilotHookService() diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts new file mode 100644 index 00000000000..c674b8b4c7e --- /dev/null +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from './crash-breadcrumb-store' + +afterEach(() => { + clearCrashBreadcrumbsForTest() +}) + +describe('crash breadcrumb store', () => { + it('keeps a fixed-size in-memory snapshot', () => { + for (let index = 0; index < 32; index += 1) { + recordCrashBreadcrumb(`event_${index}`, { index }) + } + + const snapshot = getCrashBreadcrumbSnapshot() + + expect(snapshot).toHaveLength(30) + expect(snapshot[0].name).toBe('event_2') + expect(snapshot[29].name).toBe('event_31') + }) + + it('redacts sensitive breadcrumb fields before they can be snapshotted', () => { + recordCrashBreadcrumb('workspace_opened', { + path: '/Users/alice/project', + token: 'ghp_abcdefghijklmnopqrstuvwxyz', + ssh: true + }) + + expect(getCrashBreadcrumbSnapshot()[0].data).toEqual({ + path: '[redacted-path]', + token: '[redacted-secret]', + ssh: true + }) + }) + + it('returns a copy so callers cannot mutate the ring buffer', () => { + recordCrashBreadcrumb('app_started', { packaged: false }) + + const snapshot = getCrashBreadcrumbSnapshot() + if (snapshot[0]?.data) { + snapshot[0].data.packaged = true + } + snapshot.pop() + + expect(getCrashBreadcrumbSnapshot()).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot()[0].data).toEqual({ packaged: false }) + }) +}) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts new file mode 100644 index 00000000000..61f630f5f1d --- /dev/null +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -0,0 +1,40 @@ +import { + sanitizeCrashReportBreadcrumbs, + type CrashReportBreadcrumb +} from '../../shared/crash-reporting' + +const MAX_BREADCRUMBS = 30 + +let breadcrumbs: CrashReportBreadcrumb[] = [] + +export function recordCrashBreadcrumb( + name: string, + data?: Record +): void { + const sanitized = sanitizeCrashReportBreadcrumbs([ + { + createdAt: new Date().toISOString(), + name, + data + } + ]) + const breadcrumb = sanitized?.[0] + if (!breadcrumb) { + return + } + breadcrumbs.push(breadcrumb) + if (breadcrumbs.length > MAX_BREADCRUMBS) { + breadcrumbs.shift() + } +} + +export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { + return breadcrumbs.map((breadcrumb) => ({ + ...breadcrumb, + ...(breadcrumb.data ? { data: { ...breadcrumb.data } } : {}) + })) +} + +export function clearCrashBreadcrumbsForTest(): void { + breadcrumbs = [] +} diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts new file mode 100644 index 00000000000..2dc9a87aa55 --- /dev/null +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -0,0 +1,137 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { CrashReportStore } from './crash-report-store' +import type { CrashReportCreateInput } from '../../shared/crash-reporting' + +const tempDirs: string[] = [] + +async function createStore(): Promise<{ store: CrashReportStore; filePath: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-crash-reports-')) + tempDirs.push(dir) + const filePath = path.join(dir, 'crash-reports.json') + return { store: new CrashReportStore(filePath), filePath } +} + +function input(reason = 'crashed'): CrashReportCreateInput { + return { + source: 'renderer', + processType: 'renderer', + reason, + exitCode: 5, + appVersion: '1.0.0', + platform: process.platform, + osRelease: 'test', + arch: process.arch, + electronVersion: '41', + chromeVersion: '141', + details: { path: '/Users/alice/project', code: 5 }, + breadcrumbs: [ + { + createdAt: '2026-05-16T01:00:00.000Z', + name: 'workspace_opened', + data: { path: '/Users/alice/project', ssh: false } + } + ] + } +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))) +}) + +describe('CrashReportStore', () => { + it('records sanitized pending reports and returns the latest pending report', async () => { + const { store } = await createStore() + + const report = await store.record(input()) + + expect(report.status).toBe('pending') + expect(report.details.path).toBe('[redacted-path]') + expect(report.breadcrumbs).toEqual([ + { + createdAt: '2026-05-16T01:00:00.000Z', + name: 'workspace_opened', + data: { path: '[redacted-path]', ssh: false } + } + ]) + await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id }) + }) + + it('caps reports to the newest five', async () => { + const { store } = await createStore() + + for (let index = 0; index < 7; index += 1) { + await store.record(input(`crashed-${index}`)) + } + + const reports = await store.listRecent() + expect(reports).toHaveLength(5) + expect(reports[0].reason).toBe('crashed-6') + expect(reports[4].reason).toBe('crashed-2') + }) + + it('recovers corrupt JSON as an empty report list', async () => { + const { store, filePath } = await createStore() + await fs.writeFile(filePath, '{ nope', 'utf8') + + await expect(store.listRecent()).resolves.toEqual([]) + }) + + it('allows a pending report to reach one terminal status only', async () => { + const { store } = await createStore() + const report = await store.record(input()) + + await expect(store.dismiss(report.id)).resolves.toMatchObject({ status: 'dismissed' }) + await expect(store.markSent(report.id)).resolves.toMatchObject({ status: 'dismissed' }) + }) + + it('dismisses sibling pending records from the same Electron crash event', async () => { + const { store } = await createStore() + await store.record(input()) + await store.record(input()) + const renderer = await store.record(input()) + + await expect(store.dismiss(renderer.id)).resolves.toMatchObject({ status: 'dismissed' }) + await expect(store.getLatestPending()).resolves.toBeNull() + }) + + it('dismisses sibling pending records after one crash report is sent', async () => { + const { store } = await createStore() + await store.record(input()) + await store.record(input()) + const renderer = await store.record(input()) + + await expect(store.markSent(renderer.id)).resolves.toMatchObject({ status: 'sent' }) + + const reports = await store.listRecent() + expect(reports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: renderer.id, status: 'sent' }), + expect.objectContaining({ status: 'dismissed' }), + expect.objectContaining({ status: 'dismissed' }) + ]) + ) + await expect(store.getLatestPending()).resolves.toBeNull() + }) + + it('persists a submitted dismissed report as sent', async () => { + const { store, filePath } = await createStore() + const report = await store.record(input()) + + await expect(store.dismiss(report.id)).resolves.toMatchObject({ status: 'dismissed' }) + await expect(store.markDismissedSent(report.id)).resolves.toMatchObject({ status: 'sent' }) + + const reloaded = new CrashReportStore(filePath) + await expect(reloaded.getById(report.id)).resolves.toMatchObject({ status: 'sent' }) + }) + + it('serializes concurrent writes', async () => { + const { store } = await createStore() + + await Promise.all(Array.from({ length: 5 }, (_, index) => store.record(input(`oom-${index}`)))) + + await expect(store.listRecent()).resolves.toHaveLength(5) + }) +}) diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts new file mode 100644 index 00000000000..e1791398e7b --- /dev/null +++ b/src/main/crash-reporting/crash-report-store.ts @@ -0,0 +1,169 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { app } from 'electron' +import { + formatCrashReportText, + sanitizeCrashReportBreadcrumbs, + sanitizeCrashReportDetails, + type CrashReportCreateInput, + type CrashReportRecord, + type CrashReportStatus +} from '../../shared/crash-reporting' + +const MAX_REPORTS = 5 +const RELATED_CRASH_WINDOW_MS = 5_000 + +type CrashReportFile = { + reports: CrashReportRecord[] +} + +function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRecord): boolean { + if (anchor.id === candidate.id || candidate.status !== 'pending') { + return false + } + const anchorTime = Date.parse(anchor.createdAt) + const candidateTime = Date.parse(candidate.createdAt) + if (!Number.isFinite(anchorTime) || !Number.isFinite(candidateTime)) { + return false + } + return ( + Math.abs(anchorTime - candidateTime) <= RELATED_CRASH_WINDOW_MS && + anchor.reason === candidate.reason && + anchor.exitCode === candidate.exitCode && + anchor.appVersion === candidate.appVersion && + anchor.platform === candidate.platform + ) +} + +export class CrashReportStore { + private writeChain = Promise.resolve() + + constructor(private readonly filePath: string) {} + + static fromUserData(userDataPath = app.getPath('userData')): CrashReportStore { + return new CrashReportStore(path.join(userDataPath, 'crash-reports.json')) + } + + async record(input: CrashReportCreateInput): Promise { + return this.withWrite(async (reports) => { + const report: CrashReportRecord = { + ...input, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + status: 'pending', + details: sanitizeCrashReportDetails(input.details), + breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs) + } + return { + reports: [report, ...reports].slice(0, MAX_REPORTS), + result: report + } + }) + } + + async getLatestPending(): Promise { + const reports = await this.readReports() + return reports.find((report) => report.status === 'pending') ?? null + } + + async listRecent(): Promise { + return this.readReports() + } + + async markSent(id: string): Promise { + return this.transitionPending(id, 'sent') + } + + async markDismissedSent(id: string): Promise { + return this.transitionStatus(id, 'dismissed', 'sent') + } + + async dismiss(id: string): Promise { + return this.transitionPending(id, 'dismissed') + } + + async formatDiagnosticText(id: string, notes?: string): Promise { + const reports = await this.readReports() + const report = reports.find((candidate) => candidate.id === id) + return report ? formatCrashReportText(report, notes) : null + } + + async getById(id: string): Promise { + const reports = await this.readReports() + return reports.find((report) => report.id === id) ?? null + } + + private async transitionPending( + id: string, + status: Exclude + ): Promise { + return this.transitionStatus(id, 'pending', status) + } + + private async transitionStatus( + id: string, + from: CrashReportStatus, + status: Exclude + ): Promise { + return this.withWrite(async (reports) => { + let result: CrashReportRecord | null = null + const anchor = reports.find((report) => report.id === id) + const nextReports = reports.map((report) => { + if (report.id !== id) { + // Why: one Electron crash can emit GPU/Network/renderer exits in a + // burst. Once one report is handled, sibling pending records should + // not re-open the crash prompt as separate crashes. + if (anchor && anchor.status === from && isRelatedCrashEvent(anchor, report)) { + return { ...report, status: 'dismissed' as const } + } + return report + } + if (report.status !== from) { + result = report + return report + } + result = { ...report, status } + return result + }) + return { reports: nextReports, result } + }) + } + + private async withWrite( + mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }> + ): Promise { + const run = this.writeChain.then(async () => { + const reports = await this.readReports() + const { reports: nextReports, result } = await mutate(reports) + await this.writeReports(nextReports) + return result + }) + this.writeChain = run.then( + () => undefined, + () => undefined + ) + return run + } + + private async readReports(): Promise { + try { + const raw = await fs.readFile(this.filePath, 'utf8') + const parsed = JSON.parse(raw) as Partial + return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : [] + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[crash-reporting] Failed to read crash reports:', error) + } + return [] + } + } + + private async writeReports(reports: CrashReportRecord[]): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp` + await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') + await fs.rename(tmpPath, this.filePath) + } +} diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts new file mode 100644 index 00000000000..036205235a2 --- /dev/null +++ b/src/main/cursor/hook-service.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { CursorHookService } from './hook-service' + +const CURSOR_EVENTS = [ + 'beforeSubmitPrompt', + 'stop', + 'preToolUse', + 'postToolUse', + 'postToolUseFailure', + 'beforeShellExecution', + 'beforeMCPExecution', + 'afterAgentResponse' +] + +describe('CursorHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-cursor-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs Cursor Agent hooks with the documented top-level command schema', () => { + const status = new CursorHookService().install() + + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(homeDir, '.cursor', 'hooks.json')) + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse(readFileSync(join(homeDir, '.cursor', 'hooks.json'), 'utf8')) as { + version?: number + hooks: Record + } + expect(config.version).toBe(1) + expect(Object.keys(config.hooks).sort()).toEqual([...CURSOR_EVENTS].sort()) + for (const eventName of CURSOR_EVENTS) { + const definition = config.hooks[eventName]?.[0] + expect(definition?.command).toContain('cursor-hook') + expect(definition?.command).toContain(join(homeDir, '.orca')) + expect(definition?.hooks).toBeUndefined() + } + + const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'cursor-hook.sh'), 'utf8') + expect(script).toContain('/hook/cursor') + expect(script).toContain('payload=$(cat)') + }) + + it('preserves user-authored Cursor hook entries and removes stale managed entries', () => { + const configPath = join(homeDir, '.cursor', 'hooks.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify( + { + version: 1, + hooks: { + beforeSubmitPrompt: [ + { command: '/usr/local/bin/user-hook' }, + { command: '/old/path/.orca/agent-hooks/cursor-hook.sh' } + ], + retiredEvent: [ + { command: '/old/path/.orca/agent-hooks/cursor-hook.sh' }, + { command: '/usr/local/bin/retired-user-hook' } + ] + } + }, + null, + 2 + )}\n` + ) + + new CursorHookService().install() + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks: Record + } + const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command) + expect(promptCommands).toContain('/usr/local/bin/user-hook') + expect(promptCommands.filter((command) => command?.includes('cursor-hook.sh'))).toHaveLength(1) + expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([ + '/usr/local/bin/retired-user-hook' + ]) + }) +}) diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ed7f7f3bd0a..6d7da1b1754 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -74,7 +75,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/cursor') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + buildWindowsAgentHookPostCommand('cursor'), 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0f7b3181152..49549d03e2b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -128,6 +128,75 @@ describe('createPtySubprocess', () => { expect(handle.pid).toBe(42) }) + it('does not inherit parent Orca pane identity when caller omits pane env', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const saved = { + ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, + ORCA_TAB_ID: process.env.ORCA_TAB_ID, + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + } + process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' + process.env.ORCA_TAB_ID = 'parent-tab' + process.env.ORCA_WORKTREE_ID = 'parent-worktree' + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(env.ORCA_TAB_ID).toBeUndefined() + expect(env.ORCA_WORKTREE_ID).toBeUndefined() + }) + + it('preserves explicit child Orca pane identity over parent env', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const saved = { + ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, + ORCA_TAB_ID: process.env.ORCA_TAB_ID, + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + } + process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' + process.env.ORCA_TAB_ID = 'parent-tab' + process.env.ORCA_WORKTREE_ID = 'parent-worktree' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + ORCA_PANE_KEY: 'child-tab:child-leaf', + ORCA_TAB_ID: 'child-tab', + ORCA_WORKTREE_ID: 'child-worktree' + } + }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.ORCA_PANE_KEY).toBe('child-tab:child-leaf') + expect(env.ORCA_TAB_ID).toBe('child-tab') + expect(env.ORCA_WORKTREE_ID).toBe('child-worktree') + }) + it('forwards write calls', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 10584751100..b1a4514b9ab 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -19,6 +19,8 @@ import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershe import { isPwshAvailable } from '../pwsh' import { removeInheritedNoColor } from '../pty/terminal-color-env' +const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const + export type PtySubprocessOptions = { sessionId: string cols: number @@ -50,6 +52,17 @@ function getDefaultCwd(): string { return 'C:\\' } +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' @@ -149,6 +162,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // restores clickable refs like `owner/repo#123` / `PR#123`. FORCE_HYPERLINK: '1' } as Record + // Why: the daemon is forked from Electron and can inherit the pane identity + // of the terminal that launched `pn dev`; each PTY must opt into its own. + removeUnspecifiedPaneIdentityEnv(env, opts.env) removeInheritedNoColor(env) env.LANG ??= 'en_US.UTF-8' diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 951c83e877e..02e95329ac6 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -164,6 +164,8 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 4a63443b9e2..26fe1ca7ee7 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -126,11 +126,39 @@ function ensureShellReadyWrappers(): void { const bashDir = join(root, 'bash') const zshEnv = `# Orca daemon zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "\${ORCA_ORIG_ZDOTDIR%/}" in +_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" +# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom +# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its +# preferred dir; pre-setting it (even to HOME) defeats that default. +unset ZDOTDIR +# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. +# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and +# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` +# or \`export\` to escape. +__orca_source_user_zshenv() { + # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent + # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still + # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env + # ZDOTDIR was inherited. + local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" + [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" +} +__orca_source_user_zshenv +unfunction __orca_source_user_zshenv +# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve +# the spawn-env value (an inherited resolution from a parent Orca PTY); +# else HOME. +export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" +unset _orca_spawn_orig_zdotdir +# Why: strip trailing slashes (matches Node-side normalizer) before the +# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing +# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. +while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do + ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" +done +case "\${ORCA_ORIG_ZDOTDIR}" in */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; esac -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" export ZDOTDIR=${quotePosixSingle(zshDir)} ` const zshProfile = `# Orca daemon zsh shell-ready wrapper diff --git a/src/main/dock/unread-badge.test.ts b/src/main/dock/unread-badge.test.ts new file mode 100644 index 00000000000..a997a214cd2 --- /dev/null +++ b/src/main/dock/unread-badge.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { setBadgeMock } = vi.hoisted(() => ({ + setBadgeMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + dock: { + setBadge: setBadgeMock + } + } +})) + +describe('unread Dock badge', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + setBadgeMock.mockReset() + vi.resetModules() + }) + + it('clears the native badge when unread count is zero', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const { setUnreadDockBadgeCount } = await import('./unread-badge') + + setUnreadDockBadgeCount(5) + expect(setBadgeMock).toHaveBeenLastCalledWith('5') + + setUnreadDockBadgeCount(0) + expect(setBadgeMock).toHaveBeenLastCalledWith('') + }) + + it('caps unread counts', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const { setUnreadDockBadgeCount } = await import('./unread-badge') + + setUnreadDockBadgeCount(104) + expect(setBadgeMock).toHaveBeenLastCalledWith('99+') + }) +}) diff --git a/src/main/dock/unread-badge.ts b/src/main/dock/unread-badge.ts index e9786022817..b84327f6a18 100644 --- a/src/main/dock/unread-badge.ts +++ b/src/main/dock/unread-badge.ts @@ -1,14 +1,22 @@ import { app } from 'electron' +let unreadCount = 0 + +function applyDockBadge(): void { + if (process.platform !== 'darwin') { + return + } + + const label = unreadCount === 0 ? '' : unreadCount > 99 ? '99+' : String(unreadCount) + app.dock?.setBadge(label) +} + export function setUnreadDockBadgeCount(count: number): void { if (process.platform !== 'darwin') { return } - const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 - const label = normalizedCount === 0 ? '' : normalizedCount > 99 ? '99+' : String(normalizedCount) + unreadCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 - // Why: unread counts belong on the native Dock tile on macOS. - // Windows/Linux are skipped until we define the right platform behavior. - app.dock?.setBadge(label) + applyDockBadge() } diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index dc67d5c324b..7bcaba1bbd3 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -3,6 +3,7 @@ import { join } from 'path' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -64,7 +65,7 @@ function getManagedScript(): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/droid') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + buildWindowsAgentHookPostCommand('droid'), 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/electron-updater-loader.ts b/src/main/electron-updater-loader.ts new file mode 100644 index 00000000000..c4e42088dd6 --- /dev/null +++ b/src/main/electron-updater-loader.ts @@ -0,0 +1,10 @@ +import type { autoUpdater as electronUpdaterAutoUpdater } from 'electron-updater' + +export type ElectronAutoUpdater = typeof electronUpdaterAutoUpdater + +export function loadElectronAutoUpdater(): ElectronAutoUpdater { + // Why: electron-updater validates app.getVersion() while loading. Keep the + // require behind packaged-update guards so direct dev/E2E launches do not + // fail before setupAutoUpdater() can return. + return (require('electron-updater') as { autoUpdater: ElectronAutoUpdater }).autoUpdater +} diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 7b0d418ee71..8247ca43365 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -64,7 +65,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - `powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/gemini') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`, + buildWindowsAgentHookPostCommand('gemini'), 'exit /b 0', '' ].join('\r\n') diff --git a/src/main/ghostty/mapper.test.ts b/src/main/ghostty/mapper.test.ts index 5cf53025b53..c3ed4f2631a 100644 --- a/src/main/ghostty/mapper.test.ts +++ b/src/main/ghostty/mapper.test.ts @@ -75,6 +75,24 @@ describe('mapGhosttyToOrca — font & cursor', () => { expect(result.diff).toEqual({}) expect(result.unsupportedKeys).toEqual(['focus-follows-mouse']) }) + + it('maps middle-click-action primary-paste to primary selection paste', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'primary-paste' }) + expect(result.diff).toEqual({ primarySelectionMiddleClickPaste: true }) + expect(result.unsupportedKeys).toEqual([]) + }) + + it('maps middle-click-action ignore to disabled primary selection paste', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'ignore' }) + expect(result.diff).toEqual({ primarySelectionMiddleClickPaste: false }) + expect(result.unsupportedKeys).toEqual([]) + }) + + it('rejects invalid middle-click-action value', () => { + const result = mapGhosttyToOrca({ 'middle-click-action': 'copy' }) + expect(result.diff).toEqual({}) + expect(result.unsupportedKeys).toEqual(['middle-click-action']) + }) }) describe('mapGhosttyToOrca — macos-option-as-alt', () => { diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 6cc06177d35..496de3cadbf 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -261,6 +261,13 @@ export function mapGhosttyToOrca( return null } return { key: 'terminalFocusFollowsMouse', value: v === 'true' } + }, + + 'middle-click-action': (v) => { + if (v !== 'primary-paste' && v !== 'ignore') { + return null + } + return { key: 'primarySelectionMiddleClickPaste', value: v === 'primary-paste' } } } diff --git a/src/main/git/check-ignored-paths.test.ts b/src/main/git/check-ignored-paths.test.ts new file mode 100644 index 00000000000..3338ee23027 --- /dev/null +++ b/src/main/git/check-ignored-paths.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { checkIgnoredPaths } from './check-ignored-paths' +import { gitExecFileAsync } from './runner' + +vi.mock('./runner', () => ({ + gitExecFileAsync: vi.fn() +})) + +const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync) + +describe('checkIgnoredPaths', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns ignored paths from git check-ignore output', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\n.env\n', stderr: '' }) + + await expect( + checkIgnoredPaths('/repo', ['dist/bundle.js', 'src/index.ts', '.env']) + ).resolves.toEqual(['dist/bundle.js', '.env']) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'check-ignore', + '--', + 'dist/bundle.js', + 'src/index.ts', + '.env' + ], + { cwd: '/repo' } + ) + }) + + it('treats exit code 1 as no ignored paths', async () => { + gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('no matches'), { code: 1 })) + + await expect(checkIgnoredPaths('/repo', ['src/index.ts'])).resolves.toEqual([]) + }) +}) diff --git a/src/main/git/check-ignored-paths.ts b/src/main/git/check-ignored-paths.ts new file mode 100644 index 00000000000..c61d4784f2b --- /dev/null +++ b/src/main/git/check-ignored-paths.ts @@ -0,0 +1,42 @@ +import { gitExecFileAsync } from './runner' + +const CHECK_IGNORE_CHUNK_SIZE = 100 + +type GitExecError = Error & { stdout?: string; code?: number | string } + +function parseCheckIgnoreOutput(stdout: string): string[] { + return stdout.split(/\r?\n/).filter(Boolean) +} + +async function runCheckIgnoreChunk( + worktreePath: string, + relativePaths: string[] +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths], + { cwd: worktreePath } + ) + return parseCheckIgnoreOutput(stdout) + } catch (error) { + const gitError = error as GitExecError + if (gitError.code === 1) { + return parseCheckIgnoreOutput(gitError.stdout ?? '') + } + throw error + } +} + +export async function checkIgnoredPaths( + worktreePath: string, + relativePaths: string[] +): Promise { + const ignored = new Set() + for (let i = 0; i < relativePaths.length; i += CHECK_IGNORE_CHUNK_SIZE) { + const chunk = relativePaths.slice(i, i + CHECK_IGNORE_CHUNK_SIZE) + for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk)) { + ignored.add(ignoredPath) + } + } + return Array.from(ignored) +} diff --git a/src/main/git/history.ts b/src/main/git/history.ts new file mode 100644 index 00000000000..080f2fba154 --- /dev/null +++ b/src/main/git/history.ts @@ -0,0 +1,14 @@ +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import { loadGitHistoryFromExecutor } from '../../shared/git-history' +import { gitExecFileAsync } from './runner' + +export async function getHistory( + worktreePath: string, + options: GitHistoryOptions = {} +): Promise { + return loadGitHistoryFromExecutor( + (args, cwd) => gitExecFileAsync(args, { cwd }), + worktreePath, + options + ) +} diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 4bd5b1db3f5..c991a3b76f4 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -32,7 +32,12 @@ vi.mock('fs/promises', async () => { return { ...actual, stat: statMock } }) -import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree' +import { + addSparseWorktree, + assertWorktreeCleanForRemoval, + listWorktrees, + removeWorktree +} from './worktree' type MockResult = { error?: Error @@ -292,6 +297,46 @@ branch refs/heads/main }) }) +describe('assertWorktreeCleanForRemoval', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('returns without checking git status for force removals', async () => { + await expect(assertWorktreeCleanForRemoval('/repo-feature', true)).resolves.toBeUndefined() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('passes when git status output is empty', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(assertWorktreeCleanForRemoval('/repo-feature')).resolves.toBeUndefined() + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + { cwd: '/repo-feature' } + ) + }) + + it('throws a dedicated dirty/untracked error when status output is non-empty', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '?? scratch.txt\n', stderr: '' }) + + await expect(assertWorktreeCleanForRemoval('/repo-feature')).rejects.toMatchObject({ + message: 'Worktree has uncommitted or untracked changes.', + stdout: '?? scratch.txt\n' + }) + }) + + it('rethrows preflight subprocess failures as-is', async () => { + const error = Object.assign(new Error('fatal: not a git repository'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + gitExecFileAsyncMock.mockRejectedValueOnce(error) + + await expect(assertWorktreeCleanForRemoval('/repo-feature')).rejects.toBe(error) + }) +}) + describe('listWorktrees', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 41a78afc8b0..2a8bced1111 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -4,7 +4,14 @@ import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import path from 'path' -import { getDefaultBaseRef, getRemoteCount, searchBaseRefs } from './repo' +import { + getDefaultBaseRef, + getBranchConflictKind, + getRemoteCount, + parseAndFilterSearchRefDetails, + searchBaseRefDetails, + searchBaseRefs +} from './repo' // Why: these tests exercise real git state (not mocked gitExecFileAsync) // because the change under test is in the `for-each-ref` glob argument @@ -102,6 +109,72 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('local-only') }) + it('returns the local branch name for a remote ref with slashes', async () => { + const sha = getHeadSha(tmpDir) + git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) + createRemoteRef(tmpDir, 'origin/feature/something', sha) + + const results = await searchBaseRefDetails(tmpDir, 'origin/feature/something') + + expect(results).toContainEqual({ + refName: 'origin/feature/something', + localBranchName: 'feature/something' + }) + }) + + it('keeps local branch names unchanged in detailed search results', async () => { + git(tmpDir, ['branch', 'feature/something']) + + const results = await searchBaseRefDetails(tmpDir, 'feature/something') + + expect(results).toContainEqual({ + refName: 'feature/something', + localBranchName: 'feature/something' + }) + }) + + it('allows creating a local branch from the selected matching remote base ref', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/feature/something', sha) + + const result = await getBranchConflictKind( + tmpDir, + 'feature/something', + 'origin/feature/something' + ) + + expect(result).toBeNull() + }) + + it('still reports a remote conflict for a different tracking ref with the same branch name', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/feature/something', sha) + createRemoteRef(tmpDir, 'upstream/feature/something', sha) + + const result = await getBranchConflictKind( + tmpDir, + 'feature/something', + 'origin/feature/something' + ) + + expect(result).toBe('remote') + }) + + it('uses the longest configured remote name when deriving local branch names', () => { + const results = parseAndFilterSearchRefDetails( + 'refs/remotes/foo/bar/feature/something\u0000foo/bar/feature/something\n', + 10, + ['foo', 'foo/bar'] + ) + + expect(results).toEqual([ + { + refName: 'foo/bar/feature/something', + localBranchName: 'feature/something' + } + ]) + }) + it('returns [] for a repo with no matching refs', async () => { const results = await searchBaseRefs(tmpDir, 'nonexistent-query-xyz') diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 762ca6dd09f..9b593f1787e 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -4,6 +4,7 @@ import { existsSync, statSync } from 'fs' import { join, basename } from 'path' import hostedGitInfo from 'hosted-git-info' import { gitExecFileSync, gitExecFileAsync } from './runner' +import type { BaseRefSearchResult } from '../../shared/types' /** * Ordered probe list used to resolve a repo's default base ref when no @@ -463,6 +464,14 @@ export async function getDefaultRemote(path: string): Promise { } export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { const normalizedQuery = normalizeRefSearchQuery(query) if (!normalizedQuery) { return [] @@ -471,11 +480,12 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P try { // Why: argv (including the two-remote-glob rationale) lives in // buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const { stdout } = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), { - cwd: path - }) + const [{ stdout }, remotes] = await Promise.all([ + gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), { cwd: path }), + listRemoteNames(path) + ]) - return parseAndFilterSearchRefs(stdout, limit) + return parseAndFilterSearchRefDetails(stdout, limit, remotes) } catch (err) { // Why: surface the failure for diagnostics; callers treat `[]` as "no // matches", but silently swallowing the error makes a missing result @@ -486,6 +496,18 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P } } +async function listRemoteNames(path: string): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + /** * Parse `git for-each-ref --format=%(refname)%00%(refname:short)` stdout * into a deduped list of short refs, filtering out `/HEAD` @@ -498,7 +520,16 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P * lived in a single location; two copies double the regression surface. */ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[] { + return parseAndFilterSearchRefDetails(stdout, limit).map((entry) => entry.refName) +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { const seen = new Set() + const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) return ( stdout .split('\n') @@ -531,7 +562,10 @@ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[ seen.add(short) return true }) - .map(({ short }) => short) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) // Why: `Math.max(0, limit)` — treat pathological `limit <= 0` as // "zero results" rather than "at least 1". More honest than silently // returning a single ref when the caller explicitly asked for none. @@ -539,6 +573,19 @@ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[ ) } +function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + export function normalizeRefSearchQuery(query: string): string { return query.trim().replace(/[*?[\]\\]/g, '') } @@ -556,7 +603,8 @@ export type BranchConflictKind = 'local' | 'remote' export async function getBranchConflictKind( path: string, - branchName: string + branchName: string, + allowedBaseRef?: string ): Promise { if (await hasGitRefAsync(path, `refs/heads/${branchName}`)) { return 'local' @@ -571,7 +619,11 @@ export async function getBranchConflictKind( // first three segments so that e.g. "feature/dashboard" only matches // "refs/remotes/origin/feature/dashboard", not "refs/remotes/origin/other/feature/dashboard". const hasRemoteConflict = stdout.split('\n').some((ref) => { - const parts = ref.trim().split('/') + const trimmed = ref.trim() + if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { + return false + } + const parts = trimmed.split('/') return parts.slice(3).join('/') === branchName }) @@ -581,6 +633,16 @@ export async function getBranchConflictKind( } } +function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} + /** * Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a specific file * and line in the repo. Returns null when the remote isn't a recognized host. diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 24f2648d77d..b96098e99a9 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -358,6 +358,30 @@ describe('getStatus', () => { ]) }) + it('omits ignored files by default and parses them when requested', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '! dist/\n! generated/file.js\n' + }) + + const result = await getStatus('/repo', { includeIgnored: true }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all', + '--ignored=matching' + ], + { cwd: '/repo' } + ) + expect(result.ignoredPaths).toEqual(['dist/', 'generated/file.js']) + }) + it('parses branch identity from porcelain v2 branch headers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) @@ -405,6 +429,52 @@ describe('getStatus', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) }) + + it('omits --ignored and ignoredPaths when includeIgnored is not requested', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + const result = await getStatus('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], + { cwd: '/repo' } + ) + expect('ignoredPaths' in result).toBe(false) + }) + + it('parses ! porcelain v2 records into ignoredPaths when includeIgnored is true', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '! dist/\n! .env\n! coverage/\n' + }) + + const result = await getStatus('/repo', { includeIgnored: true }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all', + '--ignored=matching' + ], + { cwd: '/repo' } + ) + expect(result.ignoredPaths).toEqual(['dist/', '.env', 'coverage/']) + expect(result.entries).toEqual([]) + }) }) describe('getStagedCommitContext', () => { @@ -489,6 +559,10 @@ describe('getBranchCompare', () => { .mockResolvedValueOnce({ stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n' }) + .mockResolvedValueOnce({ + stdout: + '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n' + }) .mockResolvedValueOnce({ stdout: '7\n' }) const result = await getBranchCompare('/repo', 'origin/main') @@ -504,9 +578,9 @@ describe('getBranchCompare', () => { status: 'ready' }) expect(result.entries).toEqual([ - { path: 'file-a.ts', status: 'modified' }, - { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed' }, - { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied' } + { path: 'file-a.ts', status: 'modified', added: 10, removed: 2 }, + { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed', added: 1, removed: 1 }, + { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied', added: 3, removed: 0 } ]) }) @@ -556,6 +630,7 @@ describe('getBranchCompare', () => { .mockResolvedValueOnce({ stdout: 'base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' }) + .mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) const result = await getBranchCompare('/repo', 'origin/main') @@ -574,6 +649,8 @@ describe('getBranchCompare', () => { ], expect.objectContaining({ cwd: '/repo' }) ) - expect(result.entries).toEqual([{ path: 'docs/日本語/sample.md', status: 'modified' }]) + expect(result.entries).toEqual([ + { path: 'docs/日本語/sample.md', status: 'modified', added: 2, removed: 1 } + ]) }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 1b74904fde7..40ff61d967c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -7,6 +7,7 @@ import type { GitBranchChangeStatus, GitBranchCompareResult, GitBranchCompareSummary, + GitCommitCompareResult, GitConflictKind, GitConflictOperation, GitDiffResult, @@ -21,11 +22,19 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 +export type GetStatusOptions = { + includeIgnored?: boolean +} + /** * Parse `git status --porcelain=v2` output into structured entries. */ -export async function getStatus(worktreePath: string): Promise { +export async function getStatus( + worktreePath: string, + options: GetStatusOptions = {} +): Promise { const entries: GitStatusEntry[] = [] + const ignoredPaths: string[] = [] let head: string | undefined let branch: string | undefined let upstreamName: string | undefined @@ -39,10 +48,18 @@ export async function getStatus(worktreePath: string): Promise // etc.) as raw UTF-8 instead of git's default C-style octal escapes wrapped // in double quotes. Without it, the parsed entry.path is unreadable in the // sidebar and downstream `git show :"docs/\346..."` lookups silently miss. - const statusPromise = gitExecFileAsync( - ['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all'], - { cwd: worktreePath } - ) + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (options.includeIgnored) { + statusArgs.push('--ignored=matching') + } + const statusPromise = gitExecFileAsync(statusArgs, { cwd: worktreePath }) const conflictOperation = await conflictPromise try { @@ -116,6 +133,8 @@ export async function getStatus(worktreePath: string): Promise // Untracked file const path = line.slice(2) entries.push({ path, status: 'untracked', area: 'untracked' }) + } else if (line.startsWith('! ')) { + ignoredPaths.push(line.slice(2)) } else if (line.startsWith('u ')) { const unmergedEntry = await parseUnmergedEntry(worktreePath, line) if (unmergedEntry) { @@ -133,6 +152,7 @@ export async function getStatus(worktreePath: string): Promise conflictOperation, head, branch, + ...(options.includeIgnored ? { ignoredPaths } : {}), ...(statusSucceeded ? { upstreamStatus: upstreamName @@ -491,6 +511,94 @@ export async function getBranchDiff( } } +export async function getCommitCompare( + worktreePath: string, + commitId: string +): Promise { + let commitOid = '' + try { + commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`) + } catch { + return { + summary: { + commitOid: '', + parentOid: null, + compareRef: commitId, + baseRef: 'parent', + changedFiles: 0, + status: 'invalid-commit', + errorMessage: `Commit ${commitId} could not be resolved in this repository.` + }, + entries: [] + } + } + + const summary = { + commitOid, + parentOid: null as string | null, + compareRef: commitOid.slice(0, 7), + baseRef: 'empty tree', + changedFiles: 0, + status: 'ready' as const + } + + try { + const { stdout } = await gitExecFileAsync(['rev-list', '--parents', '-n', '1', commitOid], { + cwd: worktreePath + }) + const [, firstParent] = stdout.trim().split(/\s+/) + summary.parentOid = firstParent ?? null + summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + + const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid) + summary.changedFiles = entries.length + return { summary, entries } + } catch (error) { + return { + summary: { + ...summary, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' + }, + entries: [] + } + } +} + +export async function getCommitDiff( + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + } +): Promise { + try { + const leftPath = args.oldPath ?? args.filePath + const leftBlob = args.parentOid + ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath) + : { content: '', isBinary: false } + const rightBlob = await readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath) + + return buildDiffResult( + leftBlob.content, + rightBlob.content, + leftBlob.isBinary, + rightBlob.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} + async function loadBranchChanges( worktreePath: string, mergeBase: string, @@ -498,10 +606,20 @@ async function loadBranchChanges( ): Promise { // Why: see core.quotePath=false rationale in getStatus — same reason here so // branch-diff entries render with their real UTF-8 paths. - const { stdout } = await gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } - ) + const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + // Why: both diffs walk the same range and are independent, so start them + // together instead of serializing two potentially large git operations. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], + gitOptions + ), + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + gitOptions + ) + ]) + const statsByPath = parseBranchChangeNumstat(numstat) const entries: GitBranchChangeEntry[] = [] // [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows, @@ -512,12 +630,108 @@ async function loadBranchChanges( } const entry = parseBranchChangeLine(line) if (entry) { - entries.push(entry) + entries.push({ ...entry, ...statsByPath.get(entry.path) }) } } return entries } +async function loadCommitChanges( + worktreePath: string, + parentOid: string | null, + commitOid: string +): Promise { + // Why: root commits have no parent tree; diff-tree --root asks git to + // compare against the repository's empty tree without hardcoding hash format. + const args = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-M', + '-C', + commitOid + ] + const numstatArgs = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--numstat', + '-r', + '-M', + '-C', + commitOid + ] + const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + // Why: commit diff rows need metadata and line counts, but those git queries + // do not depend on each other. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync(args, gitOptions), + gitExecFileAsync(numstatArgs, gitOptions) + ]) + const statsByPath = parseBranchChangeNumstat(numstat) + + const entries: GitBranchChangeEntry[] = [] + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const entry = parseBranchChangeLine(line) + if (entry) { + entries.push({ ...entry, ...statsByPath.get(entry.path) }) + } + } + return entries +} + +function parseBranchChangeCount(value: string): number | undefined { + if (value === '-') { + return undefined + } + const count = Number.parseInt(value, 10) + return Number.isFinite(count) ? count : undefined +} + +function normalizeBranchNumstatPath(path: string): string { + const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) + if (bracedRename) { + return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` + } + const renameMarker = ' => ' + const markerIndex = path.lastIndexOf(renameMarker) + return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) +} + +function parseBranchChangeNumstat( + stdout: string +): Map> { + const stats = new Map>() + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const parts = line.split('\t') + const rawPath = parts.slice(2).join('\t') + if (!rawPath) { + continue + } + stats.set(normalizeBranchNumstatPath(rawPath), { + added: parseBranchChangeCount(parts[0] ?? ''), + removed: parseBranchChangeCount(parts[1] ?? '') + }) + } + return stats +} + function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { const parts = line.split('\t') const rawStatus = parts[0] ?? '' diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index e26451c76b6..f5a91b5bae1 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -378,6 +378,29 @@ export async function removeWorktree( } } +/** + * Assert a worktree is clean enough for non-force removal. + */ +export async function assertWorktreeCleanForRemoval( + worktreePath: string, + force = false +): Promise { + if (force) { + return + } + + const { stdout } = await gitExecFileAsync(['status', '--porcelain', '--untracked-files=all'], { + cwd: worktreePath + }) + if (!stdout.trim()) { + return + } + + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error +} + function translateWorktreePath(worktreePath: string, repoPath: string): string { const prefix = 'worktree ' const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index 4ff86d8b7ae..0095fb9c98a 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -46,6 +46,16 @@ describe('Gitea repository ref parsing', () => { }) }) + it('preserves a scp-like SSH subpath when deriving the API base URL', () => { + expect(parseGiteaRepoRef('git@gitea.example.test:code/team/project.git')).toEqual({ + host: 'gitea.example.test', + owner: 'team', + repo: 'project', + apiBaseUrl: 'https://gitea.example.test/code/api/v1', + webBaseUrl: 'https://gitea.example.test/code' + }) + }) + it('parses ssh:// remotes without carrying the SSH port into web/API URLs', () => { expect(parseGiteaRepoRef('ssh://git@gitea.example.test:2222/team/project.git')).toEqual({ host: 'gitea.example.test', @@ -60,6 +70,8 @@ describe('Gitea repository ref parsing', () => { expect(parseGiteaRepoRef('git@github.com:team/project.git')).toBeNull() expect(parseGiteaRepoRef('https://gitlab.com/team/project.git')).toBeNull() expect(parseGiteaRepoRef('https://bitbucket.org/team/project.git')).toBeNull() + expect(parseGiteaRepoRef('https://dev.azure.com/team/project/_git/repo')).toBeNull() + expect(parseGiteaRepoRef('https://team.visualstudio.com/project/_git/repo')).toBeNull() }) it('reads and caches the origin remote', async () => { diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index d6bc6b2cce4..6687525a48c 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -8,7 +8,13 @@ export type GiteaRepoRef = { webBaseUrl: string } -const KNOWN_NON_GITEA_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org']) +const KNOWN_NON_GITEA_HOSTS = new Set([ + 'github.com', + 'gitlab.com', + 'bitbucket.org', + 'dev.azure.com', + 'ssh.dev.azure.com' +]) const repoRefCache = new Map() /** @internal - exposed for tests only */ @@ -51,9 +57,13 @@ function apiBaseUrlFromWebBase(webBaseUrl: string): string { return `${webBaseUrl.replace(/\/+$/, '')}/api/v1` } -function makeRepoRef(host: string, path: string, webBaseUrl: string): GiteaRepoRef | null { +function makeRepoRef(host: string, path: string, webOrigin: string): GiteaRepoRef | null { const normalizedHost = host.toLowerCase() - if (!normalizedHost || KNOWN_NON_GITEA_HOSTS.has(normalizedHost)) { + if ( + !normalizedHost || + KNOWN_NON_GITEA_HOSTS.has(normalizedHost) || + normalizedHost.endsWith('.visualstudio.com') + ) { return null } @@ -62,6 +72,11 @@ function makeRepoRef(host: string, path: string, webBaseUrl: string): GiteaRepoR return null } + // Why: Gitea/Forgejo can be hosted below a URL subpath. SSH-style remotes + // carry that base path in the repo path, so derive the web/API base here. + const webBaseUrl = parsed.basePath + ? `${webOrigin.replace(/\/+$/, '')}/${parsed.basePath}` + : webOrigin return { host: normalizedHost, owner: parsed.owner, @@ -98,8 +113,7 @@ export function parseGiteaRepoRef(remoteUrl: string): GiteaRepoRef | null { protocol === 'http:' || protocol === 'https:' ? `${protocol}//${url.host}` : `https://${url.hostname.toLowerCase()}` - const webBaseUrl = parsed.basePath ? `${webOrigin}/${parsed.basePath}` : webOrigin - return makeRepoRef(url.hostname, url.pathname, webBaseUrl) + return makeRepoRef(url.hostname, url.pathname, webOrigin) } catch { return null } diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts new file mode 100644 index 00000000000..7b0ad95e823 --- /dev/null +++ b/src/main/github/client-file-viewed.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, acquireMock, releaseMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + execFileAsync: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: vi.fn(), + getOwnerRepo: vi.fn(), + getIssueOwnerRepo: vi.fn(), + getOwnerRepoForRemote: vi.fn(), + resolveIssueSource: vi.fn(), + ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId) => ({ + repoPath, + connectionId: connectionId ?? null + })), + classifyGhError: vi.fn(), + classifyListIssuesError: vi.fn(), + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn() +})) + +import { setPRFileViewed } from './client' + +describe('setPRFileViewed', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + }) + + it('uses GitHub GraphQL file-viewed mutations', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + pullRequestId: 'PR_kwDO123', + path: 'src/app.ts', + viewed: true + }) + ).resolves.toBe(true) + + const args = ghExecFileAsyncMock.mock.calls[0][0] + expect(args[0]).toBe('api') + expect(args[1]).toBe('graphql') + expect(args.find((arg: string) => arg.startsWith('query='))).toContain('markFileAsViewed') + expect(args).toContain('pullRequestId=PR_kwDO123') + expect(args).toContain('path=src/app.ts') + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('uses GitHub GraphQL file-unviewed mutations', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + pullRequestId: 'PR_kwDO123', + path: 'src/app.ts', + viewed: false + }) + ).resolves.toBe(true) + + const args = ghExecFileAsyncMock.mock.calls[0][0] + expect(args.find((arg: string) => arg.startsWith('query='))).toContain('unmarkFileAsViewed') + }) +}) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index e6e647e6311..ea832165d76 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -50,10 +50,16 @@ vi.mock('./gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghRepoExecOptions: ghRepoExecOptionsMock, githubRepoContext: githubRepoContextMock, - classifyGhError: (stderr: string) => - stderr.toLowerCase().includes('not found') || stderr.includes('HTTP 404') - ? { type: 'not_found', message: stderr } - : { type: 'unknown', message: stderr }, + classifyGhError: (stderr: string) => { + const lower = stderr.toLowerCase() + if (lower.includes('not found') || stderr.includes('HTTP 404')) { + return { type: 'not_found', message: stderr } + } + if (lower.includes('rate limit')) { + return { type: 'rate_limited', message: stderr } + } + return { type: 'unknown', message: stderr } + }, parseGitHubOwnerRepo: (remoteUrl: string) => { const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) return match ? { owner: match[1], repo: match[2] } : null @@ -146,6 +152,302 @@ describe('getPRForBranch', () => { expect(pr?.mergeable).toBe('MERGEABLE') }) + it('prefers exact linked PR lookup when the repo identity is known', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'someone/fix', + baseRefOid: 'base-oid', + headRefOid: 'linked-head-oid' + }) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '99', + '--repo', + 'acme/widgets', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr).toMatchObject({ + number: 99, + title: 'Linked PR', + state: 'open', + headSha: 'linked-head-oid' + }) + }) + + it('uses branch discovery when exact linked PR metadata resolves to a different PR', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 99, + title: 'Stale linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'someone/other-work', + baseRefOid: 'base-oid', + headRefOid: 'stale-linked-head' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/42', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'current-worktree-head' + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', 99) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'pr', + 'list', + '--repo', + 'acme/widgets', + '--head', + 'feature/test', + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr?.number).toBe(42) + }) + + it('falls back to branch discovery when exact linked PR metadata is stale', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/42', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', 99) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'pr', + 'view', + '99', + '--repo', + 'acme/widgets', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'pr', + 'list', + '--repo', + 'acme/widgets', + '--head', + 'feature/test', + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr?.number).toBe(42) + }) + + it('continues to branch discovery when exact linked PR REST fallback also misses', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest')) + .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR after stale linked miss', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/42', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', 99) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + [ + 'pr', + 'list', + '--repo', + 'acme/widgets', + '--head', + 'feature/test', + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr?.number).toBe(42) + }) + + it('continues to branch discovery when exact linked PR REST fallback has an unclassified failure', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: server exploded')) + .mockRejectedValueOnce(new Error('HTTP 500: server error')) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR after exact lookup outage', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/42', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ]) + }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', 99) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + [ + 'pr', + 'list', + '--repo', + 'acme/widgets', + '--head', + 'feature/test', + '--state', + 'all', + '--limit', + '1', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(pr?.number).toBe(42) + }) + + it('does not spend branch discovery calls when exact linked PR REST fallback is rate limited', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) + .mockRejectedValueOnce(new Error('REST API rate limit already exceeded')) + + const pr = await getPRForBranch('/repo-root', 'feature/test', 99) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'pr', + 'view', + '99', + '--repo', + 'acme/widgets', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(pr).toBeNull() + }) + it('falls back to REST branch lookup when gh pr list is GraphQL rate limited', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -354,8 +656,8 @@ describe('getPRForBranch', () => { it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) .mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -374,9 +676,23 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(3, ['api', 'repos/acme/widgets/pulls/99'], { + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + [ + 'pr', + 'view', + '99', + '--repo', + 'acme/widgets', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { cwd: '/repo-root' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr).toMatchObject({ number: 99, state: 'merged', diff --git a/src/main/github/client.ts b/src/main/github/client.ts index feedb3db761..b02aa6fdf80 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1302,20 +1302,84 @@ async function getRestPRByNumber( return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } +async function getPRByNumber( + ownerRepo: OwnerRepo, + number: number, + ghOptions: ReturnType +): Promise { + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + PR_LOOKUP_JSON_FIELDS + ], + ghOptions + ) + return JSON.parse(stdout) as PullRequestLookupData + } catch (err) { + // Why: deleted or manually edited linked PR metadata should fall back to + // branch discovery; quota/auth/network failures get one cheaper REST exact lookup. + if (isNotFoundGhError(err)) { + return null + } + try { + return await getRestPRByNumber(ownerRepo, number, ghOptions) + } catch (restErr) { + if (isNotFoundGhError(restErr)) { + return null + } + if (!shouldStopAfterExactLookupError(restErr)) { + return null + } + throw restErr + } + } +} + +async function exactPRMatchesWorktreeHead( + repoPath: string, + branchName: string, + data: PullRequestLookupData, + connectionId?: string | null +): Promise { + if (!branchName || data.headRefName === branchName) { + return true + } + if (connectionId || !data.headRefOid) { + return false + } + try { + const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: repoPath }) + return stdout.trim() === data.headRefOid + } catch { + return false + } +} + function isNotFoundGhError(err: unknown): boolean { const stderr = err instanceof Error ? err.message : String(err) return classifyGhError(stderr).type === 'not_found' } +function shouldStopAfterExactLookupError(err: unknown): boolean { + const stderr = err instanceof Error ? err.message : String(err) + const type = classifyGhError(stderr).type + return type === 'rate_limited' || type === 'permission_denied' || type === 'network_error' +} + /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. * - * When `linkedPRNumber` is provided and the branch lookup yields nothing, - * falls back to looking up the PR by number. This handles "create from PR" - * worktrees, whose branch is a fresh local branch (not the PR's head ref) — - * the branch-keyed lookup misses, but the user still expects the linked PR - * to surface on the worktree card. + * When `linkedPRNumber` is provided and the repo identity is known, starts + * with a direct PR-number lookup. This handles "create from PR" worktrees, + * whose branch is a fresh local branch, and avoids spending a branch-list + * request before asking for the exact PR the worktree already stores. */ export async function getPRForBranch( repoPath: string, @@ -1332,11 +1396,22 @@ export async function getPRForBranch( try { const ownerRepo = await getOwnerRepo(repoPath, connectionId) let data: PullRequestLookupData | null = null + let exactLinkedData: PullRequestLookupData | null = null + + if (ownerRepo && typeof linkedPRNumber === 'number') { + data = await getPRByNumber(ownerRepo, linkedPRNumber, ghOptions) + if (data && !(await exactPRMatchesWorktreeHead(repoPath, branchName, data, connectionId))) { + // Why: linked PR metadata is user-editable. If the stored number still + // resolves but no longer matches this worktree, let branch lookup correct it. + exactLinkedData = data + data = null + } + } // During a rebase the worktree is in detached HEAD and branch is empty. // An empty --head filter causes gh to return an arbitrary PR — skip the // branch lookup and rely on the linkedPR fallback below if available. - if (branchName) { + if (!data && branchName) { if (ownerRepo) { try { const { stdout } = await ghExecFileAsync( @@ -1375,33 +1450,24 @@ export async function getPRForBranch( } } - if (!data && typeof linkedPRNumber === 'number') { - const args = ownerRepo - ? [ - 'pr', - 'view', - String(linkedPRNumber), - '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, - '--json', - PR_LOOKUP_JSON_FIELDS - ] - : ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS] + if (!data && !ownerRepo && typeof linkedPRNumber === 'number') { + const args = ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS] try { const { stdout } = await ghExecFileAsync(args, ghOptions) data = JSON.parse(stdout) - } catch (err) { + } catch { // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes // `gh pr view ` reject. Treat that as the no-PR case so // callers see the historical `null` semantics instead of a thrown // error every poll cycle. - data = - ownerRepo && !isNotFoundGhError(err) - ? await getRestPRByNumber(ownerRepo, linkedPRNumber, ghOptions) - : null + data = null } } + if (!data && exactLinkedData) { + data = exactLinkedData + } + if (!data) { return null } @@ -1794,6 +1860,47 @@ export async function getPRComments( } } +/** + * Mark or unmark a PR file as viewed via GitHub's GraphQL API. + */ +export async function setPRFileViewed(args: { + repoPath: string + connectionId?: string | null + pullRequestId: string + path: string + viewed: boolean +}): Promise { + const ghOptions = ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId)) + const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' + const query = `mutation($pullRequestId: ID!, $path: String!) { + ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { + pullRequest { id } + } + }` + await acquire() + try { + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `pullRequestId=${args.pullRequestId}`, + '-f', + `path=${args.path}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} + /** * Resolve or unresolve a PR review thread via GraphQL. */ diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 91cc8588b89..41ce4f465c3 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -20,6 +20,7 @@ import { classifyListIssuesError, getIssueOwnerRepo, getOwnerRepo, + parseGitHubRemoteIdentity, parseGitHubOwnerRepo, resolveIssueSource } from './gh-utils' @@ -47,6 +48,20 @@ describe('github owner/repo resolution', () => { expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull() }) + it('parses GitHub Enterprise host identity', () => { + expect(parseGitHubRemoteIdentity('https://ghe.acme.internal/acme/orca.git')).toEqual({ + host: 'ghe.acme.internal', + owner: 'acme', + repo: 'orca' + }) + expect(parseGitHubRemoteIdentity('git@ghe.acme.internal:acme/orca.git')).toEqual({ + host: 'ghe.acme.internal', + owner: 'acme', + repo: 'orca' + }) + expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull() + }) + it('keeps getOwnerRepo origin-based', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 3d8b3478850..bcff859ad58 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -113,6 +113,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { // short local name `OwnerRepo`. export type OwnerRepo = GitHubOwnerRepo +export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } + export type GitHubRepoContext = { repoPath: string connectionId?: string | null @@ -143,11 +145,24 @@ export function _resetOwnerRepoCache(): void { } export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { - const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) { + const identity = parseGitHubRemoteIdentity(remoteUrl) + if (!identity || identity.host.toLowerCase() !== 'github.com') { return null } - return { owner: match[1], repo: match[2] } + return { owner: identity.owner, repo: identity.repo } +} + +export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { + const trimmed = remoteUrl.trim() + const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i) + if (httpsMatch) { + return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] } + } + const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) + if (sshMatch) { + return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] } + } + return null } export async function getRemoteUrlForRepo( diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts new file mode 100644 index 00000000000..0341899f0db --- /dev/null +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getWorkItemMock, + getPRChecksMock, + getPRCommentsMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getWorkItemMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCommentsMock: vi.fn(), + rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId) => ({ + repoPath, + connectionId: connectionId ?? null + })), + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('./client', () => ({ + getWorkItem: getWorkItemMock, + getPRChecks: getPRChecksMock, + getPRComments: getPRCommentsMock +})) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock +})) + +import { getWorkItemDetails } from './work-item-details' + +describe('getWorkItemDetails PR file viewed state', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getWorkItemMock.mockReset() + getPRChecksMock.mockReset() + getPRCommentsMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + }) + + it('merges GitHub viewer viewed state into PR files', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:42', + type: 'pr', + number: 42, + title: 'Review files', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/42', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: null + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation((args: string[]) => { + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return Promise.resolve({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_kwDO123', + files: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { path: 'src/viewed.ts', viewerViewedState: 'VIEWED' }, + { path: 'src/changed.ts', viewerViewedState: 'DISMISSED' } + ] + } + } + } + } + }) + }) + } + if (query.includes('participants')) { + return Promise.resolve({ + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + }) + } + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? '' + if (endpoint === 'repos/stablyai/orca/pulls/42') { + return Promise.resolve({ + stdout: JSON.stringify({ + body: 'PR body', + head: { sha: 'head-sha' }, + base: { sha: 'base-sha' } + }) + }) + } + if (endpoint === 'repos/stablyai/orca/pulls/42/files?per_page=100') { + return Promise.resolve({ + stdout: JSON.stringify([ + { + filename: 'src/viewed.ts', + status: 'modified', + additions: 3, + deletions: 1, + changes: 4, + patch: '@@' + }, + { + filename: 'src/changed.ts', + status: 'modified', + additions: 1, + deletions: 0, + changes: 1, + patch: '@@' + } + ]) + }) + } + return Promise.reject(new Error(`unexpected gh call: ${args.join(' ')}`)) + }) + + const details = await getWorkItemDetails('/repo-root', 42, 'pr') + + expect(details?.pullRequestId).toBe('PR_kwDO123') + expect(details?.headSha).toBe('head-sha') + expect(details?.baseSha).toBe('base-sha') + expect(details?.files?.map((file) => [file.path, file.viewerViewedState])).toEqual([ + ['src/viewed.ts', 'VIEWED'], + ['src/changed.ts', 'DISMISSED'] + ]) + expect(getPRChecksMock).toHaveBeenCalledWith('/repo-root', 42, 'head-sha', undefined, undefined) + }) +}) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index d417cbe45f4..42e8624d170 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -5,6 +5,7 @@ import type { GitHubAssignableUser, GitHubPRFile, GitHubPRFileContents, + GitHubPRFileViewedState, GitHubWorkItem, GitHubWorkItemDetails, PRComment @@ -26,6 +27,21 @@ import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' // the gh semaphore while we fetch file listings. const MAX_PR_FILES = 300 +const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + viewerViewedState + } + } + } + } +}` + const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) @include(if: $isPr) { @@ -318,6 +334,102 @@ async function getPRFiles( } } +type PRFileViewedStatesResult = { + pullRequestId: string + viewedStates: Map +} + +async function getPRFileViewedStates( + repoPath: string, + prNumber: number, + connectionId?: string | null +): Promise { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) + if (!ownerRepo) { + return null + } + if (rateLimitGuard('graphql').blocked) { + return null + } + const viewedStates = new Map() + let pullRequestId: string | null = null + let after: string | null = null + + try { + for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_FILE_VIEWED_STATES_QUERY}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `number=${prNumber}` + ] + if (after) { + args.push('-f', `after=${after}`) + } + noteRateLimitSpend('graphql') + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { + id?: string + files?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { + path?: string | null + viewerViewedState?: GitHubPRFileViewedState | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] + } + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const pullRequest = parsed.data?.repository?.pullRequest + if (!pullRequest?.id) { + return null + } + pullRequestId = pullRequest.id + for (const file of pullRequest.files?.nodes ?? []) { + if (file.path && file.viewerViewedState) { + viewedStates.set(file.path, file.viewerViewedState) + } + } + if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { + break + } + after = pullRequest.files.pageInfo.endCursor + } + } catch { + return null + } + + return pullRequestId ? { pullRequestId, viewedStates } : null +} + +function mergePRFileViewedStates( + files: GitHubPRFile[], + viewedStates: PRFileViewedStatesResult | null +): GitHubPRFile[] { + if (!viewedStates) { + return files + } + return files.map((file) => ({ + ...file, + viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' + })) +} + async function getIssueBodyAndComments( repoPath: string, issueNumber: number, @@ -614,11 +726,12 @@ export async function getWorkItemDetails( } // PR: fetch body + comments + checks + files + head/base SHAs in parallel. - const [body, comments, shas, files, participants] = await Promise.all([ + const [body, comments, shas, files, viewedStates, participants] = await Promise.all([ getPRBody(repoPath, item.number, connectionId), getPRComments(repoPath, item.number, undefined, connectionId), getPRHeadBaseSha(repoPath, item.number, connectionId), getPRFiles(repoPath, item.number, connectionId), + getPRFileViewedStates(repoPath, item.number, connectionId), getWorkItemParticipants(repoPath, item, connectionId) ]) @@ -638,8 +751,9 @@ export async function getWorkItemDetails( comments, headSha: shas?.headSha, baseSha: shas?.baseSha, + pullRequestId: viewedStates?.pullRequestId, checks, - files, + files: mergePRFileViewedStates(files, viewedStates), participants: mentionParticipants } } finally { diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 96ab9a22b4e..c4a54fba9d9 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -61,6 +61,21 @@ describe('gitlab project ref parsing', () => { ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) }) + it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => { + expect( + parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + expect( + parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + it('rejects single-segment paths (host root or user-only)', () => { expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() @@ -72,6 +87,13 @@ describe('gitlab project ref parsing', () => { path: 'acme/widgets' }) }) + + it('preserves git protocol remote support', () => { + expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) }) describe('gitlab project ref resolution', () => { diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index cec1a1df225..38fb75638b9 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -115,28 +115,53 @@ export function _resetProjectRefCache(): void { */ export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const +function normalizeHost(value: string): string { + return value.trim().toLowerCase() +} + +function stripGitSuffix(path: string): string { + return path.replace(/\.git$/i, '') +} + +function makeProjectRef( + host: string, + path: string, + knownHosts: readonly string[] +): ProjectRef | null { + const normalizedHost = normalizeHost(host) + if (!knownHosts.map(normalizeHost).includes(normalizedHost)) { + return null + } + const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() + // Reject paths without at least one group segment — `gitlab.com:foo` + // alone is not a project reference. + if (!normalizedPath.includes('/')) { + return null + } + return { host: normalizedHost, path: normalizedPath } +} + export function parseGitLabProjectRef( remoteUrl: string, knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS ): ProjectRef | null { const trimmed = remoteUrl.trim() - for (const host of knownHosts) { - const escapedHost = host.replace(/\./g, '\\.') - // Match SSH (git@host:path) and HTTPS (https://host/path) forms with an - // optional .git suffix. Path may contain nested groups — keep it whole. - const match = trimmed.match(new RegExp(`${escapedHost}[:/]([^\\s]+?)(?:\\.git)?$`)) - if (!match) { - continue + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + return makeProjectRef(scpLike[1], scpLike[2], knownHosts) } - const path = match[1] - // Reject paths without at least one group segment — `gitlab.com:foo` - // alone is not a project reference. - if (!path.includes('/')) { - continue - } - return { host, path } } - return null + + try { + const url = new URL(trimmed) + if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + return makeProjectRef(url.hostname, url.pathname, knownHosts) + } catch { + return null + } } export async function getProjectRefForRemote( diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts new file mode 100644 index 00000000000..b64c70d7be1 --- /dev/null +++ b/src/main/grok/hook-service.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('os', async () => { + const actual = (await vi.importActual('os')) as Record + return { + ...actual, + homedir: homedirMock + } +}) + +import { GrokHookService } from './hook-service' + +describe('GrokHookService', () => { + let homeDir: string + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-grok-home-')) + homedirMock.mockReturnValue(homeDir) + }) + + afterEach(() => { + vi.clearAllMocks() + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs a dedicated global Grok hook config and managed script', () => { + const status = new GrokHookService().install() + + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(homeDir, '.grok', 'hooks', 'orca-status.json')) + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse( + readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ) as { + hooks: Record + } + expect(Object.keys(config.hooks).sort()).toEqual( + [ + 'Notification', + 'PostToolUse', + 'PostToolUseFailure', + 'PreToolUse', + 'SessionEnd', + 'SessionStart', + 'Stop', + 'UserPromptSubmit' + ].sort() + ) + expect(config.hooks.PreToolUse[0].matcher).toBe('*') + expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') + expect(config.hooks.Notification[0].matcher).toBeUndefined() + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook') + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) + + const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'grok-hook.sh'), 'utf8') + expect(script).toContain('/hook/grok') + expect(script).toContain('payload=$(cat)') + }) + + it('preserves user-authored hook entries in the Orca Grok config file', () => { + const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `${JSON.stringify( + { + hooks: { + Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] + } + }, + null, + 2 + )}\n` + ) + + new GrokHookService().install() + + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + hooks: Record + } + const commands = config.hooks.Notification.flatMap((definition) => + definition.hooks.map((hook) => hook.command) + ) + expect(commands).toContain('/usr/local/bin/user-hook') + expect(commands.some((command) => command.includes('grok-hook.sh'))).toBe(true) + }) +}) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts new file mode 100644 index 00000000000..bb8ed8de48c --- /dev/null +++ b/src/main/grok/hook-service.ts @@ -0,0 +1,277 @@ +import { homedir } from 'os' +import { join } from 'path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + buildWindowsAgentHookPostCommand, + getSharedManagedScriptPath, + readHooksJson, + removeManagedCommands, + wrapPosixHookCommand, + writeHooksJson, + writeManagedScript, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' + +const GROK_EVENTS = [ + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } +] as const + +function getConfigPath(): string { + // Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep + // Orca's managed entries in a dedicated file so user-authored hook files stay + // untouched and project-level trust is not required for status reporting. + return join(homedir(), '.grok', 'hooks', 'orca-status.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', + 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', + 'if "%ORCA_PANE_KEY%"=="" exit /b 0', + buildWindowsAgentHookPostCommand('grok'), + 'exit /b 0', + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +function buildInstalledConfig( + config: NonNullable>, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(GROK_EVENTS.map((event) => event.eventName)) + + // Why: Orca owns only grok-hook.* entries. Sweep stale managed commands out + // of retired events while preserving any user-authored hooks in this file. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const event of GROK_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} + +export class GrokHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + const command = getManagedCommand(scriptPath) + const missing: string[] = [] + let presentCount = 0 + for (const event of GROK_EVENTS) { + const definitions = Array.isArray(config.hooks?.[event.eventName]) + ? config.hooks![event.eventName]! + : [] + const hasCommand = definitions.some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === command) + ) + if (hasCommand) { + presentCount += 1 + } else { + missing.push(event.eventName) + } + } + + const managedHooksPresent = presentCount > 0 + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (presentCount === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'grok', state, configPath, managedHooksPresent, detail } + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, config) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.grok/hooks/orca-status.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Grok hook config' + } + } + + buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'grok-hook.sh') + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'grok', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'grok', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = readHooksJson(configPath) + if (!config) { + return { + agent: 'grok', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse Grok hook config' + } + } + + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + config.hooks = nextHooks + writeHooksJson(configPath, config) + return this.getStatus() + } +} + +export const grokHookService = new GrokHookService() diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts new file mode 100644 index 00000000000..bc6bc1a5a59 --- /dev/null +++ b/src/main/hermes/hook-service.test.ts @@ -0,0 +1,227 @@ +import { createServer } from 'http' +import { execFile, execFileSync, spawnSync } from 'child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +import { makePaneKey } from '../../shared/stable-pane-id' +import { HermesHookService, _internals } from './hook-service' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +describe('HermesHookService', () => { + let homeDir: string + let previousHermesHome: string | undefined + + beforeEach(() => { + previousHermesHome = process.env.HERMES_HOME + homeDir = mkdtempSync(join(tmpdir(), 'orca-hermes-hooks-')) + process.env.HERMES_HOME = homeDir + }) + + afterEach(() => { + if (previousHermesHome === undefined) { + delete process.env.HERMES_HOME + } else { + process.env.HERMES_HOME = previousHermesHome + } + rmSync(homeDir, { recursive: true, force: true }) + }) + + it('installs the managed Hermes plugin and enables it in config.yaml', () => { + const status = new HermesHookService().install() + + expect(status).toMatchObject({ + agent: 'hermes', + state: 'installed', + managedHooksPresent: true, + detail: null + }) + const pluginDir = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME) + expect(readFileSync(join(pluginDir, 'plugin.yaml'), 'utf-8')).toContain('provides_hooks:') + expect(readFileSync(join(pluginDir, '__init__.py'), 'utf-8')).toContain('/hook/hermes') + const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as { + plugins: { enabled: string[] } + } + expect(config.plugins.enabled).toContain(_internals.HERMES_PLUGIN_NAME) + }) + + it('preserves other enabled plugins and removes Orca from disabled list', () => { + writeFileSync( + join(homeDir, 'config.yaml'), + [ + 'model: test-model', + 'plugins:', + ' enabled:', + ' - disk-cleanup', + ' disabled:', + ` - ${_internals.HERMES_PLUGIN_NAME}`, + '' + ].join('\n'), + 'utf-8' + ) + + new HermesHookService().install() + + const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as { + model: string + plugins: { enabled: string[]; disabled: string[] } + } + expect(config.model).toBe('test-model') + expect(config.plugins.enabled).toEqual(['disk-cleanup', _internals.HERMES_PLUGIN_NAME]) + expect(config.plugins.disabled).toEqual([]) + }) + + it('normalizes malformed plugin lists during install', () => { + writeFileSync( + join(homeDir, 'config.yaml'), + ['plugins:', ' enabled: "not-a-list"', ' disabled: "not-a-list"', ''].join('\n'), + 'utf-8' + ) + + const status = new HermesHookService().install() + + expect(status.state).toBe('installed') + const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as { + plugins: { enabled: string[]; disabled: string[] } + } + expect(config.plugins.enabled).toEqual([_internals.HERMES_PLUGIN_NAME]) + expect(config.plugins.disabled).toEqual([]) + }) + + it('reports partial when the plugin exists but is not enabled', () => { + new HermesHookService().install() + const update = _internals.updateConfigContent( + readFileSync(join(homeDir, 'config.yaml'), 'utf-8'), + _internals.disablePlugin + ) + expect(update.content).toBeTruthy() + writeFileSync(join(homeDir, 'config.yaml'), update.content!, 'utf-8') + + const status = new HermesHookService().getStatus() + + expect(status.state).toBe('partial') + expect(status.detail).toContain('not enabled') + }) + + it('is visible to the real hermes CLI when hermes is installed', () => { + const hermesAvailable = spawnSync('hermes', ['--version'], { encoding: 'utf-8' }).status === 0 + if (!hermesAvailable) { + return + } + new HermesHookService().install() + + const output = execFileSync('hermes', ['plugins', 'list'], { + env: { ...process.env, HERMES_HOME: homeDir }, + encoding: 'utf-8', + timeout: 15_000 + }) + + expect(output).toContain(_internals.HERMES_PLUGIN_NAME) + expect(output.toLowerCase()).toContain('enabled') + }) + + it('registered plugin hooks post normalized JSON to Orca', async () => { + const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0 + if (!pythonAvailable) { + return + } + new HermesHookService().install() + + const received = new Promise>((resolve, reject) => { + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + try { + expect(req.url).toBe('/hook/hermes') + expect(req.headers['x-orca-agent-hook-token']).toBe('token-1') + res.writeHead(204) + res.end() + clearTimeout(timeout) + server.close() + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as Record) + } catch (error) { + clearTimeout(timeout) + server.close() + reject(error) + } + }) + }) + const timeout = setTimeout(() => { + server.close() + reject(new Error('timed out waiting for Hermes plugin POST')) + }, 5_000) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + reject(new Error('server did not bind to a TCP port')) + return + } + const initPath = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME, '__init__.py') + const script = [ + 'import importlib.util', + `spec = importlib.util.spec_from_file_location("orca_status", ${JSON.stringify(initPath)})`, + 'mod = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(mod)', + 'class Ctx:', + ' def __init__(self): self.hooks = {}', + ' def register_hook(self, name, callback): self.hooks[name] = callback', + 'ctx = Ctx()', + 'mod.register(ctx)', + 'ctx.hooks["pre_llm_call"](', + ' session_id="sess-1",', + ' user_message="make Hermes good",', + ' is_first_turn=True,', + ' model="test-model",', + ' platform="cli",', + ')' + ].join('\n') + // Why: the Python hook POSTs back into this process. A synchronous + // child process blocks the HTTP server from replying, deadlocking the test. + execFile( + 'python3', + ['-c', script], + { + env: { + ...process.env, + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'token-1', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PANE_KEY: PANE_KEY, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: '1' + }, + encoding: 'utf-8' + }, + (error) => { + if (!error) { + return + } + clearTimeout(timeout) + server.close() + reject(error) + } + ) + }) + }) + + await expect(received).resolves.toMatchObject({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + version: '1', + payload: { + hook_event_name: 'pre_llm_call', + prompt: 'make Hermes good', + user_message: 'make Hermes good' + } + }) + }) +}) diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts new file mode 100644 index 00000000000..0f3e9bbd890 --- /dev/null +++ b/src/main/hermes/hook-service.ts @@ -0,0 +1,520 @@ +/* eslint-disable max-lines -- Why: install/status/remove must share the exact Hermes plugin source, YAML enablement logic, and status classification. Splitting would make the managed plugin bytes drift from the installer tests that verify them against the real Hermes CLI. */ +import { randomUUID } from 'crypto' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync +} from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { SFTPWrapper } from 'ssh2' +import { parse, stringify } from 'yaml' + +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + readTextFileRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' + +const HERMES_PLUGIN_NAME = 'orca-status' +const HERMES_PLUGIN_MARKER = 'Managed by Orca. Do not edit; changes may be overwritten.' + +const HERMES_EVENTS = [ + 'on_session_start', + 'pre_llm_call', + 'post_llm_call', + 'pre_tool_call', + 'post_tool_call', + 'pre_approval_request', + 'post_approval_response', + 'on_session_end', + 'on_session_finalize', + 'on_session_reset' +] as const + +type HermesConfig = Record + +type ConfigParseResult = { ok: true; config: HermesConfig } | { ok: false; detail: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asStringArray(value: unknown): string[] | null { + if (value === undefined) { + return [] + } + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + return null + } + return value +} + +function getHermesHome(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.HERMES_HOME?.trim() + return explicit ? explicit : join(homedir(), '.hermes') +} + +function getConfigPath(): string { + return join(getHermesHome(), 'config.yaml') +} + +function getPluginDir(): string { + return join(getHermesHome(), 'plugins', HERMES_PLUGIN_NAME) +} + +function getManifestPath(pluginDir = getPluginDir()): string { + return join(pluginDir, 'plugin.yaml') +} + +function getInitPath(pluginDir = getPluginDir()): string { + return join(pluginDir, '__init__.py') +} + +function parseHermesConfig(content: string | null): ConfigParseResult { + if (!content || content.trim().length === 0) { + return { ok: true, config: {} } + } + try { + const parsed = parse(content) as unknown + if (parsed === null || parsed === undefined) { + return { ok: true, config: {} } + } + if (!isRecord(parsed)) { + return { ok: false, detail: 'Hermes config.yaml root must be a mapping' } + } + return { ok: true, config: { ...parsed } } + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : String(error) + } + } +} + +function serializeHermesConfig(config: HermesConfig): string { + return `${stringify(config, { lineWidth: 0 }).trimEnd()}\n` +} + +function enablePlugin(config: HermesConfig): HermesConfig { + const next: HermesConfig = { ...config } + const plugins = isRecord(next.plugins) ? { ...next.plugins } : {} + const enabled = asStringArray(plugins.enabled) ?? [] + const disabled = asStringArray(plugins.disabled) + plugins.enabled = Array.from(new Set([...enabled, HERMES_PLUGIN_NAME])).sort() + if (disabled === null) { + // Why: Hermes treats a malformed disabled list as empty. Normalize it here + // so Orca's install status matches what the real Hermes loader will do. + plugins.disabled = [] + } else if (disabled.includes(HERMES_PLUGIN_NAME)) { + const filtered = disabled.filter((name) => name !== HERMES_PLUGIN_NAME) + plugins.disabled = filtered + } + next.plugins = plugins + return next +} + +function disablePlugin(config: HermesConfig): HermesConfig { + const next: HermesConfig = { ...config } + if (!isRecord(next.plugins)) { + return next + } + const plugins = { ...next.plugins } + const enabled = asStringArray(plugins.enabled) + if (enabled !== null) { + plugins.enabled = enabled.filter((name) => name !== HERMES_PLUGIN_NAME) + } + next.plugins = plugins + return next +} + +function readConfigFile(configPath: string): ConfigParseResult { + if (!existsSync(configPath)) { + return { ok: true, config: {} } + } + return parseHermesConfig(readFileSync(configPath, 'utf-8')) +} + +function writeConfigFile(configPath: string, config: HermesConfig): void { + const dir = dirname(configPath) + mkdirSync(dir, { recursive: true }) + const serialized = serializeHermesConfig(config) + if (existsSync(configPath)) { + try { + if (readFileSync(configPath, 'utf-8') === serialized) { + return + } + } catch { + // Fall through to the atomic write path. + } + } + + const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, serialized, 'utf-8') + if (existsSync(configPath)) { + copyFileSync(configPath, `${configPath}.bak`) + } + renameSync(tmpPath, configPath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} + +function updateConfigContent( + content: string | null, + updater: (config: HermesConfig) => HermesConfig +): { content: string | null; detail?: string } { + const parsed = parseHermesConfig(content) + if (!parsed.ok) { + return { content: null, detail: parsed.detail } + } + return { content: serializeHermesConfig(updater(parsed.config)) } +} + +function getPluginFilesState(pluginDir = getPluginDir()): { + present: boolean + managed: boolean + detail: string | null +} { + const manifestPath = getManifestPath(pluginDir) + const initPath = getInitPath(pluginDir) + if (!existsSync(manifestPath) || !existsSync(initPath)) { + return { present: false, managed: false, detail: 'Managed Hermes plugin files are missing' } + } + try { + const manifest = readFileSync(manifestPath, 'utf-8') + const init = readFileSync(initPath, 'utf-8') + const managed = manifest.includes(HERMES_PLUGIN_MARKER) && init.includes(HERMES_PLUGIN_MARKER) + return { + present: true, + managed, + detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed' + } + } catch (error) { + return { + present: true, + managed: false, + detail: error instanceof Error ? error.message : String(error) + } + } +} + +function getConfigEnablement(config: HermesConfig): { + enabled: boolean + disabled: boolean + detail: string | null +} { + if (!isRecord(config.plugins)) { + return { enabled: false, disabled: false, detail: 'plugins.enabled is missing' } + } + const enabled = asStringArray(config.plugins.enabled) + const disabled = asStringArray(config.plugins.disabled) + if (enabled === null) { + return { enabled: false, disabled: false, detail: 'plugins.enabled is not a string list' } + } + if (disabled === null) { + return { enabled: false, disabled: false, detail: 'plugins.disabled is not a string list' } + } + return { + enabled: enabled.includes(HERMES_PLUGIN_NAME), + disabled: disabled.includes(HERMES_PLUGIN_NAME), + detail: null + } +} + +function buildStatus(configPath: string, config: HermesConfig): AgentHookInstallStatus { + const pluginFiles = getPluginFilesState() + const enablement = getConfigEnablement(config) + const details = [ + pluginFiles.detail, + enablement.detail, + !enablement.enabled ? 'orca-status is not enabled in Hermes config.yaml' : null, + enablement.disabled ? 'orca-status is disabled in Hermes config.yaml' : null + ].filter((detail): detail is string => Boolean(detail)) + + let state: AgentHookInstallState + if (!pluginFiles.present && !enablement.enabled) { + state = 'not_installed' + } else if ( + pluginFiles.present && + pluginFiles.managed && + enablement.enabled && + !enablement.disabled + ) { + state = 'installed' + } else { + state = 'partial' + } + + return { + agent: 'hermes', + state, + configPath, + managedHooksPresent: pluginFiles.present && pluginFiles.managed, + detail: state === 'installed' || state === 'not_installed' ? null : details.join('; ') + } +} + +function getPluginManifest(): string { + return [ + `# ${HERMES_PLUGIN_MARKER}`, + `name: ${HERMES_PLUGIN_NAME}`, + 'version: 1.0.0', + 'description: "Reports Hermes Agent lifecycle events to Orca."', + 'author: "Orca"', + 'kind: standalone', + 'provides_hooks:', + ...HERMES_EVENTS.map((event) => ` - ${event}`), + '' + ].join('\n') +} + +function getPluginInitSource(): string { + return `# ${HERMES_PLUGIN_MARKER} +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any, Callable + +EVENTS = ${JSON.stringify(HERMES_EVENTS)} +SELECTED_KEYS = { + "on_session_start": ("session_id", "model", "platform"), + "pre_llm_call": ("session_id", "user_message", "is_first_turn", "model", "platform", "sender_id"), + "post_llm_call": ("session_id", "user_message", "assistant_response", "model", "platform"), + "pre_tool_call": ("session_id", "task_id", "tool_call_id", "tool_name", "args"), + "post_tool_call": ("session_id", "task_id", "tool_call_id", "tool_name", "args", "result", "duration_ms"), + "pre_approval_request": ("command", "description", "pattern_key", "pattern_keys", "session_key", "surface"), + "post_approval_response": ("command", "description", "pattern_key", "pattern_keys", "session_key", "surface", "choice"), + "on_session_end": ("session_id",), + "on_session_finalize": ("session_id", "platform"), + "on_session_reset": ("session_id", "platform"), +} + + +def _jsonable(value: Any, depth: int = 0) -> Any: + if depth > 5: + return repr(value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _jsonable(v, depth + 1) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(v, depth + 1) for v in value] + return repr(value) + + +def _endpoint_env() -> dict[str, str]: + env = dict(os.environ) + endpoint = env.get("ORCA_AGENT_HOOK_ENDPOINT", "") + if endpoint and os.path.isfile(endpoint): + try: + with open(endpoint, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("set "): + line = line[4:] + key, sep, value = line.partition("=") + if sep and key: + env[key] = value + except OSError: + pass + return env + + +def _post_to_orca(payload: dict[str, Any]) -> None: + env = _endpoint_env() + port = env.get("ORCA_AGENT_HOOK_PORT", "") + token = env.get("ORCA_AGENT_HOOK_TOKEN", "") + pane_key = env.get("ORCA_PANE_KEY", "") + if not port or not token or not pane_key: + return + body = { + "paneKey": pane_key, + "tabId": env.get("ORCA_TAB_ID", ""), + "worktreeId": env.get("ORCA_WORKTREE_ID", ""), + "env": env.get("ORCA_AGENT_HOOK_ENV", ""), + "version": env.get("ORCA_AGENT_HOOK_VERSION", ""), + "payload": payload, + } + data = json.dumps(body, separators=(",", ":")).encode("utf-8") + request = urllib.request.Request( + f"http://127.0.0.1:{port}/hook/hermes", + data=data, + method="POST", + headers={ + "Content-Type": "application/json", + "X-Orca-Agent-Hook-Token": token, + }, + ) + try: + with urllib.request.urlopen(request, timeout=0.75): + pass + except (OSError, urllib.error.URLError): + return + + +def _payload_for_event(event_name: str, kwargs: dict[str, Any]) -> dict[str, Any]: + payload: dict[str, Any] = {"hook_event_name": event_name, "cwd": os.getcwd()} + for key in SELECTED_KEYS.get(event_name, ()): + if key in kwargs: + payload[key] = _jsonable(kwargs[key]) + if "user_message" in payload: + payload["prompt"] = payload["user_message"] + if "assistant_response" in payload: + payload["last_assistant_message"] = payload["assistant_response"] + if "args" in payload: + payload["tool_input"] = payload["args"] + if event_name in {"pre_approval_request", "post_approval_response"}: + payload["tool_name"] = "approval" + payload["tool_input"] = { + "command": payload.get("command", ""), + "description": payload.get("description", ""), + } + return payload + + +def _make_hook(event_name: str) -> Callable[..., None]: + def _hook(**kwargs: Any) -> None: + _post_to_orca(_payload_for_event(event_name, kwargs)) + + return _hook + + +def register(ctx: Any) -> None: + for event_name in EVENTS: + ctx.register_hook(event_name, _make_hook(event_name)) +` +} + +function writePluginFiles(pluginDir = getPluginDir()): void { + mkdirSync(pluginDir, { recursive: true }) + writeFileSync(getManifestPath(pluginDir), getPluginManifest(), 'utf-8') + writeFileSync(getInitPath(pluginDir), getPluginInitSource(), 'utf-8') +} + +function stripTrailingSlash(path: string): string { + return path.replace(/\/+$/, '') +} + +export class HermesHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const parsed = readConfigFile(configPath) + if (!parsed.ok) { + return { + agent: 'hermes', + state: 'error', + configPath, + managedHooksPresent: getPluginFilesState().managed, + detail: `Could not parse Hermes config.yaml: ${parsed.detail}` + } + } + return buildStatus(configPath, parsed.config) + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const parsed = readConfigFile(configPath) + if (!parsed.ok) { + return { + agent: 'hermes', + state: 'error', + configPath, + managedHooksPresent: getPluginFilesState().managed, + detail: `Could not parse Hermes config.yaml: ${parsed.detail}` + } + } + + writePluginFiles() + writeConfigFile(configPath, enablePlugin(parsed.config)) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteRoot = stripTrailingSlash(remoteHome) + const remoteConfigPath = `${remoteRoot}/.hermes/config.yaml` + const remotePluginDir = `${remoteRoot}/.hermes/plugins/${HERMES_PLUGIN_NAME}` + try { + const existing = await readTextFileRemote(sftp, remoteConfigPath) + const next = updateConfigContent(existing, enablePlugin) + if (next.content === null) { + return { + agent: 'hermes', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: `Could not parse remote Hermes config.yaml: ${next.detail ?? 'unknown error'}` + } + } + await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/plugin.yaml`, getPluginManifest()) + await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/__init__.py`, getPluginInitSource()) + await writeTextFileRemoteAtomic(sftp, remoteConfigPath, next.content) + return { + agent: 'hermes', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (error) { + return { + agent: 'hermes', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const parsed = readConfigFile(configPath) + if (!parsed.ok) { + return { + agent: 'hermes', + state: 'error', + configPath, + managedHooksPresent: getPluginFilesState().managed, + detail: `Could not parse Hermes config.yaml: ${parsed.detail}` + } + } + const pluginDir = getPluginDir() + if (getPluginFilesState(pluginDir).managed) { + rmSync(pluginDir, { recursive: true, force: true }) + } + writeConfigFile(configPath, disablePlugin(parsed.config)) + return this.getStatus() + } +} + +export const hermesHookService = new HermesHookService() + +export const _internals = { + HERMES_PLUGIN_NAME, + HERMES_EVENTS, + getHermesHome, + getPluginManifest, + getPluginInitSource, + parseHermesConfig, + enablePlugin, + disablePlugin, + updateConfigContent +} diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index cb8e1b8c994..da98c8ddba7 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -185,33 +185,45 @@ describe('createIssueCommandRunnerScript', () => { it('writes a POSIX runner under the worktree git dir for long issue commands', async () => { const fs = await import('fs') + const originalPlatform = process.platform execFileSyncMock.mockReturnValue( '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh' ) - - const { createIssueCommandRunnerScript } = await import('./hooks') - const result = createIssueCommandRunnerScript( - makeRepo(), - '/test/repo-feature', - 'codex exec "long command"\nclaude -p "review it"' - ) - - expect(result).toEqual({ - runnerScriptPath: '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: '/test/repo', - ORCA_WORKTREE_PATH: '/test/repo-feature' - }) + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' }) - expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', - '#!/usr/bin/env bash\nset -e\ncodex exec "long command"\nclaude -p "review it"\n', - 'utf-8' - ) - expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith( - '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', - 0o755 - ) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + '/test/repo-feature', + 'codex exec "long command"\nclaude -p "review it"' + ) + + expect(result).toEqual({ + runnerScriptPath: '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/test/repo', + ORCA_WORKTREE_PATH: '/test/repo-feature' + }) + }) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\ncodex exec "long command"\nclaude -p "review it"\n', + 'utf-8' + ) + expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith( + '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh', + 0o755 + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } }) }) diff --git a/src/main/index.ts b/src/main/index.ts index e87525574d9..3f0e82354c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { grantDirAcl } from './win32-utils' import { existsSync } from 'fs' import { join } from 'path' +import os from 'node:os' import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' @@ -13,6 +14,7 @@ import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' +import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -26,6 +28,7 @@ import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' +import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' @@ -38,11 +41,12 @@ import { patchPackagedProcessPath } from './startup/configure-process' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' +import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock } from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' import { attachMainWindowServices } from './window/attach-main-window-services' -import { createMainWindow } from './window/createMainWindow' +import { createMainWindow, loadMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import { ClaudeAccountService } from './claude-accounts/service' @@ -55,6 +59,9 @@ import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' import { droidHookService } from './droid/hook-service' +import { grokHookService } from './grok/hook-service' +import { copilotHookService } from './copilot/hook-service' +import { hermesHookService } from './hermes/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, @@ -67,6 +74,12 @@ import { setUnreadDockBadgeCount } from './dock/unread-badge' import { registerFeatureWallFirstAgentTour } from './feature-wall/first-agent-tour' import { AutomationService } from './automations/service' import { AgentAwakeService } from './agent-awake-service' +import { + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from './crash-reporting/crash-breadcrumb-store' +import { CrashReportStore } from './crash-reporting/crash-report-store' +import { isCrashReportReason } from '../shared/crash-reporting' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -77,6 +90,7 @@ let store: Store | null = null let stats: StatsCollector | null = null let claudeUsage: ClaudeUsageStore | null = null let codexUsage: CodexUsageStore | null = null +let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null let claudeAccounts: ClaudeAccountService | null = null @@ -86,12 +100,14 @@ let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null +let crashReports: CrashReportStore | null = null let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null let disposeFeatureWallFirstAgentTour: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null const isServeMode = process.argv.includes('--serve') +const devInstanceIdentity = getDevInstanceIdentity(is.dev) installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -189,6 +205,12 @@ if (hasSingleInstanceLock) { initStatsPath() initClaudeUsagePath() initCodexUsagePath() + initOpenCodeUsagePath() + crashReports = CrashReportStore.fromUserData() + recordCrashBreadcrumb('app_started', { + packaged: app.isPackaged, + platform: process.platform + }) enableMainProcessGpuFeatures() } @@ -208,6 +230,9 @@ function openMainWindow(): BrowserWindow { if (!codexUsage) { throw new Error('Codex usage store must be initialized before opening the main window') } + if (!openCodeUsage) { + throw new Error('OpenCode usage store must be initialized before opening the main window') + } if (!rateLimits) { throw new Error('Rate limit service must be initialized before opening the main window') } @@ -246,14 +271,23 @@ function openMainWindow(): BrowserWindow { getIsQuitting: () => isQuitting, onQuitAborted: () => { isQuitting = false - } + }, + onRendererProcessGone: (details) => { + recordProcessGoneCrash('renderer', 'renderer', details.reason, details.exitCode ?? null, { + processType: 'renderer' + }) + }, + deferLoad: true, + title: devInstanceIdentity.name }) + recordCrashBreadcrumb('main_window_created') // Why: telemetry-plan.md§First-launch experience anchors default-on // `app_opened` to the first main-window load. Existing users in the // pending-banner cohort resolve through telemetry/client.ts; this load // path only fires once consent is already enabled. const onFirstWindowLoad = (): void => { + recordCrashBreadcrumb('main_window_loaded') if (!store) { return } @@ -271,6 +305,7 @@ function openMainWindow(): BrowserWindow { stats, claudeUsage, codexUsage, + openCodeUsage, codexAccounts, claudeAccounts, rateLimits, @@ -283,7 +318,8 @@ function openMainWindow(): BrowserWindow { : null, prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() }, - agentAwakeService ?? undefined + agentAwakeService ?? undefined, + crashReports ?? undefined ) automations.setWebContents(window.webContents) automations.start() @@ -335,6 +371,10 @@ function openMainWindow(): BrowserWindow { receivedAt, stateStartedAt }) + recordCrashBreadcrumb('agent_state_changed', { + agentType: payload.agentType ?? 'unknown', + state: payload.state + }) // Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn, // and opencode's stays bare "OpenCode" — neither carries a working/idle // signal the title heuristic can read. Synthesize an OSC title update @@ -361,6 +401,7 @@ function openMainWindow(): BrowserWindow { }) } }) + loadMainWindow(window) return window } @@ -370,6 +411,52 @@ function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { webContents?.send('ui:openFeatureTour') } +function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents + webContents?.send('ui:openCrashReport') +} + +const recentCrashKeys = new Map() + +function recordProcessGoneCrash( + source: 'renderer' | 'child', + processType: string, + reason: string, + exitCode: number | null, + details: Record +): void { + if (!crashReports || !isCrashReportReason(reason)) { + return + } + const key = `${processType}:${reason}:${exitCode ?? 'null'}` + const now = Date.now() + if (now - (recentCrashKeys.get(key) ?? 0) < 2_000) { + return + } + recentCrashKeys.set(key, now) + void crashReports + .record({ + source, + processType, + reason, + exitCode, + appVersion: app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + details, + // Why: breadcrumbs stay memory-only during normal operation. Persist a + // snapshot only after Electron reports a crash-like process exit. + breadcrumbs: getCrashBreadcrumbSnapshot() + }) + .catch((error) => { + console.error('[crash-reporting] Failed to persist crash report:', error) + }) +} + function shutdownWatchersOnce(): Promise { if (watcherShutdownDone) { return Promise.resolve() @@ -426,6 +513,11 @@ const SYNTHETIC_TITLE_PROFILES: Record = { workingLabel: 'Droid', permissionLabel: 'Droid - action required', idleLabel: 'Droid ready' + }, + hermes: { + workingLabel: 'Hermes', + permissionLabel: 'Hermes - action required', + idleLabel: 'Hermes ready' } } @@ -627,8 +719,8 @@ function driveSyntheticTitleFromHook( } app.whenReady().then(async () => { - electronApp.setAppUserModelId('com.stablyai.orca') - app.setName('Orca') + electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) + app.setName(devInstanceIdentity.name) if (process.platform === 'darwin' && is.dev) { const dockIcon = nativeImage.createFromPath(devIcon) @@ -663,6 +755,7 @@ app.whenReady().then(async () => { stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) + openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) @@ -691,8 +784,15 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) - automations = new AutomationService(store) + automations = new AutomationService(store, { claudeUsage, codexUsage }) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + runtime.setCommitMessageAgentEnvironmentResolvers({ + prepareForCodexLaunch: () => + store!.getSettings().activeCodexManagedAccountId + ? codexRuntimeHome!.prepareForCodexLaunch() + : null, + prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() + }) disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({ stats, getWindow: () => mainWindow @@ -707,20 +807,39 @@ app.whenReady().then(async () => { // (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup. // The agent label travels with each installer so the catch can attribute // the failure in the `agent_hook_install_failed` telemetry event. - runManagedHookInstallers([ + const managedHookInstallers = [ ['claude', () => claudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['cursor', () => cursorHookService.install()], - ['droid', () => droidHookService.install()] - ]) + ['droid', () => droidHookService.install()], + ['grok', () => grokHookService.install()], + ['copilot', () => copilotHookService.install()], + ['hermes', () => hermesHookService.install()] + ] as const + runManagedHookInstallers(managedHookInstallers) + + app.on('child-process-gone', (_event, details) => { + recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { + name: details.name, + serviceName: details.serviceName, + type: details.type + }) + }) registerAppMenu({ onCheckForUpdates: (options) => checkForUpdatesFromMenu(options), onOpenSettings: () => { + recordCrashBreadcrumb('settings_opened') mainWindow?.webContents.send('ui:openSettings') }, + onOpenCrashReport: (targetWindow) => { + recordCrashBreadcrumb('crash_report_opened') + const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null + sendOpenCrashReport(targetBrowserWindow) + }, onOpenFeatureTour: (targetWindow) => { + recordCrashBreadcrumb('feature_tour_opened') // Why: menu clicks provide the BrowserWindow that invoked the item. Use it // first so hidden/headless E2E windows and future multi-window flows route // the tour to the correct renderer instead of relying on global focus. @@ -945,6 +1064,7 @@ app.on('will-quit', (e) => { const rpcStopAndClear = runtimeRpc ? runtimeRpc .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) .then(() => { if (ownedRuntimeId) { clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index f82ade795e1..835ba210543 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -53,6 +53,15 @@ vi.mock('../gemini/hook-service', () => ({ vi.mock('../cursor/hook-service', () => ({ cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) } })) +vi.mock('../droid/hook-service', () => ({ + droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) } +})) +vi.mock('../grok/hook-service', () => ({ + grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) } +})) +vi.mock('../hermes/hook-service', () => ({ + hermesHookService: { getStatus: vi.fn(() => ({ agent: 'hermes', state: 'absent' })) } +})) beforeEach(() => { dropStatusEntry.mockReset() diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 350c1c9200e..1e45dd3c9d0 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -14,6 +14,9 @@ import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' import { cursorHookService } from '../cursor/hook-service' import { droidHookService } from '../droid/hook-service' +import { grokHookService } from '../grok/hook-service' +import { copilotHookService } from '../copilot/hook-service' +import { hermesHookService } from '../hermes/hook-service' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a @@ -31,6 +34,9 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:cursorStatus') ipcMain.removeHandler('agentHooks:droidStatus') + ipcMain.removeHandler('agentHooks:grokStatus') + ipcMain.removeHandler('agentHooks:copilotStatus') + ipcMain.removeHandler('agentHooks:hermesStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via @@ -133,4 +139,43 @@ export function registerAgentHookHandlers(): void { } } }) + ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => { + try { + return grokHookService.getStatus() + } catch (err) { + return { + agent: 'grok', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) + ipcMain.handle('agentHooks:copilotStatus', (): AgentHookInstallStatus => { + try { + return copilotHookService.getStatus() + } catch (err) { + return { + agent: 'copilot', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) + ipcMain.handle('agentHooks:hermesStatus', (): AgentHookInstallStatus => { + try { + return hermesHookService.getStatus() + } catch (err) { + return { + agent: 'hermes', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) } diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 9ded836a371..b31913178d1 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,11 +1,15 @@ import { ipcMain } from 'electron' -import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} from '../agent-trust-presets' -export type AgentTrustPreset = 'cursor' | 'copilot' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' /** - * Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar - * directory behind a "Do you trust this folder?" menu that consumes + * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an + * unfamiliar directory behind a "Do you trust this folder?" menu that consumes * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL * paste flow needs the input box, not the menu, so before Orca spawns the * agent it asks main to write the same trust artifacts the agents write @@ -25,6 +29,8 @@ export function registerAgentTrustHandlers(): void { markCursorWorkspaceTrusted(args.workspacePath) } else if (args.preset === 'copilot') { markCopilotFolderTrusted(args.workspacePath) + } else if (args.preset === 'codex') { + markCodexProjectTrusted(args.workspacePath) } } catch { // Best-effort: see Why above. The user can still accept the trust diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 70e7e9c6ff4..2e0507aea3a 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -5,7 +5,10 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { app, ipcMain } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest } from '../../shared/types' +import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' import { isWslAvailable } from '../wsl' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -73,6 +76,19 @@ function resolveDevFeatureWallAssetDir(): string { export function registerAppHandlers(): void { ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) + ipcMain.handle('app:getIdentity', (): AppIdentity => { + const identity = getDevInstanceIdentity(is.dev) + return { + name: identity.name, + isDev: identity.isDev, + devLabel: identity.devLabel, + devBranch: identity.devBranch, + devWorktreeName: identity.devWorktreeName, + devRepoRoot: identity.devRepoRoot, + dockBadgeLabel: identity.dockBadgeLabel + } + }) + ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index f5c762d265c..ec3ddbcf712 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -5,14 +5,21 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + ExternalAutomationCreateInput, ExternalAutomationActionInput, ExternalAutomationManager, + ExternalAutomationRunsInput, + ExternalAutomationRunsPage, + ExternalAutomationUpdateInput, AutomationRun, AutomationUpdateInput } from '../../shared/automations-types' import { + createExternalAutomation, listExternalAutomationManagers, - runExternalAutomationAction + listExternalAutomationRuns, + runExternalAutomationAction, + updateExternalAutomation } from '../automations/external-manager' export function registerAutomationHandlers(store: Store, service: AutomationService): void { @@ -25,6 +32,18 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ ipcMain.handle('automations:listExternalManagers', (): Promise => { return listExternalAutomationManagers(store) }) + ipcMain.handle( + 'automations:listExternalRuns', + (_event, input: ExternalAutomationRunsInput): Promise => { + return listExternalAutomationRuns(input) + } + ) + ipcMain.handle('automations:createExternal', (_event, input: ExternalAutomationCreateInput) => { + return createExternalAutomation(input) + }) + ipcMain.handle('automations:updateExternal', (_event, input: ExternalAutomationUpdateInput) => { + return updateExternalAutomation(input) + }) ipcMain.handle( 'automations:runExternalAction', (_event, input: ExternalAutomationActionInput) => { @@ -49,7 +68,13 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ ) ipcMain.handle( 'automations:markDispatchResult', - (_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result) + (_event, result: AutomationDispatchResult): Promise => + service.markDispatchResult(result) + ) + ipcMain.handle( + 'automations:snapshotWorkspaceName', + (_event, args: { workspaceId: string; displayName: string }): number => + store.snapshotAutomationRunWorkspaceDisplayName(args.workspaceId, args.displayName) ) ipcMain.handle('automations:rendererReady', (): void => { service.setRendererReady() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 25a3b73dae1..7b89cc1da35 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -8,6 +8,7 @@ const { getGuestWebContentsIdMock, getWorktreeIdForTabMock, openDevToolsMock, + setAnnotationViewportBridgeMock, getDownloadPromptMock, acceptDownloadMock, cancelDownloadMock, @@ -21,6 +22,7 @@ const { getGuestWebContentsIdMock: vi.fn(), getWorktreeIdForTabMock: vi.fn(), openDevToolsMock: vi.fn().mockResolvedValue(true), + setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), getDownloadPromptMock: vi.fn(), acceptDownloadMock: vi.fn(), cancelDownloadMock: vi.fn(), @@ -48,6 +50,7 @@ vi.mock('../browser/browser-manager', () => ({ getGuestWebContentsId: getGuestWebContentsIdMock, getWorktreeIdForTab: getWorktreeIdForTabMock, openDevTools: openDevToolsMock, + setAnnotationViewportBridge: setAnnotationViewportBridgeMock, getDownloadPrompt: getDownloadPromptMock, acceptDownload: acceptDownloadMock, cancelDownload: cancelDownloadMock @@ -66,12 +69,14 @@ describe('registerBrowserHandlers', () => { getGuestWebContentsIdMock.mockReset() getWorktreeIdForTabMock.mockReset() openDevToolsMock.mockReset() + setAnnotationViewportBridgeMock.mockReset() getDownloadPromptMock.mockReset() acceptDownloadMock.mockReset() cancelDownloadMock.mockReset() showSaveDialogMock.mockReset() browserWindowFromWebContentsMock.mockReset() openDevToolsMock.mockResolvedValue(true) + setAnnotationViewportBridgeMock.mockResolvedValue(true) setAgentBrowserBridgeRef(null) }) @@ -159,4 +164,67 @@ describe('registerBrowserHandlers', () => { expect(result).toBe(true) expect(onTabChangedMock).toHaveBeenCalledWith(4242, 'wt-browser') }) + + it('validates annotation viewport bridge requests before syncing to the guest', async () => { + registerBrowserHandlers() + + const syncHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setAnnotationViewportBridge' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise | boolean + + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + const result = await syncHandler( + { sender }, + { + browserPageId: 'page-1', + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' + } + ) + + expect(result).toBe(true) + expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' + }) + }) + + it('rejects invalid annotation viewport bridge requests', async () => { + registerBrowserHandlers() + + const syncHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setAnnotationViewportBridge' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean + + const result = syncHandler( + { + sender: { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + }, + { + browserPageId: 'page-1', + emitViewport: false, + enabled: true, + markers: [], + token: 'short' + } + ) + + expect(result).toBe(false) + expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index e2424648b98..c640365d7ec 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -28,6 +28,11 @@ import type { BrowserSessionProfileScope, BrowserViewportOverride } from '../../shared/types' +import { + isValidBrowserAnnotationViewportBridgeMarkers, + isValidBrowserAnnotationViewportBridgeToken, + type BrowserSetAnnotationViewportBridgeArgs +} from '../../shared/browser-annotation-viewport-bridge' let trustedBrowserRendererWebContentsId: number | null = null let agentBrowserBridgeRef: AgentBrowserBridge | null = null @@ -86,6 +91,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') ipcMain.removeHandler('browser:setViewportOverride') + ipcMain.removeHandler('browser:setAnnotationViewportBridge') ipcMain.removeHandler('browser:acceptDownload') ipcMain.removeHandler('browser:cancelDownload') ipcMain.removeHandler('browser:setGrabMode') @@ -216,6 +222,30 @@ export function registerBrowserHandlers(): void { } ) + ipcMain.handle( + 'browser:setAnnotationViewportBridge', + (event, args: BrowserSetAnnotationViewportBridgeArgs): Promise | boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + if ( + typeof args?.browserPageId !== 'string' || + typeof args.enabled !== 'boolean' || + typeof args.emitViewport !== 'boolean' || + !isValidBrowserAnnotationViewportBridgeMarkers(args.markers) || + !isValidBrowserAnnotationViewportBridgeToken(args.token) + ) { + return false + } + return browserManager.setAnnotationViewportBridge(args.browserPageId, { + enabled: args.enabled, + emitViewport: args.emitViewport, + markers: args.markers, + token: args.token + }) + } + ) + ipcMain.handle('browser:acceptDownload', async (event, args: { downloadId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'not-authorized' as const } diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts new file mode 100644 index 00000000000..7166d5238f4 --- /dev/null +++ b/src/main/ipc/crash-reporting.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CrashReportRecord } from '../../shared/crash-reporting' + +const { handlers, clipboardWriteTextMock, submitFeedbackMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + clipboardWriteTextMock: vi.fn(), + submitFeedbackMock: vi.fn() +})) + +vi.mock('electron', () => ({ + clipboard: { writeText: clipboardWriteTextMock }, + ipcMain: { + removeHandler: vi.fn((channel: string) => handlers.delete(channel)), + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('./feedback', () => ({ + submitFeedback: (...args: unknown[]) => submitFeedbackMock(...args) +})) + +import { registerCrashReportingHandlers } from './crash-reporting' + +function report( + status: CrashReportRecord['status'] = 'pending', + id = 'crash-1' +): CrashReportRecord { + return { + id, + createdAt: '2026-05-16T01:00:00.000Z', + status, + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: process.platform, + osRelease: 'test', + arch: process.arch, + electronVersion: '41', + chromeVersion: '141', + details: {} + } +} + +describe('registerCrashReportingHandlers', () => { + beforeEach(() => { + handlers.clear() + clipboardWriteTextMock.mockReset() + submitFeedbackMock.mockReset() + submitFeedbackMock.mockResolvedValue({ ok: true }) + }) + + it('copies the latest pending diagnostic text to the clipboard', async () => { + const latest = report() + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => latest), + getById: vi.fn(async () => latest), + dismiss: vi.fn(), + markSent: vi.fn(), + listRecent: vi.fn(async () => [latest]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, { + notes: 'extra /Users/alice/project' + }) + + expect(result).toEqual({ ok: true }) + expect(clipboardWriteTextMock).toHaveBeenCalledWith(expect.stringContaining('[Crash Report]')) + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + expect.stringContaining('extra [redacted-path]') + ) + }) + + it('submits a dismissed report when the already-open prompt sends it', async () => { + const dismissed = report('dismissed', 'crash-already-dismissed') + const sent = report('sent', dismissed.id) + const markDismissedSent = vi.fn(async () => sent) + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => null), + getById: vi.fn(async () => dismissed), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent, + listRecent: vi.fn(async () => []), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: dismissed.id, + githubLogin: null, + githubEmail: null + }) + + expect(result).toEqual({ ok: true, report: sent }) + expect(submitFeedbackMock).toHaveBeenCalledWith( + expect.objectContaining({ feedback: expect.stringContaining('[Crash Report]') }) + ) + expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id) + }) + + it('submits through feedback and marks the report sent only after success', async () => { + const latest = report('pending', 'crash-submit-success') + const sent = report('sent', latest.id) + const markSent = vi.fn(async () => sent) + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => latest), + getById: vi.fn(async () => latest), + dismiss: vi.fn(), + markSent, + listRecent: vi.fn(async () => [latest]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: latest.id, + githubLogin: 'me', + githubEmail: 'me@example.com', + notes: 'extra' + }) + + expect(result).toEqual({ ok: true, report: sent }) + expect(submitFeedbackMock).toHaveBeenCalledWith( + expect.objectContaining({ + feedback: expect.stringContaining('[Crash Report]'), + githubLogin: 'me', + githubEmail: 'me@example.com' + }) + ) + expect(markSent).toHaveBeenCalledWith(latest.id) + }) + + it('does not surface a successful upload as failed if marking sent fails locally', async () => { + const latest = report('pending', 'crash-mark-sent-fails') + const markSent = vi.fn(async () => { + throw new Error('disk unavailable') + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => latest), + getById: vi.fn(async () => latest), + dismiss: vi.fn(), + markSent, + listRecent: vi.fn(async () => [latest]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + try { + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: latest.id, + githubLogin: null, + githubEmail: null + }) + + expect(result).toEqual({ ok: true, report: { ...latest, status: 'sent' } }) + expect(markSent).toHaveBeenCalledWith(latest.id) + await expect(handlers.get('crashReports:getLatestPending')?.(null)).resolves.toBeNull() + } finally { + consoleError.mockRestore() + } + }) + + it('keeps the report pending on feedback failure', async () => { + submitFeedbackMock.mockResolvedValue({ ok: false, status: 500, error: 'status 500' }) + const latest = report('pending', 'crash-submit-failure') + const markSent = vi.fn() + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => latest), + getById: vi.fn(async () => latest), + dismiss: vi.fn(), + markSent, + listRecent: vi.fn(async () => [latest]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + githubLogin: null, + githubEmail: null + }) + + expect(result).toMatchObject({ ok: false, status: 500, report: latest }) + expect(markSent).not.toHaveBeenCalled() + }) + + it('does not dismiss a report while submission is in flight', async () => { + let resolveSubmit: (value: { ok: true }) => void = () => {} + submitFeedbackMock.mockReturnValue( + new Promise((resolve) => { + resolveSubmit = resolve + }) + ) + const latest = report('pending', 'crash-in-flight') + const dismiss = vi.fn() + registerCrashReportingHandlers({ + getLatestPending: vi.fn(async () => latest), + getById: vi.fn(async () => latest), + dismiss, + markSent: vi.fn(async () => report('sent', latest.id)), + listRecent: vi.fn(), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const submitPromise = handlers.get('crashReports:submit')?.(null, { + reportId: latest.id, + githubLogin: null, + githubEmail: null + }) + await vi.waitFor(() => expect(submitFeedbackMock).toHaveBeenCalled()) + + const dismissResult = await handlers.get('crashReports:dismiss')?.(null, { + reportId: latest.id + }) + + expect(dismissResult).toEqual(latest) + expect(dismiss).not.toHaveBeenCalled() + resolveSubmit({ ok: true }) + await expect(submitPromise).resolves.toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts new file mode 100644 index 00000000000..16041ead2b0 --- /dev/null +++ b/src/main/ipc/crash-reporting.ts @@ -0,0 +1,121 @@ +import { clipboard, ipcMain } from 'electron' +import { + formatCrashReportText, + type CrashReportSubmitArgs, + type CrashReportSubmitResult +} from '../../shared/crash-reporting' +import { submitFeedback } from './feedback' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' + +const inFlightSubmissions = new Set() +const uploadedReportIds = new Set() + +async function getLatestPendingReport( + store: CrashReportStore +): Promise>> { + const reports = await store.listRecent() + return ( + reports.find((report) => report.status === 'pending' && !uploadedReportIds.has(report.id)) ?? + null + ) +} + +export function registerCrashReportingHandlers(store: CrashReportStore): void { + ipcMain.removeHandler('crashReports:getLatestPending') + ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) + + ipcMain.removeHandler('crashReports:dismiss') + ipcMain.handle('crashReports:dismiss', async (_event, args: { reportId: string }) => { + if (inFlightSubmissions.has(args.reportId)) { + return store.getById(args.reportId) + } + if (uploadedReportIds.has(args.reportId)) { + const report = await store.getById(args.reportId) + return report ? { ...report, status: 'sent' as const } : null + } + return store.dismiss(args.reportId) + }) + + ipcMain.removeHandler('crashReports:copyLatestDiagnostics') + ipcMain.handle( + 'crashReports:copyLatestDiagnostics', + async (_event, args?: { reportId?: string; notes?: string }) => { + const report = args?.reportId + ? await store.getById(args.reportId) + : await getLatestPendingReport(store) + if (!report) { + return { ok: false as const, error: 'No crash report available.' } + } + clipboard.writeText(formatCrashReportText(report, args?.notes)) + return { ok: true as const } + } + ) + + ipcMain.removeHandler('crashReports:submit') + ipcMain.handle( + 'crashReports:submit', + async (_event, args: CrashReportSubmitArgs): Promise => { + const report = args.reportId + ? await store.getById(args.reportId) + : await getLatestPendingReport(store) + if (!report) { + return { ok: false, status: null, error: 'No crash report available.' } + } + const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed') + if ( + (!canSubmitDismissedReport && report.status !== 'pending') || + uploadedReportIds.has(report.id) + ) { + return { + ok: true, + report: uploadedReportIds.has(report.id) ? { ...report, status: 'sent' } : report + } + } + if (inFlightSubmissions.has(report.id)) { + return { + ok: false, + status: null, + error: 'Crash report submission already in progress.', + report + } + } + + inFlightSubmissions.add(report.id) + try { + const result = await submitFeedback({ + feedback: formatCrashReportText(report, args.notes), + submitAnonymously: args.submitAnonymously, + githubLogin: args.githubLogin, + githubEmail: args.githubEmail + }) + if (!result.ok) { + return { ...result, report } + } + uploadedReportIds.add(report.id) + if (report.status === 'dismissed') { + try { + // Why: startup prompts are dismissed before the user can send from + // the still-open dialog, so successful uploads must update storage. + const sent = await store.markDismissedSent(report.id) + return { ok: true, report: sent ?? { ...report, status: 'sent' } } + } catch (error) { + console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error) + return { ok: true, report: { ...report, status: 'sent' } } + } + } + try { + const sent = await store.markSent(report.id) + return { ok: true, report: sent ?? { ...report, status: 'sent' } } + } catch (error) { + // Why: the upstream submission already succeeded. A local persistence + // failure must not present as upload failure or invite duplicate sends + // during this app session. + console.error('[crash-reporting] Failed to mark crash report sent:', error) + return { ok: true, report: { ...report, status: 'sent' } } + } + } finally { + inFlightSubmissions.delete(report.id) + } + } + ) +} diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts new file mode 100644 index 00000000000..427b8ee39f1 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock } = vi.hoisted(() => ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' +import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' + +type HandlerMap = Record Promise | unknown> + +describe('local filesystem watcher unsubscribe cleanup', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + vi.mocked(stat).mockReset() + vi.mocked(subscribeParcelWatcher).mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('awaits an unsubscribe already started by sender cleanup during shutdown', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + destroyedCallbacks[0]() + + let shutdownResolved = false + const shutdownPromise = closeAllWatchers().then(() => { + shutdownResolved = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(shutdownResolved).toBe(false) + + resolveUnsubscribe() + await shutdownPromise + expect(shutdownResolved).toBe(true) + }) + + it('awaits an unsubscribe already started by watcher error cleanup during shutdown', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: (err: Error | null, events: []) => void = () => {} + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: unsubscribeMock } as never + }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + watcherCallback(new Error('root disappeared'), []) + + let shutdownResolved = false + const shutdownPromise = closeAllWatchers().then(() => { + shutdownResolved = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(shutdownResolved).toBe(false) + + resolveUnsubscribe() + await shutdownPromise + expect(shutdownResolved).toBe(true) + }) +}) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 4fcd2462fd4..d7496128a3b 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -194,4 +194,158 @@ describe('registerFilesystemWatcherHandlers', () => { ) expect(unwatchMock).toHaveBeenCalledTimes(1) }) + + it('dedupes concurrent pending SSH worktree watcher installs', async () => { + const sendOne = vi.fn() + const sendTwo = vi.fn() + const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const unwatchMock = vi.fn() + let resolveWatch: (unwatch: () => void) => void = () => {} + const watchPromise = new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + const watchMock = vi.fn().mockReturnValue(watchPromise) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + const firstWatch = handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) as Promise + const secondWatch = handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + resolveWatch(unwatchMock) + await Promise.all([firstWatch, secondWatch]) + + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendOne).toHaveBeenCalledTimes(1) + expect(sendTwo).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']( + { sender: { id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + handlers['fs:unwatchWorktree']( + { sender: { id: 2 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(unwatchMock).toHaveBeenCalledTimes(1) + }) + + it('keeps a pending SSH watcher install alive when only one pending sender unwatches', async () => { + const sendOne = vi.fn() + const sendTwo = vi.fn() + const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const unwatchMock = vi.fn() + let resolveWatch: (unwatch: () => void) => void = () => {} + const watchPromise = new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + const watchMock = vi.fn().mockReturnValue(watchPromise) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + const firstWatch = handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) as Promise + const secondWatch = handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']( + { sender: { id: 2 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + resolveWatch(unwatchMock) + await Promise.all([firstWatch, secondWatch]) + + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendOne).toHaveBeenCalledTimes(1) + expect(sendTwo).not.toHaveBeenCalled() + + handlers['fs:unwatchWorktree']( + { sender: { id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(unwatchMock).toHaveBeenCalledTimes(1) + }) + + it('revives a pending SSH watcher install when a new sender joins after cancellation', async () => { + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const unwatchMock = vi.fn() + let resolveWatch!: (unwatch: () => void) => void + const watchMock = vi.fn().mockReturnValue( + new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + const firstWatch = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + const secondWatch = handlers['fs:watchWorktree']( + { sender: senderTwo }, + args + ) as Promise + + expect(watchMock).toHaveBeenCalledTimes(1) + resolveWatch(unwatchMock) + await Promise.all([firstWatch, secondWatch]) + + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(senderOne.send).not.toHaveBeenCalled() + expect(senderTwo.send).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + expect(unwatchMock).toHaveBeenCalledTimes(1) + }) + + it('registers one destroyed listener for many SSH worktree watches', async () => { + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 99 + } + const unwatchMock = vi.fn() + const watchMock = vi.fn().mockResolvedValue(unwatchMock) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + for (let i = 0; i < 12; i += 1) { + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: `/home/me/repo-${i}`, connectionId: 'conn-1' } + ) + } + + // Why: WebContents warns after 10 listeners. The cleanup work still covers + // every remote watch by scanning the shared remote watcher registry. + expect(sender.once).toHaveBeenCalledTimes(1) + expect(destroyedCallbacks).toHaveLength(1) + + destroyedCallbacks[0]() + + expect(unwatchMock).toHaveBeenCalledTimes(12) + }) }) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 1555b859592..afaeb9a8d4a 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -50,10 +50,9 @@ const watchedRoots = new Map() // repeated "Failed to read changes" / "watchman not found" errors. const unwatchableRoots = new Set() -// Why: the `destroyed` listener was previously registered per-root on the -// same WebContents. With 11+ worktrees, this exceeded Node's default -// MaxListeners of 10. Track which senders already have a single cleanup -// listener so we register exactly once per sender. +// Why: watcher cleanup is keyed to the renderer WebContents, not to a specific +// watched root. One listener per sender avoids MaxListeners warnings when a +// workspace has many local and SSH-backed worktrees open. const senderCleanupRegistered = new Set() // Why: on Windows, tearing down and recreating @parcel/watcher subscriptions @@ -63,6 +62,9 @@ const senderCleanupRegistered = new Set() // Key: rootKey, Value: pending teardown timer. const WATCHER_TEARDOWN_GRACE_MS = 30_000 const pendingTeardowns = new Map>() +// Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy +// cleanup can start it before app shutdown, so will-quit must still await it. +const pendingLocalUnsubscribes = new Set>() // ── Path normalization ─────────────────────────────────────────────── @@ -276,9 +278,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { - // Already errored — ignore cleanup failures - }) + void trackLocalUnsubscribe(rootKey, root) } errorCleanedUp = true watchedRoots.delete(rootKey) @@ -296,7 +296,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise {}) + void trackLocalUnsubscribe(rootKey, root) throw new Error(`Watcher for ${rootKey} errored during subscribe`) } } catch (err) { @@ -311,6 +311,52 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { + const unsubscribePromise = Promise.resolve() + .then(() => root.subscription.unsubscribe()) + .catch((err: unknown) => { + console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) + }) + .finally(() => { + pendingLocalUnsubscribes.delete(unsubscribePromise) + }) + pendingLocalUnsubscribes.add(unsubscribePromise) + return unsubscribePromise +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + sender.once('destroyed', () => { + senderCleanupRegistered.delete(sender.id) + cleanupLocalWatchersForSender(sender.id) + cleanupRemoteWatchersForSender(sender.id) + }) +} + async function subscribe(worktreePath: string, sender: WebContents): Promise { const rootKey = normalizeRootPath(worktreePath) @@ -364,38 +410,7 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise { - senderCleanupRegistered.delete(sender.id) - for (const [key, watchedRoot] of watchedRoots) { - if (watchedRoot.listeners.has(sender.id)) { - watchedRoot.listeners.delete(sender.id) - if (watchedRoot.listeners.size === 0) { - // Cancel any pending grace-period teardown for this root. - const pending = pendingTeardowns.get(key) - if (pending) { - clearTimeout(pending) - pendingTeardowns.delete(key) - } - if (watchedRoot.batch.timer) { - clearTimeout(watchedRoot.batch.timer) - } - void watchedRoot.subscription.unsubscribe().catch((err: unknown) => { - console.error(`[filesystem-watcher] unsubscribe error for ${key}:`, err) - }) - watchedRoots.delete(key) - } - } - } - }) - } + registerSenderCleanup(sender) } function unsubscribe(worktreePath: string, senderId: number): void { @@ -421,9 +436,7 @@ function unsubscribe(worktreePath: string, senderId: number): void { if (!currentRoot || currentRoot.listeners.size > 0) { return } - void currentRoot.subscription.unsubscribe().catch((err: unknown) => { - console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) - }) + void trackLocalUnsubscribe(rootKey, currentRoot) watchedRoots.delete(rootKey) }, WATCHER_TEARDOWN_GRACE_MS) @@ -437,6 +450,11 @@ type RemoteWatcherState = { listeners: Map } +type RemoteWatcherInstallToken = { + cancelled: boolean + listeners: Map +} + // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() const loggedUnavailableRemoteWatchers = new Set() @@ -445,7 +463,12 @@ const pendingRemoteWatcherRetries = new Map() +const inFlightRemoteInstalls = new Map() +// Why: dedupe concurrent installRemoteWatcher calls for the same key so +// overlapping fs:watchWorktree IPCs share one native watcher and one listener +// map, instead of each call independently invoking provider.watch() and +// overwriting the per-key state on resolution. +const pendingRemoteInstallPromises = new Map>() const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -455,9 +478,7 @@ function addRemoteWatchListener(key: string, sender: WebContents): void { return } state.listeners.set(sender.id, sender) - sender.once('destroyed', () => { - releaseRemoteWatchListener(key, sender.id) - }) + registerSenderCleanup(sender) } function releaseRemoteWatchListener(key: string, senderId: number): void { @@ -473,6 +494,12 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { remoteWatchers.delete(key) } +function cleanupRemoteWatchersForSender(senderId: number): void { + for (const key of Array.from(remoteWatchers.keys())) { + releaseRemoteWatchListener(key, senderId) + } +} + type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' async function installRemoteWatcher( @@ -491,8 +518,54 @@ async function installRemoteWatcher( addRemoteWatchListener(key, sender) return 'installed' } - const cancelToken = { cancelled: false } + // Why: a second concurrent fs:watchWorktree for the same key must share the + // first call's provider.watch() instead of starting its own. Without this, + // both calls would create distinct native watchers and the second's resolve + // would overwrite the per-key state, dropping the first's unwatch handle + // and erasing its sender from the listener map. + const pendingInstall = pendingRemoteInstallPromises.get(key) + if (pendingInstall) { + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight && !sender.isDestroyed()) { + inFlight.listeners.set(sender.id, sender) + // Why: a new watcher can join after all previous pending listeners + // unwatched but before provider.watch() resolves; revive that install + // instead of inheriting the stale cancellation. + inFlight.cancelled = false + } + const result = await pendingInstall + if ( + result === 'installed' && + remoteWatchers.has(key) && + !sender.isDestroyed() && + (!inFlight || inFlight.listeners.has(sender.id)) + ) { + addRemoteWatchListener(key, sender) + } + return result + } + const cancelToken: RemoteWatcherInstallToken = { + cancelled: false, + listeners: sender.isDestroyed() ? new Map() : new Map([[sender.id, sender]]) + } inFlightRemoteInstalls.set(key, cancelToken) + const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) + pendingRemoteInstallPromises.set(key, installPromise) + try { + return await installPromise + } finally { + if (pendingRemoteInstallPromises.get(key) === installPromise) { + pendingRemoteInstallPromises.delete(key) + } + } +} + +async function doInstallRemoteWatcher( + provider: NonNullable>, + key: string, + worktreePath: string, + cancelToken: RemoteWatcherInstallToken +): Promise { let unwatch: () => void try { unwatch = await provider.watch(worktreePath, (events) => { @@ -515,7 +588,10 @@ async function installRemoteWatcher( inFlightRemoteInstalls.delete(key) } } - if (cancelToken.cancelled || sender.isDestroyed()) { + const liveListeners = new Map( + Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed()) + ) + if (cancelToken.cancelled || liveListeners.size === 0) { try { unwatch() } catch (err) { @@ -523,8 +599,10 @@ async function installRemoteWatcher( } return 'cancelled' } - remoteWatchers.set(key, { unwatch, listeners: new Map() }) - addRemoteWatchListener(key, sender) + remoteWatchers.set(key, { unwatch, listeners: liveListeners }) + for (const listener of liveListeners.values()) { + registerSenderCleanup(listener) + } loggedUnavailableRemoteWatchers.delete(key) return 'installed' } @@ -623,7 +701,8 @@ export function registerFilesystemWatcherHandlers(): void { // of leaving the renderer with a watcher it asked to stop. const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { - inFlight.cancelled = true + inFlight.listeners.delete(_event.sender.id) + inFlight.cancelled = inFlight.listeners.size === 0 } loggedUnavailableRemoteWatchers.delete(key) releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) @@ -637,6 +716,8 @@ export function registerFilesystemWatcherHandlers(): void { /** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { + senderCleanupRegistered.clear() + // Cancel any pending grace-period teardowns — we're tearing down everything. for (const timer of pendingTeardowns.values()) { clearTimeout(timer) @@ -658,13 +739,10 @@ export async function closeAllWatchers(): Promise { if (root.batch.timer) { clearTimeout(root.batch.timer) } - try { - await root.subscription.unsubscribe() - } catch (err) { - console.error(`[filesystem-watcher] shutdown unsubscribe error for ${rootKey}:`, err) - } + await trackLocalUnsubscribe(rootKey, root) } watchedRoots.clear() + await Promise.allSettled(Array.from(pendingLocalUnsubscribes)) // Why: remote watchers are tracked separately from local @parcel/watcher // subscriptions. Without cleaning them up here, their unwatch callbacks diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 5196bdb475b..b9c12aea63d 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -25,6 +25,7 @@ const { bulkUnstageFilesMock, bulkDiscardChangesMock, discardChangesMock, + checkIgnoredPathsMock, listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, @@ -53,6 +54,7 @@ const { bulkUnstageFilesMock: vi.fn(), bulkDiscardChangesMock: vi.fn(), discardChangesMock: vi.fn(), + checkIgnoredPathsMock: vi.fn(), listWorktreesMock: vi.fn(), resolveCommitMessageSettingsMock: vi.fn(), generateCommitMessageFromContextMock: vi.fn(), @@ -95,6 +97,10 @@ vi.mock('../git/status', () => ({ discardChanges: discardChangesMock })) +vi.mock('../git/check-ignored-paths', () => ({ + checkIgnoredPaths: checkIgnoredPathsMock +})) + vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock })) @@ -470,7 +476,64 @@ describe('registerFilesystemHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) - expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false }) + }) + + it('forwards includeIgnored through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + includeIgnored: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + includeIgnored: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: true }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) + }) + + it('checks ignored paths through local and SSH git providers', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) + const sshProvider = { + checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js']) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:checkIgnored')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + paths: ['dist/bundle.js', 'src/index.ts'] + }) + ).resolves.toEqual(['dist/bundle.js']) + await expect( + handlers.get('git:checkIgnored')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + paths: ['build/output.js'] + }) + ).resolves.toEqual(['build/output.js']) + + expect(checkIgnoredPathsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [ + path.join('dist', 'bundle.js'), + path.join('src', 'index.ts') + ]) + expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [ + path.join('build', 'output.js') + ]) }) it('rejects git file paths that escape the selected worktree', async () => { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f35adbca549..6dafcd3fcfc 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -3,12 +3,13 @@ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' import { extname, join } from 'path' import type { ChildProcess } from 'child_process' -import { wslAwareSpawn } from '../git/runner' +import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import type { Store } from '../persistence' import type { DirEntry, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -18,6 +19,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildRgArgs, createAccumulator, @@ -39,16 +41,24 @@ import { discardChanges, getStagedCommitContext, getBranchCompare, - getBranchDiff + getBranchDiff, + getCommitCompare, + getCommitDiff } from '../git/status' +import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, - type GenerateCommitMessageResult + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' +import { checkIgnoredPaths } from '../git/check-ignored-paths' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { validateGitPushTarget } from '../git/push-target-validation' import { getRemoteFileUrl } from '../git/repo' @@ -66,13 +76,16 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './mar import { checkRgAvailable } from './rg-availability' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { + prepareLocalCommitMessageAgentEnv, + type CommitMessageAgentEnvironmentResolvers +} from '../text-generation/commit-message-agent-environment' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB const BINARY_PROBE_BYTES = 8192 +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ // Why: previewable binaries (PDFs, images) are rendered by the viewer as // base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and // raising this cap only affects binary preview, not text/search paths. @@ -93,54 +106,11 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } -export type CommitMessageAgentEnvironmentResolvers = { - prepareForCodexLaunch?: () => string | null - prepareForClaudeLaunch?: () => Promise -} - -function cloneProcessEnv(): Record { - const env: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - env[key] = value - } +function validateFullGitObjectId(value: string, label: string): string { + if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) } - return env -} - -async function prepareLocalCommitMessageAgentEnv( - agentId: string, - resolvers: CommitMessageAgentEnvironmentResolvers | undefined -): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { - if (!resolvers) { - return { ok: true } - } - - try { - if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { - const codexHomePath = resolvers.prepareForCodexLaunch() - return { - ok: true, - env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined - } - } - - if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { - const preparation = await resolvers.prepareForClaudeLaunch() - const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { - stripAuthEnv: preparation.stripAuthEnv - }) - return { ok: true, env } - } - } catch (error) { - console.error('[filesystem] Failed to prepare commit message agent environment:', error) - return { - ok: false, - error: 'Failed to prepare the selected agent account for commit message generation.' - } - } - - return { ok: true } + return value } /** @@ -532,17 +502,57 @@ export function registerFilesystemHandlers( 'git:status', async ( _event, - args: { worktreePath: string; connectionId?: string } + args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean } ): Promise => { + const options = { includeIgnored: args.includeIgnored ?? false } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } - return provider.getStatus(args.worktreePath) + return provider.getStatus(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getStatus(worktreePath) + return getStatus(worktreePath, options) + } + ) + + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + return checkIgnoredPaths(worktreePath, paths) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return getHistory(worktreePath, options) } ) @@ -706,6 +716,96 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + } + ): Promise => { + const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + const context = await getPullRequestDraftContext( + (argv) => provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const context = await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: worktreePath, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath) + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) + ipcMain.handle( 'git:branchCompare', async ( @@ -724,6 +824,25 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return getCommitCompare(worktreePath, commitId) + } + ) + ipcMain.handle( 'git:upstreamStatus', async ( @@ -856,6 +975,47 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + return getCommitDiff(worktreePath, { + commitOid, + parentOid, + filePath, + oldPath + }) + } + ) + ipcMain.handle( 'git:stage', async ( diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index ddaa07da8ea..e36c8f767c8 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -25,6 +25,7 @@ import { getPRChecks, getPRComments, resolveReviewThread, + setPRFileViewed, addPRReviewComment, addPRReviewCommentReply, updatePRTitle, @@ -316,6 +317,47 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRFileViewed', + async ( + event, + args: { + repoPath: string + repoId?: string + prNumber: number + pullRequestId: string + path: string + viewed: boolean + } + ) => { + const repo = assertRegisteredRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return false + } + if (!args.pullRequestId?.trim() || !args.path?.trim()) { + return false + } + const ok = await setPRFileViewed({ + repoPath: repo.path, + connectionId: repoConnectionId(repo), + pullRequestId: args.pullRequestId.trim(), + path: args.path, + viewed: Boolean(args.viewed) + }) + if (ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok + } + ) + ipcMain.handle( 'gh:addPRReviewCommentReply', async ( diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index f19276e59b3..d972334bf15 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -13,6 +13,8 @@ import { getHostedReviewCreationEligibility } from '../source-control/hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' +import { resolveRegisteredWorktreePath } from './filesystem-auth' +import { listRepoWorktrees } from '../repo-worktrees' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -30,6 +32,22 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } +async function resolveHostedReviewWorktreePath( + repo: Repo, + store: Store, + worktreePath?: string +): Promise { + if (!worktreePath) { + return repo.path + } + const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + const repoWorktrees = await listRepoWorktrees(repo) + if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { + throw new Error('Access denied: worktree does not belong to repository') + } + return resolvedWorktreePath +} + export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) @@ -40,6 +58,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector linkedGitHubPR: args.linkedGitHubPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { @@ -70,7 +89,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector body: null } } - return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path }) + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath }) } ) @@ -83,7 +103,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector error: 'Creating pull requests from SSH worktrees is not supported yet.' } } - const result = await createHostedReview(repo.path, { + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const result = await createHostedReview(worktreePath, { provider: args.provider, base: args.base, head: args.head, diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 585b8aaf1fd..dc78e888f51 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock } = vi.hoisted(() => ({ - handleMock: vi.fn() +const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + networkInterfacesMock: vi.fn() })) vi.mock('electron', () => ({ @@ -14,6 +15,10 @@ vi.mock('qrcode', () => ({ } })) +vi.mock('os', () => ({ + networkInterfaces: networkInterfacesMock +})) + import { registerMobileHandlers } from './mobile' describe('registerMobileHandlers', () => { @@ -22,11 +27,36 @@ describe('registerMobileHandlers', () => { beforeEach(() => { handlers.clear() handleMock.mockReset() + networkInterfacesMock.mockReset() + networkInterfacesMock.mockReturnValue({}) handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler) }) }) + it('re-reads system network interfaces on each request', () => { + networkInterfacesMock + .mockReturnValueOnce({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + .mockReturnValueOnce({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }], + tailscale0: [{ family: 'IPv4', internal: false, address: '100.64.1.20' }] + }) + + registerMobileHandlers({} as never) + + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [{ name: 'en0', address: '192.168.1.24' }] + }) + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'en0', address: '192.168.1.24' }, + { name: 'tailscale0', address: '100.64.1.20' } + ] + }) + }) + it('lists only paired mobile-scoped devices', () => { const rpcServer = { getDeviceRegistry: () => ({ diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts new file mode 100644 index 00000000000..60b059257fc --- /dev/null +++ b/src/main/ipc/notification-options.ts @@ -0,0 +1,157 @@ +import type { NotificationDispatchRequest } from '../../shared/types' + +const NOTIFICATION_AGENT_LABEL_MAX_LENGTH = 40 +const NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH = 80 +const NOTIFICATION_BODY_PREVIEW_MAX_LENGTH = 180 + +const AGENT_TYPE_LABELS: Readonly> = { + claude: 'Claude', + codex: 'Codex', + gemini: 'Gemini', + opencode: 'OpenCode', + cursor: 'Cursor', + aider: 'Aider', + pi: 'Pi', + droid: 'Droid', + grok: 'Grok', + hermes: 'Hermes' +} + +export function buildNotificationOptions(args: NotificationDispatchRequest): { + title: string + body: string + silent?: boolean +} { + if (args.source === 'terminal-bell') { + return { + title: `Bell in ${args.worktreeLabel ?? 'workspace'}`, + body: args.repoLabel ? `${args.repoLabel} · Attention requested` : 'Attention requested' + } + } + + if (args.source === 'test') { + return { + title: 'Orca notifications are on', + body: 'This is a test notification from Orca.' + } + } + + const richOptions = buildAgentTaskCompleteNotificationOptions(args) + if (richOptions) { + return richOptions + } + + return buildAgentTaskCompleteFallbackNotificationOptions(args) +} + +function buildAgentTaskCompleteNotificationOptions( + args: NotificationDispatchRequest +): { title: string; body: string } | null { + if (!hasAgentNotificationSnapshot(args)) { + return null + } + + const agentLabel = formatNotificationAgentLabel(args.agentType) + const worktreeContext = formatNotificationWorktreeContext(args) + const statusText = + args.agentState === 'blocked' || args.agentState === 'waiting' + ? 'needs input' + : args.agentState === 'done' && args.agentInterrupted + ? 'stopped' + : 'finished' + + return { + title: `${worktreeContext} - ${agentLabel} ${statusText}`, + body: buildAgentTaskCompleteRichBody(args) ?? `${agentLabel} ${statusText}.` + } +} + +function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string { + const worktreeLabel = normalizeNotificationText( + args.worktreeLabel, + NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH + ) + const repoLabel = normalizeNotificationText(args.repoLabel, NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH) + if (args.hasMultipleActiveRepos && repoLabel && worktreeLabel) { + return normalizeNotificationText( + `${repoLabel} / ${worktreeLabel}`, + NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH + ) + } + return worktreeLabel || repoLabel || 'workspace' +} + +function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolean { + return Boolean( + args.agentType || + args.agentState || + args.agentPrompt || + args.agentToolName || + args.agentToolInput || + args.agentLastAssistantMessage || + args.agentInterrupted + ) +} + +function buildAgentTaskCompleteRichBody(args: NotificationDispatchRequest): string | null { + const assistantMessage = normalizeNotificationText( + args.agentLastAssistantMessage, + NOTIFICATION_BODY_PREVIEW_MAX_LENGTH + ) + if (assistantMessage) { + return assistantMessage + } + + const toolName = normalizeNotificationText(args.agentToolName, 60) + const toolInput = normalizeNotificationText( + args.agentToolInput, + NOTIFICATION_BODY_PREVIEW_MAX_LENGTH + ) + if (toolName && toolInput) { + return `Using ${toolName}: ${toolInput}` + } + if (toolName) { + return `Using ${toolName}` + } + if (toolInput) { + return `Tool input: ${toolInput}` + } + + return null +} + +function buildAgentTaskCompleteFallbackNotificationOptions(args: NotificationDispatchRequest): { + title: string + body: string +} { + return { + title: `Task complete in ${args.worktreeLabel ?? 'workspace'}`, + body: buildAgentTaskCompleteFallbackBody(args) + } +} + +function buildAgentTaskCompleteFallbackBody(args: NotificationDispatchRequest): string { + return args.repoLabel + ? `${args.repoLabel}${args.terminalTitle ? ` · ${args.terminalTitle}` : ''}` + : (args.terminalTitle ?? 'A coding agent finished working.') +} + +function formatNotificationAgentLabel(agentType: string | null | undefined): string { + const normalized = normalizeNotificationText(agentType, NOTIFICATION_AGENT_LABEL_MAX_LENGTH) + if (!normalized || normalized === 'unknown') { + return 'Agent' + } + return AGENT_TYPE_LABELS[normalized] ?? normalized +} + +function normalizeNotificationText(value: string | null | undefined, maxLength: number): string { + const normalized = value?.replace(/\s+/g, ' ').trim() ?? '' + if (normalized.length <= maxLength) { + return normalized + } + const truncated = normalized.slice(0, maxLength - 1) + const lastCode = truncated.charCodeAt(truncated.length - 1) + const safeTruncated = + lastCode >= 0xd800 && lastCode <= 0xdbff ? truncated.slice(0, -1) : truncated + return `${safeTruncated}…` +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 2d1e6c39188..85087fd387a 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -199,6 +199,282 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) + it('formats agent-task-complete with the agent response when a status snapshot is present', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + repoLabel: 'orca', + terminalTitle: '* Claude done', + agentType: 'codex', + agentState: 'done', + agentPrompt: 'Fix rich notification text', + agentLastAssistantMessage: 'Updated the notification body.' + } + ) + ).toEqual({ delivered: true }) + + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'feat/notis - Codex finished', + body: 'Updated the notification body.' + }) + }) + + it('includes the repo name when multiple repos are active', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + repoLabel: 'orca', + hasMultipleActiveRepos: true, + agentType: 'codex', + agentState: 'done', + agentLastAssistantMessage: 'Updated the notification body.' + } + ) + ).toEqual({ delivered: true }) + + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'orca / feat/notis - Codex finished', + body: 'Updated the notification body.' + }) + }) + + it('keeps a readable body when no assistant response was captured', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'main', + repoLabel: 'jinjing-work', + hasMultipleActiveRepos: true, + agentType: 'claude', + agentState: 'done', + agentPrompt: 'Do not show this request text' + } + ) + ).toEqual({ delivered: true }) + + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'jinjing-work / main - Claude finished', + body: 'Claude finished.' + }) + }) + + it('formats blocked and interrupted agent snapshots distinctly', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: false + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + agentType: 'claude', + agentState: 'blocked', + agentLastAssistantMessage: 'Please approve the command.' + } + ) + ).toEqual({ delivered: true }) + vi.advanceTimersByTime(5001) + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + agentType: 'claude', + agentState: 'done', + agentInterrupted: true, + agentLastAssistantMessage: 'Stopped by user.' + } + ) + ).toEqual({ delivered: true }) + + expect(notificationCtorMock).toHaveBeenNthCalledWith(1, { + title: 'feat/notis - Claude needs input', + body: 'Please approve the command.' + }) + expect(notificationCtorMock).toHaveBeenNthCalledWith(2, { + title: 'feat/notis - Claude stopped', + body: 'Stopped by user.' + }) + }) + + it('normalizes custom agent labels and re-bounds multiline assistant previews', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: false + } + }) + } as never) + + const longAssistantMessage = `Line one\n\n${'x'.repeat(400)}` + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + agentType: 'builder\nagent', + agentState: 'done', + agentLastAssistantMessage: longAssistantMessage + } + ) + ).toEqual({ delivered: true }) + + const options = ( + notificationCtorMock.mock.calls as unknown as [{ title: string; body: string }][] + )[0]?.[0] + if (!options) { + throw new Error('Expected notification options') + } + expect(options).toMatchObject({ + title: 'feat/notis - builder agent finished' + }) + expect(options.body).toMatch(/^Line one x+/) + expect(options.body).not.toContain('\n') + expect(options.body.length).toBeLessThanOrEqual(180) + }) + + it('uses tool context before falling back when no prompt or assistant preview exists', () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + agentType: 'unknown', + agentState: 'working', + agentToolName: 'Bash', + agentToolInput: 'pnpm test' + } + ) + ).toEqual({ delivered: true }) + + expect(notificationCtorMock).toHaveBeenCalledWith({ + title: 'feat/notis - Agent finished', + body: 'Using Bash: pnpm test' + }) + }) + + it('uses rich formatter output for mobile notifications before desktop guards', () => { + notificationIsSupportedMock.mockReturnValue(false) + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { + source: 'agent-task-complete', + worktreeId: 'repo::wt1', + worktreeLabel: 'feat/notis', + agentType: 'hermes', + agentState: 'done', + agentPrompt: 'Summarize the diff', + agentLastAssistantMessage: 'The diff updates notification formatting.' + } + ) + ).toEqual({ delivered: false, reason: 'not-supported' }) + + expect(dispatchMobileNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + title: 'feat/notis - Hermes finished', + body: 'The diff updates notification formatting.', + worktreeId: 'repo::wt1' + }) + expect(notificationCtorMock).not.toHaveBeenCalled() + }) + it('silences the native notification when a custom sound is configured', () => { registerNotificationHandlers({ getSettings: () => ({ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 775130af915..544859c3bb8 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -10,6 +10,7 @@ import type { } from '../../shared/types' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { buildNotificationOptions } from './notification-options' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 @@ -302,30 +303,3 @@ export function triggerStartupNotificationRegistration(store: Store): void { notification.show() } - -function buildNotificationOptions(args: NotificationDispatchRequest): { - title: string - body: string - silent?: boolean -} { - if (args.source === 'terminal-bell') { - return { - title: `Bell in ${args.worktreeLabel ?? 'workspace'}`, - body: args.repoLabel ? `${args.repoLabel} · Attention requested` : 'Attention requested' - } - } - - if (args.source === 'test') { - return { - title: 'Orca notifications are on', - body: 'This is a test notification from Orca.' - } - } - - return { - title: `Task complete in ${args.worktreeLabel ?? 'workspace'}`, - body: args.repoLabel - ? `${args.repoLabel}${args.terminalTitle ? ` · ${args.terminalTitle}` : ''}` - : (args.terminalTitle ?? 'A coding agent finished working.') - } -} diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts new file mode 100644 index 00000000000..180403f370b --- /dev/null +++ b/src/main/ipc/opencode-usage.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../shared/opencode-usage-types' + +export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { + ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) + ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => + openCodeUsage.setEnabled(args.enabled) + ) + ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => + openCodeUsage.refresh(args?.force ?? false) + ) + ipcMain.handle( + 'openCodeUsage:getSummary', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getSummary(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getDaily', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getBreakdown', + ( + _event, + args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + } + ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + 'openCodeUsage:getRecentSessions', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => + openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) + ) +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index fefc0d0ad3d..bae8d145b5a 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -9,6 +9,7 @@ const { hydrateShellPathMock, mergePathSegmentsMock, getBitbucketAuthStatusMock, + getAzureDevOpsAuthStatusMock, getGiteaAuthStatusMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,6 +18,7 @@ const { hydrateShellPathMock: vi.fn(), mergePathSegmentsMock: vi.fn(), getBitbucketAuthStatusMock: vi.fn(), + getAzureDevOpsAuthStatusMock: vi.fn(), getGiteaAuthStatusMock: vi.fn() })) @@ -45,6 +47,10 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketAuthStatus: getBitbucketAuthStatusMock })) +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsAuthStatus: getAzureDevOpsAuthStatusMock +})) + vi.mock('../gitea/client', () => ({ getGiteaAuthStatus: getGiteaAuthStatusMock })) @@ -61,6 +67,13 @@ type HandlerMap = Record { const handlers: HandlerMap = {} const defaultBitbucketStatus = { configured: false, authenticated: false, account: null } + const defaultAzureDevOpsStatus = { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } const defaultGiteaStatus = { configured: false, authenticated: false, @@ -75,8 +88,10 @@ describe('preflight', () => { hydrateShellPathMock.mockReset() mergePathSegmentsMock.mockReset() getBitbucketAuthStatusMock.mockReset() + getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() getBitbucketAuthStatusMock.mockResolvedValue(defaultBitbucketStatus) + getAzureDevOpsAuthStatusMock.mockResolvedValue(defaultAzureDevOpsStatus) getGiteaAuthStatusMock.mockResolvedValue(defaultGiteaStatus) _resetPreflightCache() @@ -107,6 +122,7 @@ describe('preflight', () => { gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, bitbucket: defaultBitbucketStatus, + azureDevOps: defaultAzureDevOpsStatus, gitea: defaultGiteaStatus }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], { @@ -209,6 +225,7 @@ describe('preflight', () => { gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, bitbucket: defaultBitbucketStatus, + azureDevOps: defaultAzureDevOpsStatus, gitea: defaultGiteaStatus }) }) @@ -236,6 +253,7 @@ describe('preflight', () => { gh: { installed: true, authenticated: false }, glab: { installed: true, authenticated: true }, bitbucket: defaultBitbucketStatus, + azureDevOps: defaultAzureDevOpsStatus, gitea: defaultGiteaStatus }) expect(refreshedStatus).toEqual({ @@ -243,6 +261,7 @@ describe('preflight', () => { gh: { installed: true, authenticated: true }, glab: { installed: true, authenticated: true }, bitbucket: defaultBitbucketStatus, + azureDevOps: defaultAzureDevOpsStatus, gitea: defaultGiteaStatus }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index db3248091c3..101ae955fb6 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -5,8 +5,10 @@ import path from 'path' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getAzureDevOpsAuthStatus } from '../azure-devops/client' import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' +import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) @@ -20,6 +22,13 @@ export type PreflightStatus = { // gate on `glab?.authenticated`. glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } gitea?: { configured: boolean authenticated: boolean @@ -163,16 +172,27 @@ export async function runPreflightCheck(force = false): Promise return cached } + if (force) { + // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the + // first GitLab request and never invalidated within a session. A user who + // runs `glab auth login` for a self-hosted host after Orca starts would + // otherwise see "No GitLab project found" until app relaunch. The Re-check + // path in IntegrationsPane forces preflight, so piggyback on that signal + // to refresh the host list too. + _resetKnownHostsCache() + } + const [gitInstalled, ghInstalled, glabInstalled] = await Promise.all([ isCommandAvailable('git'), isCommandAvailable('gh'), isCommandAvailable('glab') ]) - const [ghAuthenticated, glabAuthenticated, bitbucket, gitea] = await Promise.all([ + const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ ghInstalled ? isGhAuthenticated() : Promise.resolve(false), glabInstalled ? isGlabAuthenticated() : Promise.resolve(false), getBitbucketAuthStatus(), + getAzureDevOpsAuthStatus(), getGiteaAuthStatus() ]) @@ -181,6 +201,7 @@ export async function runPreflightCheck(force = false): Promise gh: { installed: ghInstalled, authenticated: ghAuthenticated }, glab: { installed: glabInstalled, authenticated: glabAuthenticated }, bitbucket, + azureDevOps, gitea } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2cd81d45df2..f4ba75065a9 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2,6 +2,7 @@ one focused file because the registration helper is stateful and each spawn-path assertion reuses the same mocked IPC and node-pty harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { delimiter, join } from 'node:path' const { handleMock, @@ -21,6 +22,7 @@ const { openCodeClearPtyMock, buildAgentHookEnvMock, clearAgentHookPaneStateMock, + registerPaneKeyAliasMock, piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, @@ -30,7 +32,8 @@ const { unregisterPtyMock, setMigrationUnsupportedPtyMock, clearMigrationUnsupportedPtyMock, - clearMigrationUnsupportedPtysForPaneKeyMock + clearMigrationUnsupportedPtysForPaneKeyMock, + clearPaneKeyAliasesForPtyMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -50,6 +53,7 @@ const { openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), + registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), @@ -58,7 +62,8 @@ const { unregisterPtyMock: vi.fn(), setMigrationUnsupportedPtyMock: vi.fn(), clearMigrationUnsupportedPtyMock: vi.fn(), - clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn() + clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(), + clearPaneKeyAliasesForPtyMock: vi.fn() })) vi.mock('electron', () => ({ @@ -101,7 +106,9 @@ vi.mock('../opencode/hook-service', () => ({ vi.mock('../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, - clearPaneState: clearAgentHookPaneStateMock + clearPaneState: clearAgentHookPaneStateMock, + registerPaneKeyAlias: registerPaneKeyAliasMock, + clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock } })) @@ -206,6 +213,7 @@ describe('registerPtyHandlers', () => { openCodeClearPtyMock.mockReset() buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() + registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() @@ -216,6 +224,7 @@ describe('registerPtyHandlers', () => { setMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtysForPaneKeyMock.mockReset() + clearPaneKeyAliasesForPtyMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -772,7 +781,23 @@ describe('registerPtyHandlers', () => { try { const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }) expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') - expect(env.PATH).toContain('/tmp/orca-user-data/cli/bin') + expect(env.PATH).toContain(join('/tmp/orca-user-data', 'cli', 'bin')) + } finally { + mockedApp.isPackaged = prev + } + }) + + it('preserves the inherited PATH when dev-mode daemon env omits PATH', async () => { + const { app } = await import('electron') + const mockedApp = app as unknown as { isPackaged: boolean } + const prev = mockedApp.isPackaged + mockedApp.isPackaged = false + try { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { + PATH: '/system/bin' + }) + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.PATH).toBe(`${join('/tmp/orca-user-data', 'cli', 'bin')}${delimiter}/system/bin`) } finally { mockedApp.isPackaged = prev } @@ -1293,6 +1318,7 @@ describe('registerPtyHandlers', () => { try { expect(controller.kill('remote-pty')).toBe(true) await Promise.resolve() + await Promise.resolve() } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') @@ -1303,10 +1329,10 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) }) - it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when feature flag is off', async () => { + it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => { const sshSpawn = vi.fn(async (_opts: { env: Record }) => ({ id: 'ssh-pty' })) @@ -1335,7 +1361,7 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never) const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0' try { await handlers.get('pty:spawn')!(null, { cols: 80, @@ -1366,7 +1392,7 @@ describe('registerPtyHandlers', () => { } }) - it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH when feature flag is on', async () => { + it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH by default', async () => { const sshSpawn = vi.fn(async (_opts: { env: Record }) => ({ id: 'ssh-pty' })) @@ -1395,7 +1421,7 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never) const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS try { const leafId = '22222222-2222-4222-8222-222222222222' const paneKey = makePaneKey('tab-2', leafId) @@ -2326,9 +2352,10 @@ describe('registerPtyHandlers', () => { } }) - it('registers only validated stable pane keys in the local PTY memory registry', async () => { + it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => { registerPtyHandlers(mainWindow as never) const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2340,22 +2367,17 @@ describe('registerPtyHandlers', () => { expect(registerPtyMock).toHaveBeenLastCalledWith( expect.objectContaining({ - paneKey: null + paneKey: stablePaneKey }) ) - expect(setMigrationUnsupportedPtyMock).toHaveBeenCalledWith( - expect.objectContaining({ - ptyId: expect.any(String), - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId, - paneKey: makePaneKey('tab-1', leafId), - reason: 'legacy-numeric-pane-key', - source: 'local' - }) + expect(registerPaneKeyAliasMock).toHaveBeenCalledWith( + 'tab-1:0', + stablePaneKey, + expect.any(String) ) + expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey) + expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled() - const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2422,6 +2444,31 @@ describe('registerPtyHandlers', () => { expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey) }) + it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => { + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) + + const current = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + })) as { id: string } + + expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id) + clearPaneKeyAliasesForPtyMock.mockClear() + + clearProviderPtyState('old-pty-without-forward-pane-key') + + const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find( + ([ptyId]) => ptyId === 'old-pty-without-forward-pane-key' + )?.[1] + expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false) + }) + it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a0d4cc4c26e..8fd26117813 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -38,11 +38,15 @@ import { } from '../../shared/telemetry-events' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { readShellStartupEnvVar } from '../pty/shell-startup-env' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey, - setMigrationUnsupportedPty + clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' // ─── Provider Registry ────────────────────────────────────────────── @@ -119,28 +123,6 @@ function isValidPaneKey(paneKey: unknown): paneKey is string { return parseValidPaneKey(paneKey) !== null } -function parseLegacyNumericPaneKey( - paneKey: unknown -): { tabId: string; numericPaneId: string } | null { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - const trimmed = paneKey.trim() - const delimiter = trimmed.indexOf(':') - if ( - delimiter <= 0 || - delimiter !== trimmed.lastIndexOf(':') || - delimiter === trimmed.length - 1 - ) { - return null - } - const numericPaneId = trimmed.slice(delimiter + 1) - if (!/^\d+$/.test(numericPaneId)) { - return null - } - return { tabId: trimmed.slice(0, delimiter), numericPaneId } -} - function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' if (!isValidPaneKey(normalizedPaneKey)) { @@ -247,6 +229,10 @@ export type BuildPtyHostEnvOptions = { githubAttributionEnabled: boolean } +function readInheritedPath(baseEnv: Record): string { + return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? '' +} + /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. * @@ -355,11 +341,12 @@ export function buildPtyHostEnv( if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath const devCliBin = join(opts.userDataPath, 'cli', 'bin') + const inheritedPath = readInheritedPath(baseEnv) // Why: avoid a trailing delimiter when PATH is empty — some shells // treat an empty segment as `.`, which would let commands resolve from // the current working directory (a foot-gun we don't want to create // for dev terminals). - baseEnv.PATH = baseEnv.PATH ? `${devCliBin}${delimiter}${baseEnv.PATH}` : devCliBin + baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin } // Why: GitHub attribution should only affect commands launched from @@ -462,19 +449,31 @@ export function clearProviderPtyState(id: string): void { openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) ptySizes.delete(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias + // ownership is our only proof. Once a newer PTY owns the same stable + // paneKey, alias teardown must not erase that newer status. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to // correlate a ptyId back to its paneKey. - const paneKey = ptyPaneKey.get(id) if (paneKey) { - const stillOwnsPaneKey = paneKeyPtyId.get(paneKey) === id if (stillOwnsPaneKey) { agentHookServer.clearPaneState(paneKey) paneKeyPtyId.delete(paneKey) @@ -962,6 +961,11 @@ export function registerPtyHandlers( console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) + // Why: callers of controller.kill must observe a kill→exit pair so + // runtime tail buffers close and agents stop treating the pane as + // live. Preserve provider/lease state so a retry can still target + // the remote PTY if it survived the transient failure. + runtime?.onPtyExit(ptyId, -1) }) return true }, @@ -1151,15 +1155,28 @@ export function registerPtyHandlers( isTerminalLeafId(args.leafId) ? makePaneKey(args.tabId, args.leafId) : null - const baseEnv = baseEnvWithAuth - ? { ...baseEnvWithAuth, ...(verifiedPaneKey ? { ORCA_PANE_KEY: verifiedPaneKey } : {}) } - : undefined - if (baseEnv && !verifiedPaneKey) { + const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey + const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + if (baseEnv && stablePaneKey) { + baseEnv.ORCA_PANE_KEY = stablePaneKey + if (typeof args.tabId === 'string') { + baseEnv.ORCA_TAB_ID = args.tabId + } else if (!args.connectionId) { + delete baseEnv.ORCA_TAB_ID + } + if (typeof args.worktreeId === 'string') { + baseEnv.ORCA_WORKTREE_ID = args.worktreeId + } else if (!args.connectionId) { + delete baseEnv.ORCA_WORKTREE_ID + } + } else if (baseEnv) { // Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the // key proven to match this spawn's tab+leaf may leave the IPC boundary. delete baseEnv.ORCA_PANE_KEY + delete baseEnv.ORCA_TAB_ID + delete baseEnv.ORCA_WORKTREE_ID } - const validatedPaneKey = verifiedPaneKey + const validatedPaneKey = stablePaneKey const validatedLeafId = verifiedLeafId ?? metadataLeafId let env: Record | undefined = baseEnv const preAllocatedHandle = @@ -1445,24 +1462,17 @@ export function registerPtyHandlers( const rememberedPaneKey = validatedPaneKey ? rememberPaneKeyForPty(result.id, validatedPaneKey) : null - if (validatedPaneKey) { + if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + legacySpawnPaneKey.paneKey, + migrationUnsupportedPaneKey, + result.id + ) + clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) + } else if (validatedPaneKey) { if (!result.isReattach) { clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) } - } else if (migrationUnsupportedPaneKey && legacySpawnPaneKey) { - // Why: old live PTYs can still carry `${tabId}:${numericPaneId}` in - // ORCA_PANE_KEY. Only surface a migration row when this spawn also - // proves the owning UUID leaf through the renderer IPC metadata. - setMigrationUnsupportedPty({ - ptyId: result.id, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - tabId: legacySpawnPaneKey.tabId, - leafId: args.leafId, - paneKey: migrationUnsupportedPaneKey, - reason: 'legacy-numeric-pane-key', - source: args.connectionId ? 'ssh' : 'local', - updatedAt: Date.now() - }) } // Why: register local PTYs (connectionId falsy) with the memory // collector so it can walk each PTY's process subtree and attribute diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f81db6fddf8..8b3c226a6d3 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -7,6 +7,7 @@ const { registerPreflightHandlersMock, registerClaudeUsageHandlersMock, registerCodexUsageHandlersMock, + registerOpenCodeUsageHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -42,12 +43,14 @@ const { registerExportHandlersMock, registerOnboardingHandlersMock, registerSpeechHandlersMock, + registerSkillsHandlersMock, registerWorkspaceSpaceHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), registerCodexUsageHandlersMock: vi.fn(), + registerOpenCodeUsageHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -83,6 +86,7 @@ const { registerExportHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn(), + registerSkillsHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn() })) @@ -110,6 +114,10 @@ vi.mock('./codex-usage', () => ({ registerCodexUsageHandlers: registerCodexUsageHandlersMock })) +vi.mock('./opencode-usage', () => ({ + registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +})) + vi.mock('./github', () => ({ registerGitHubHandlers: registerGitHubHandlersMock })) @@ -150,6 +158,10 @@ vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) +vi.mock('./skills', () => ({ + registerSkillsHandlers: registerSkillsHandlersMock +})) + vi.mock('./workspace-space', () => ({ registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock })) @@ -211,10 +223,13 @@ vi.mock('./claude-accounts', () => ({ })) vi.mock('../window/attach-main-window-services', () => ({ - registerClipboardHandlers: registerClipboardHandlersMock, registerUpdaterHandlers: registerUpdaterHandlersMock })) +vi.mock('../window/clipboard-ipc-handlers', () => ({ + registerClipboardHandlers: registerClipboardHandlersMock +})) + vi.mock('./browser', () => ({ registerBrowserHandlers: registerBrowserHandlersMock, setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock, @@ -245,6 +260,7 @@ describe('registerCoreHandlers', () => { registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() registerCodexUsageHandlersMock.mockReset() + registerOpenCodeUsageHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -279,6 +295,7 @@ describe('registerCoreHandlers', () => { registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() + registerSkillsHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() }) @@ -288,9 +305,11 @@ describe('registerCoreHandlers', () => { const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } + const openCodeUsage = { marker: 'openCodeUsage' } const codexAccounts = { marker: 'codexAccounts' } const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } + const agentAwakeService = { marker: 'agentAwakeService' } registerCoreHandlers( store as never, @@ -298,13 +317,19 @@ describe('registerCoreHandlers', () => { stats as never, claudeUsage as never, codexUsage as never, + openCodeUsage as never, codexAccounts as never, claudeAccounts as never, - rateLimits as never + rateLimits as never, + null, + undefined, + undefined, + agentAwakeService as never ) expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) + expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerPetHandlersMock).toHaveBeenCalled() @@ -321,7 +346,8 @@ describe('registerCoreHandlers', () => { expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled() - expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, undefined) + expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService) + expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store) expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) @@ -348,6 +374,7 @@ describe('registerCoreHandlers', () => { const stats2 = { marker: 'stats2' } const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } + const openCodeUsage2 = { marker: 'openCodeUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } const claudeAccounts2 = { marker: 'claudeAccounts2' } const rateLimits2 = { marker: 'rateLimits2' } @@ -358,6 +385,7 @@ describe('registerCoreHandlers', () => { stats2 as never, claudeUsage2 as never, codexUsage2 as never, + openCodeUsage2 as never, codexAccounts2 as never, claudeAccounts2 as never, rateLimits2 as never, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f3e5bcce61a..7379623dfd9 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -4,18 +4,18 @@ import { registerPreflightHandlers } from './preflight' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { StatsCollector } from '../stats/collector' -import { - registerFilesystemHandlers, - type CommitMessageAgentEnvironmentResolvers -} from './filesystem' +import { registerFilesystemHandlers } from './filesystem' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' +import { registerOpenCodeUsageHandlers } from './opencode-usage' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' import { registerLinearHandlers } from './linear' import { registerFeedbackHandlers } from './feedback' +import { registerCrashReportingHandlers } from './crash-reporting' import { registerExportHandlers } from './export' import { registerStatsHandlers } from './stats' import { registerMemoryHandlers } from './memory' @@ -30,6 +30,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerSkillsHandlers } from './skills' import { registerWorkspaceSpaceHandlers } from './workspace-space' import { registerAutomationHandlers } from './automations' import { registerTelemetryHandlers } from './telemetry' @@ -44,17 +45,17 @@ import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' -import { - registerClipboardHandlers, - registerUpdaterHandlers -} from '../window/attach-main-window-services' +import { registerUpdaterHandlers } from '../window/attach-main-window-services' +import { registerClipboardHandlers } from '../window/clipboard-ipc-handlers' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { RateLimitService } from '../rate-limits/service' import type { CodexAccountService } from '../codex-accounts/service' import type { ClaudeAccountService } from '../claude-accounts/service' import type { AutomationService } from '../automations/service' import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' let registered = false @@ -64,13 +65,15 @@ export function registerCoreHandlers( stats: StatsCollector, claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, + openCodeUsage: OpenCodeUsageStore, codexAccounts: CodexAccountService, claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, mainWindowWebContentsId: number | null = null, automations?: AutomationService, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers, - agentAwakeService?: AgentAwakeService + agentAwakeService?: AgentAwakeService, + crashReports?: CrashReportStore ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -88,6 +91,7 @@ export function registerCoreHandlers( registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) + registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() registerAgentTrustHandlers() @@ -98,6 +102,9 @@ export function registerCoreHandlers( registerHostedReviewHandlers(store, stats) registerLinearHandlers() registerFeedbackHandlers() + if (crashReports) { + registerCrashReportingHandlers(crashReports) + } registerExportHandlers() registerStatsHandlers(stats) registerMemoryHandlers(store) @@ -107,6 +114,7 @@ export function registerCoreHandlers( registerDeveloperPermissionHandlers() registerComputerUsePermissionHandlers() registerSettingsHandlers(store, agentAwakeService) + registerSkillsHandlers(store) if (automations) { registerAutomationHandlers(store, automations) } diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index ef5b78197f8..dca66f5d39b 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' const { handleMock, @@ -87,7 +88,7 @@ type CreateResult = | { error: string } describe('repos:create', () => { - const handlers = new Map Promise>() + const handlers = new Map Promise>() const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } @@ -98,14 +99,14 @@ describe('repos:create', () => { if (!handler) { throw new Error('repos:create handler was never registered') } - return handler(null, args) + return handler(null, args) as Promise } beforeEach(() => { handlers.clear() handleMock.mockReset() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { - handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise) + handlers.set(channel, handler as (event: unknown, args: unknown) => Promise) }) removeHandlerMock.mockReset() mockStore.getRepos.mockReset().mockReturnValue([]) @@ -218,6 +219,15 @@ describe('repos:create', () => { expect(result).toHaveProperty('repo.kind', 'folder') }) + it('defaults repos:create badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'default-color', kind: 'folder' }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR }) + ) + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + // ── git repo happy path ─────────────────────────────────────────── it('creates a git repo with an empty initial commit (in order)', async () => { @@ -376,4 +386,21 @@ describe('repos:create', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(mockStore.addRepo).not.toHaveBeenCalled() }) + + it('returns existing badgeColor unchanged on repos:create dedupe', async () => { + const existing = { + id: 'abc', + path: '/tmp/dupe-color', + displayName: 'dupe-color', + kind: 'git', + badgeColor: '#ef4444' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await callCreate({ parentPath: '/tmp', name: 'dupe-color', kind: 'git' }) + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#ef4444') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 871a98948bb..e99aa1fc17c 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -4,28 +4,33 @@ fixture setup and mock plumbing can be shared. Splitting by line count would duplicate the hoisted mocks and the `../git/repo` partial-real/partial-stub setup. */ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { EventEmitter } from 'events' import type * as RepoModule from '../git/repo' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -const { handleMock, mockStore, mockGitProvider, mockMultiplexer } = vi.hoisted(() => ({ - handleMock: vi.fn(), - mockStore: { - getRepos: vi.fn().mockReturnValue([]), - addRepo: vi.fn(), - removeRepo: vi.fn(), - getRepo: vi.fn(), - updateRepo: vi.fn(), - getSshTarget: vi.fn() - }, - mockGitProvider: { - isGitRepo: vi.fn().mockReturnValue(true), - isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - }, - mockMultiplexer: { - request: vi.fn(), - notify: vi.fn() - } -})) +const { handleMock, mockStore, mockGitProvider, mockMultiplexer, gitSpawnMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getSshTarget: vi.fn() + }, + mockGitProvider: { + isGitRepo: vi.fn().mockReturnValue(true), + isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + }, + mockMultiplexer: { + request: vi.fn(), + notify: vi.fn() + }, + gitSpawnMock: vi.fn() + }) +) vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn() }, @@ -53,6 +58,11 @@ vi.mock('../git/repo', async () => { } }) +vi.mock('../git/runner', () => ({ + gitExecFileAsync: vi.fn(), + gitSpawn: gitSpawnMock +})) + vi.mock('./filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) @@ -93,8 +103,16 @@ describe('repos:addRemote', () => { mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() mockStore.getSshTarget.mockReset() + mockStore.updateRepo.mockReset() mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() + gitSpawnMock.mockReset() + gitSpawnMock.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc + }) mockWindow.webContents.send.mockReset() registerRepoHandlers(mockWindow as never, mockStore as never) @@ -115,7 +133,8 @@ describe('repos:addRemote', () => { path: '/home/user/project', connectionId: 'conn-1', kind: 'git', - displayName: 'project' + displayName: 'project', + badgeColor: DEFAULT_REPO_BADGE_COLOR }) ) expect(result).toHaveProperty('repo.id') @@ -188,7 +207,8 @@ describe('repos:addRemote', () => { expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ kind: 'folder', - path: '/home/user/documents' + path: '/home/user/documents', + badgeColor: DEFAULT_REPO_BADGE_COLOR }) ) expect(result).toHaveProperty('repo.kind', 'folder') @@ -292,6 +312,104 @@ describe('repos:addRemote', () => { }) }) +describe('repos:add + repos:clone', () => { + const handlers = new Map unknown>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + mockStore.updateRepo.mockReset() + mockWindow.webContents.send.mockReset() + gitSpawnMock.mockReset() + gitSpawnMock.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc + }) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => { + const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'folder' }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: '/tmp/from-add', badgeColor: DEFAULT_REPO_BADGE_COLOR }) + ) + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + + it('returns existing badgeColor unchanged on repos:add dedupe', async () => { + const existing = { + id: 'repo-add-existing', + path: '/tmp/from-add-existing', + displayName: 'from-add-existing', + kind: 'folder', + badgeColor: '#22c55e' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await handlers.get('repos:add')!(null, { + path: '/tmp/from-add-existing', + kind: 'folder' + }) + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#22c55e') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('defaults repos:clone badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const result = await handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination: '/tmp' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + kind: 'git' + }) + ) + expect(result).toHaveProperty('badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + + it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => { + const existing = { + id: 'folder-repo', + path: '/tmp/orca', + displayName: 'orca', + badgeColor: '#8b5cf6', + addedAt: 1, + kind: 'folder' + } + const upgraded = { ...existing, kind: 'git' as const } + mockStore.getRepos.mockReturnValue([existing]) + mockStore.updateRepo.mockReturnValue(upgraded) + + const result = await handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination: '/tmp' + }) + + expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) + expect(result).toEqual(upgraded) + expect(result).toHaveProperty('badgeColor', '#8b5cf6') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) +}) + describe('repos:getBaseRefDefault envelope', () => { const handlers = new Map unknown>() const mockWindow = { @@ -571,7 +689,7 @@ describe('repos:searchBaseRefs SSH relay', () => { await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' }) - expect(mockGitProvider.exec).toHaveBeenCalledTimes(1) + expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) const [argv, path] = mockGitProvider.exec.mock.calls[0] expect(path).toBe('/remote/repo') expect(argv[0]).toBe('for-each-ref') @@ -580,6 +698,7 @@ describe('repos:searchBaseRefs SSH relay', () => { expect(argv).toContain('refs/heads/*upstream*') // Guard against regression to the old origin-only glob. expect(argv).not.toContain('refs/remotes/origin/*upstream*') + expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo']) }) it('sends segmented argv for display-format queries like `upstream/main`', async () => { @@ -599,7 +718,7 @@ describe('repos:searchBaseRefs SSH relay', () => { await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' }) - expect(mockGitProvider.exec).toHaveBeenCalledTimes(1) + expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) const [argv] = mockGitProvider.exec.mock.calls[0] expect(argv).toContain('refs/remotes/*upstream*/*main*') expect(argv).toContain('refs/heads/*upstream*/*main*') @@ -608,6 +727,7 @@ describe('repos:searchBaseRefs SSH relay', () => { // which fnmatch cannot match because `*` doesn't cross `/`. expect(argv).not.toContain('refs/remotes/*upstream/main*/*') expect(argv).not.toContain('refs/remotes/*/*upstream/main*') + expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo']) }) it('parses NUL-delimited stdout and filters /HEAD pseudo-refs', async () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 1e16236a81f..ff81578d3e4 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -5,9 +5,14 @@ import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' import type { Store } from '../persistence' -import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' +import type { + BaseRefSearchResult, + Repo, + BaseRefDefaultResult, + SparsePreset +} from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' -import { REPO_COLORS } from '../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' @@ -20,11 +25,11 @@ import { getBaseRefDefault, getRemoteCount, normalizeRefSearchQuery, - parseAndFilterSearchRefs, + parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, - searchBaseRefs + searchBaseRefDetails } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' @@ -75,6 +80,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getGitUsername') ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') + ipcMain.removeHandler('repos:searchBaseRefDetails') ipcMain.removeHandler('repos:addRemote') ipcMain.removeHandler('repos:create') ipcMain.removeHandler('sparsePresets:list') @@ -107,7 +113,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: args.path, displayName: getRepoName(args.path), - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -206,7 +212,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: resolvedPath, displayName, - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind, connectionId: args.connectionId @@ -405,7 +411,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: targetPath, displayName: name, - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -686,7 +692,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git' } @@ -801,46 +807,67 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.handle( 'repos:searchBaseRefs', async (_event, args: { repoId: string; query: string; limit?: number }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return [] - } - const limit = args.limit ?? 25 - // Why: remote repos need the relay to list branches on the remote host. - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return [] - } - // Why: mirror the local path's sanitization (normalizeRefSearchQuery - // in ../git/repo.ts) — strip glob metacharacters to prevent glob - // injection via the SSH branch, and short-circuit empty queries so - // we don't leak every ref. Without this the SSH path diverges from - // the local path's behavior. - const normalizedQuery = normalizeRefSearchQuery(args.query) - if (!normalizedQuery) { - return [] - } - try { - // Why: argv (including the two-remote-glob rationale) lives in - // buildSearchBaseRefsArgv so the SSH and local paths cannot drift. - const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path) - // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline - // to the shared helper so the SSH and local paths cannot diverge. - // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup + - // HEAD-filter rationale. - return parseAndFilterSearchRefs(result.stdout, limit) - } catch (err) { - console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', { - path: repo.path, - err - }) - return [] - } - } - return searchBaseRefs(repo.path, args.query, limit) + return (await searchBaseRefDetailsForRepo(store, args)).map((entry) => entry.refName) } ) + + ipcMain.handle( + 'repos:searchBaseRefDetails', + async (_event, args: { repoId: string; query: string; limit?: number }) => { + return searchBaseRefDetailsForRepo(store, args) + } + ) +} + +async function searchBaseRefDetailsForRepo( + store: Store, + args: { repoId: string; query: string; limit?: number } +): Promise { + const repo = store.getRepo(args.repoId) + if (!repo || isFolderRepo(repo)) { + return [] + } + const limit = args.limit ?? 25 + // Why: remote repos need the relay to list branches on the remote host. + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return [] + } + // Why: mirror the local path's sanitization (normalizeRefSearchQuery + // in ../git/repo.ts) — strip glob metacharacters to prevent glob + // injection via the SSH branch, and short-circuit empty queries so + // we don't leak every ref. Without this the SSH path diverges from + // the local path's behavior. + const normalizedQuery = normalizeRefSearchQuery(args.query) + if (!normalizedQuery) { + return [] + } + try { + // Why: argv (including the two-remote-glob rationale) lives in + // buildSearchBaseRefsArgv so the SSH and local paths cannot drift. + const [result, remotesResult] = await Promise.all([ + provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path), + provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) + ]) + // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline + // to the shared helper so the SSH and local paths cannot diverge. + // See parseAndFilterSearchRefs in ../git/repo.ts for the dedup + + // HEAD-filter rationale. + const remotes = remotesResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', { + path: repo.path, + err + }) + return [] + } + } + return searchBaseRefDetails(repo.path, args.query, limit) } function notifyReposChanged(mainWindow: BrowserWindow): void { diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts index 2a06416bb94..8b913055488 100644 --- a/src/main/ipc/runtime-environment-call-queue.ts +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -23,6 +23,7 @@ function isBackgroundRuntimeMethod(method: string): boolean { method === 'github.listWorkItems' || method === 'github.countWorkItems' || method === 'git.status' || + method === 'git.history' || method === 'git.conflictOperation' || method === 'git.branchCompare' || method === 'git.upstreamStatus' diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 5f034fb90b4..1ada6db824c 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -94,13 +94,13 @@ describe('registerShellHandlers', () => { statMock.mockResolvedValue({ isDirectory: () => true }) }) - function getHandler(channel: string): (event: unknown, args?: unknown) => Promise { + function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise { registerShellHandlers() const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel) if (!call) { throw new Error(`${channel} handler not registered`) } - return call[1] as (event: unknown, args?: unknown) => Promise + return call[1] as (event: unknown, ...args: unknown[]) => Promise } it('picks audio files with a constrained native dialog filter', async () => { @@ -240,6 +240,25 @@ describe('registerShellHandlers', () => { expect(openPathMock).not.toHaveBeenCalled() }) + it('uses a provided launcher command', async () => { + const workspacePath = resolve('workspace') + const handler = getHandler('shell:openInExternalEditor') + + await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true }) + expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor') + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ + normalize(workspacePath) + ]) + }) + + it('falls back to VS Code when command is blank', async () => { + const workspacePath = resolve('workspace') + const handler = getHandler('shell:openInExternalEditor') + + await expect(handler({}, workspacePath, ' ')).resolves.toEqual({ ok: true }) + expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND) + }) + it('uses platform-safe launcher command arguments', async () => { getSpawnArgsForWindowsMock.mockReturnValueOnce({ spawnCmd: 'platform-runner', diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index cb6fba2d9b5..04192ebd668 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -46,8 +46,13 @@ async function openInFileManager(pathValue: string): Promise { - const editorCommand = resolveCliCommand(EXTERNAL_EDITOR_CLI_COMMAND) +function resolveExternalEditorCommand(command?: string): string { + const trimmed = command?.trim() + return resolveCliCommand(trimmed || EXTERNAL_EDITOR_CLI_COMMAND) +} + +async function launchExternalEditor(pathValue: string, command?: string): Promise { + const editorCommand = resolveExternalEditorCommand(command) const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(editorCommand, [pathValue]) await new Promise((resolvePromise, rejectPromise) => { @@ -75,13 +80,16 @@ async function launchExternalEditor(pathValue: string): Promise { }) } -async function openInExternalEditor(pathValue: string): Promise { +async function openInExternalEditor( + pathValue: string, + command?: string +): Promise { const target = await validateLocalPathTarget(pathValue) if (!target.ok) { return target } try { - await launchExternalEditor(target.path) + await launchExternalEditor(target.path, command) return { ok: true } } catch { return { ok: false, reason: 'launch-failed' } @@ -100,7 +108,8 @@ export function registerShellHandlers(): void { ipcMain.handle( 'shell:openInExternalEditor', - (_event, path: string): Promise => openInExternalEditor(path) + (_event, path: string, command?: string): Promise => + openInExternalEditor(path, command) ) ipcMain.handle('shell:openUrl', (_event, rawUrl: string) => { diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts new file mode 100644 index 00000000000..397579d4796 --- /dev/null +++ b/src/main/ipc/skills.ts @@ -0,0 +1,10 @@ +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import { discoverSkills } from '../skills/discovery' +import type { SkillDiscoveryResult } from '../../shared/skills' + +export function registerSkillsHandlers(store: Store): void { + ipcMain.handle('skills:discover', async (): Promise => { + return discoverSkills({ repos: store.getRepos() }) + }) +} diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts new file mode 100644 index 00000000000..2a1e5072183 --- /dev/null +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -0,0 +1,418 @@ +/* eslint-disable max-lines -- Why: scan and IPC process-liveness tests share + hoisted Electron/git provider mocks; splitting would duplicate brittle setup. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { DiffComment, GitStatusResult, Repo } from '../../shared/types' + +const { + listRepoWorktreesMock, + getStatusMock, + gitExecFileAsyncMock, + getSshGitProviderMock, + getSshPtyProviderMock, + listRegisteredPtysMock +} = vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + getStatusMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getSshPtyProviderMock: vi.fn(), + listRegisteredPtysMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../repo-worktrees', () => ({ + listRepoWorktrees: listRepoWorktreesMock, + createFolderWorktree: vi.fn() +})) + +vi.mock('../git/status', () => ({ + getStatus: getStatusMock +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: listRegisteredPtysMock +})) + +vi.mock('./pty', () => ({ + getSshPtyProvider: getSshPtyProviderMock +})) + +import { registerWorkspaceCleanupHandlers, scanWorkspaceCleanup } from './workspace-cleanup' + +const NOW = 1_700_000_000_000 +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: NOW +} + +function makeStore( + options: { + baseRef?: string + diffComments?: DiffComment[] + lastActivityAt?: number + linkedIssue?: number | null + repos?: Repo[] + } = {} +): Store { + const baseRef = Object.hasOwn(options, 'baseRef') ? options.baseRef : 'origin/main' + return { + getRepos: () => options.repos ?? [REPO], + getWorktreeMeta: () => ({ + linkedPR: null, + linkedIssue: options.linkedIssue ?? null, + lastActivityAt: options.lastActivityAt ?? NOW - 40 * 24 * 60 * 60 * 1000, + baseRef, + diffComments: options.diffComments + }), + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ + pr: {}, + issue: {} + }) + } as unknown as Store +} + +describe('workspace cleanup scan', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + listRepoWorktreesMock.mockReset() + getStatusMock.mockReset() + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + getSshPtyProviderMock.mockReset() + listRegisteredPtysMock.mockReset() + listRegisteredPtysMock.mockReturnValue([]) + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.removeHandler).mockReset() + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '0\n', stderr: '' }) + getSshGitProviderMock.mockReturnValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('default-selects inactive workspaces when git status is clean', async () => { + const result = await scanWorkspaceCleanup(makeStore()) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + git: { + clean: true, + upstreamAhead: 0 + } + }) + }) + + it('keeps raw scan errors out of renderer-facing results', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.errors).toEqual([ + { + repoId: 'repo-1', + repoName: 'Repo', + message: 'Git could not list worktrees.' + } + ]) + }) + + it('skips disconnected remote workspaces without a scan warning', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(result.errors).toEqual([]) + expect(result.candidates).toEqual([]) + }) + + it('scans connected remote workspaces through the SSH git provider', async () => { + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]), + getStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + } + getSshGitProviderMock.mockReturnValue(provider) + + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature') + expect(result.errors).toEqual([]) + expect(result.candidates[0]).toMatchObject({ + connectionId: 'ssh-1', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'] + }) + }) + + it('skips connected remote workspaces that fail during broad scans', async () => { + const provider = { + listWorktrees: vi.fn().mockRejectedValue(new Error('ssh timeout')), + getStatus: vi.fn() + } + getSshGitProviderMock.mockReturnValue(provider) + + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [{ ...REPO, connectionId: 'ssh-1' }] + }) + ) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(result.errors).toEqual([]) + expect(result.candidates).toEqual([]) + }) + + it('filters out recent workspaces before running git status', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + }) + ) + + expect(getStatusMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(result.candidates).toEqual([]) + }) + + it('includes focused remove preflight rows even when they are recent', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 + }), + { worktreeId: 'repo-1::/repo-feature' } + ) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(result.candidates[0]).toMatchObject({ + tier: 'review', + selectedByDefault: false, + reasons: [], + git: { + clean: true, + checkedAt: expect.any(Number) + } + }) + }) + + it('honors renderer git deferrals without hiding the workspace', async () => { + const result = await scanWorkspaceCleanup(makeStore(), { + skipGitWorktreeIds: ['repo-1::/repo-feature'] + }) + + expect(getStatusMock).not.toHaveBeenCalled() + expect(result.candidates[0]).toMatchObject({ + tier: 'review', + selectedByDefault: false, + reasons: ['idle-clean'], + git: { + clean: null, + checkedAt: null + } + }) + }) + + it('uses remote commit presence when a clean inactive workspace has no upstream', async () => { + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-list', '--count', 'HEAD', '--not', '--remotes'], + { cwd: '/repo-feature' } + ) + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + git: { + clean: true, + upstreamAhead: null + } + }) + }) + + it('protects clean inactive workspaces with local-only commits', async () => { + getStatusMock.mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } satisfies GitStatusResult) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '2\n', stderr: '' }) + + const result = await scanWorkspaceCleanup(makeStore()) + + expect(result.candidates[0]).toMatchObject({ + tier: 'protected', + selectedByDefault: false, + blockers: ['unpushed-commits'], + git: { + clean: true, + upstreamAhead: null + } + }) + }) + + it('keeps diff notes as context instead of blocking inactive cleanup', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + baseRef: undefined, + diffComments: [ + { + id: 'comment-1', + worktreeId: 'repo-1::/repo-feature', + filePath: 'src/file.ts', + lineNumber: 12, + body: 'Follow up before deleting', + createdAt: NOW - 1_000, + side: 'modified' + } + ] + }) + ) + + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + localContext: { + diffCommentCount: 1, + newestDiffCommentAt: NOW - 1_000 + } + }) + }) + + it('does not expose PR cache state in inactivity cleanup results', async () => { + const result = await scanWorkspaceCleanup( + makeStore({ + repos: [REPO, { ...REPO, id: 'repo-2' }] + }) + ) + + expect(result.candidates[0]).toMatchObject({ + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'] + }) + expect(result.candidates[0]).not.toHaveProperty('linkedPR') + }) + + it('reports local processes that workspace deletion would kill', async () => { + const localProvider = { + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'repo-1::/repo-feature@@session-1', + cwd: '/repo-feature', + title: 'zsh' + } + ]) + } + registerWorkspaceCleanupHandlers(makeStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never, + getLocalPtyProvider: () => localProvider as never + }) + + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] + + await expect(handler?.({} as never, { worktreeId: 'repo-1::/repo-feature' })).resolves.toEqual({ + hasKillableProcesses: true + }) + }) + + it('reports SSH processes inside the remote workspace path', async () => { + getSshPtyProviderMock.mockReturnValue({ + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'remote-session-1', + cwd: '/remote/repo-feature/subdir', + title: 'codex' + } + ]) + }) + registerWorkspaceCleanupHandlers(makeStore(), { + runtime: { + hasTerminalsForWorktree: vi.fn().mockResolvedValue(false) + } as never + }) + + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1] + + await expect( + handler?.({} as never, { + worktreeId: 'repo-ssh::/remote/repo-feature', + connectionId: 'ssh-1', + worktreePath: '/remote/repo-feature' + }) + ).resolves.toEqual({ + hasKillableProcesses: true + }) + }) +}) diff --git a/src/main/ipc/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup.ts new file mode 100644 index 00000000000..21c7e26819a --- /dev/null +++ b/src/main/ipc/workspace-cleanup.ts @@ -0,0 +1,700 @@ +/* eslint-disable max-lines */ +import { ipcMain } from 'electron' +import { basename } from 'node:path' +import type { Store } from '../persistence' +import { getStatus } from '../git/status' +import { gitExecFileAsync } from '../git/runner' +import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { IGitProvider, IPtyProvider } from '../providers/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { listRegisteredPtys } from '../memory/pty-registry' +import { getSshPtyProvider } from './pty' +import { isFolderRepo } from '../../shared/repo-kind' +import type { GitStatusResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import { mergeWorktree } from './worktree-logic' +import { splitWorktreeId } from '../../shared/worktree-id' +import { + WORKSPACE_CLEANUP_CLASSIFIER_VERSION, + applyWorkspaceCleanupPolicy, + createWorkspaceCleanupFingerprint, + getWorkspaceCleanupInactivityReasons, + isWorkspaceOldForCleanup, + type WorkspaceCleanupBlocker, + type WorkspaceCleanupCandidate, + type WorkspaceCleanupDismissArgs, + type WorkspaceCleanupLocalProcessArgs, + type WorkspaceCleanupLocalProcessResult, + type WorkspaceCleanupReason, + type WorkspaceCleanupScanError, + type WorkspaceCleanupScanArgs, + type WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' + +const GIT_READ_TIMEOUT_MS = 8_000 +const WORKTREE_SCAN_CONCURRENCY = 3 + +type GitEvidence = { + clean: boolean | null + upstreamAhead: number | null + upstreamBehind: number | null + checkedAt: number | null + blockers: WorkspaceCleanupBlocker[] +} + +type WorkspaceCleanupHandlerDeps = { + runtime?: OrcaRuntimeService + getLocalPtyProvider?: () => IPtyProvider +} + +export function registerWorkspaceCleanupHandlers( + store: Store, + deps: WorkspaceCleanupHandlerDeps = {} +): void { + ipcMain.removeHandler('workspaceCleanup:scan') + ipcMain.removeHandler('workspaceCleanup:dismiss') + ipcMain.removeHandler('workspaceCleanup:clearDismissals') + ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses') + + ipcMain.handle( + 'workspaceCleanup:scan', + (_event, args?: WorkspaceCleanupScanArgs): Promise => + scanWorkspaceCleanup(store, args ?? {}) + ) + + ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => { + const current = store.getUI().workspaceCleanup?.dismissals ?? {} + const next = { ...current } + for (const dismissal of args.dismissals ?? []) { + if ( + dismissal && + dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION && + typeof dismissal.worktreeId === 'string' && + typeof dismissal.fingerprint === 'string' + ) { + next[dismissal.worktreeId] = dismissal + } + } + store.updateUI({ workspaceCleanup: { dismissals: next } }) + }) + + ipcMain.handle('workspaceCleanup:clearDismissals', () => { + store.updateUI({ workspaceCleanup: { dismissals: {} } }) + }) + + ipcMain.handle( + 'workspaceCleanup:hasKillableLocalProcesses', + async ( + _event, + args: WorkspaceCleanupLocalProcessArgs + ): Promise => ({ + hasKillableProcesses: await hasKillableProcesses(args, deps) + }) + ) +} + +async function hasKillableProcesses( + args: WorkspaceCleanupLocalProcessArgs, + deps: WorkspaceCleanupHandlerDeps +): Promise { + const { worktreeId } = args + if (typeof worktreeId !== 'string' || worktreeId.length === 0) { + return false + } + + let livenessUnknown = false + if (deps.runtime) { + try { + if (await deps.runtime.hasTerminalsForWorktree(worktreeId)) { + return true + } + } catch { + livenessUnknown = true + } + } + + if (args.connectionId) { + return hasKillableSshProcesses(args.connectionId, args.worktreePath ?? '', livenessUnknown) + } + + const registryPtyIds = new Set( + listRegisteredPtys() + .filter((entry) => entry.worktreeId === worktreeId) + .map((entry) => entry.ptyId) + ) + + const provider = deps.getLocalPtyProvider?.() + if (!provider) { + return registryPtyIds.size > 0 ? true : null + } + + try { + const prefix = `${worktreeId}@@` + const sessions = await provider.listProcesses() + if ( + sessions.some((session) => session.id.startsWith(prefix) || registryPtyIds.has(session.id)) + ) { + return true + } + return livenessUnknown ? null : false + } catch { + return registryPtyIds.size > 0 ? true : null + } +} + +async function hasKillableSshProcesses( + connectionId: string, + worktreePath: string, + livenessUnknown: boolean +): Promise { + const provider = getSshPtyProvider(connectionId) + if (!provider) { + return null + } + + try { + const normalizedWorktreePath = normalizeRemotePath(worktreePath) + const sessions = await provider.listProcesses() + if ( + sessions.some((session) => { + if (session.id.startsWith(`${worktreePath}@@`)) { + return true + } + return ( + normalizedWorktreePath.length > 0 && + isPathWithin(normalizeRemotePath(session.cwd), normalizedWorktreePath) + ) + }) + ) { + return true + } + return livenessUnknown ? null : false + } catch { + return null + } +} + +function normalizeRemotePath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function isPathWithin(candidatePath: string, parentPath: string): boolean { + return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`) +} + +export async function scanWorkspaceCleanup( + store: Store, + args: WorkspaceCleanupScanArgs = {} +): Promise { + const scannedAt = Date.now() + const repos = store.getRepos() + const errors: WorkspaceCleanupScanResult['errors'] = [] + const candidates: WorkspaceCleanupCandidate[] = [] + + for (const repo of repos) { + const result = await scanRepoWorkspaces({ + store, + repo, + scannedAt, + targetWorktreeId: args.worktreeId, + skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []) + }) + candidates.push(...result.candidates) + errors.push(...result.errors) + } + + return { scannedAt, candidates, errors } +} + +async function scanRepoWorkspaces(args: { + store: Store + repo: Repo + scannedAt: number + targetWorktreeId?: string + skipGitWorktreeIds: Set +}): Promise { + const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args + const errors: WorkspaceCleanupScanResult['errors'] = [] + let provider: IGitProvider | null = null + let gitWorktrees: GitWorktreeInfo[] = [] + const repoIsFolder = isFolderRepo(repo) + + try { + if (repoIsFolder) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + provider = getSshGitProvider(repo.connectionId) ?? null + if (!provider) { + // Why: cleanup should reflect only workspaces Orca can currently + // inspect. Disconnected SSH repos are skipped in broad scans. + return { + scannedAt, + candidates: targetWorktreeId + ? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId) + : [], + errors: [] + } + } + gitWorktrees = await withTimeout( + provider.listWorktrees(repo.path), + GIT_READ_TIMEOUT_MS, + 'Timed out listing SSH worktrees.' + ) + } else { + gitWorktrees = await withTimeout( + listRepoWorktrees(repo), + GIT_READ_TIMEOUT_MS, + 'Timed out listing worktrees.' + ) + } + } catch (error) { + console.error('Workspace cleanup repo scan failed', error) + if (repo.connectionId && !targetWorktreeId) { + // Why: broad cleanup only shows remote workspaces Orca can inspect now. + // A connected SSH repo that fails mid-scan is omitted, not bannered. + return { scannedAt, candidates: [], errors: [] } + } + errors.push(createScanError(repo, toSafeRepoScanError(error))) + return { scannedAt, candidates: [], errors } + } + + const worktrees = gitWorktrees + .map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + .filter((worktree) => { + if (targetWorktreeId) { + return worktree.id === targetWorktreeId + } + return ( + !repoIsFolder && + !worktree.isMainWorktree && + isWorkspaceInactiveForCleanup(worktree, scannedAt) + ) + }) + + const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) => + buildCandidate({ + repo, + worktree, + scannedAt, + provider, + skipGit: skipGitWorktreeIds.has(worktree.id), + forceGitCheck: Boolean(targetWorktreeId) + }).catch((error) => { + console.error('Workspace cleanup candidate scan failed', error) + return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error)) + }) + ) + + return { scannedAt, candidates, errors } +} + +async function buildCandidate(args: { + repo: Repo + worktree: Worktree + scannedAt: number + provider: IGitProvider | null + skipGit: boolean + forceGitCheck: boolean +}): Promise { + const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args + const blockers: WorkspaceCleanupBlocker[] = [] + const reasons = getInactivityReasons(worktree, scannedAt) + const repoIsFolder = isFolderRepo(repo) + + if (worktree.isMainWorktree) { + blockers.push('main-worktree') + } + if (repoIsFolder) { + blockers.push('folder-repo') + } + if (worktree.isPinned) { + blockers.push('pinned') + } + + const localContext = buildLocalContext(worktree) + const shouldReadGit = shouldReadGitEvidence({ + repoIsFolder, + blockers, + worktree, + skipGit, + forceGitCheck + }) + + const gitEvidence = !shouldReadGit + ? createEmptyGitEvidence() + : await readGitEvidence(worktree, repo, provider) + blockers.push(...gitEvidence.blockers) + + const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortBranchName(worktree.branch), + path: worktree.path, + tier: 'review', + selectedByDefault: false, + reasons, + blockers: uniqueBlockers(blockers), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext, + git: { + clean: gitEvidence.clean, + upstreamAhead: gitEvidence.upstreamAhead, + upstreamBehind: gitEvidence.upstreamBehind, + checkedAt: gitEvidence.checkedAt + }, + fingerprint: '' + } + + const fingerprint = createWorkspaceCleanupFingerprint({ + branch: candidateWithoutFingerprint.branch, + head: worktree.head, + gitClean: gitEvidence.clean, + lastActivityAt: worktree.lastActivityAt + }) + + return applyWorkspaceCleanupPolicy({ + ...candidateWithoutFingerprint, + reasons: uniqueReasons(reasons), + blockers: uniqueBlockers(blockers), + fingerprint + }) +} + +function shouldReadGitEvidence(args: { + repoIsFolder: boolean + blockers: WorkspaceCleanupBlocker[] + worktree: Worktree + skipGit: boolean + forceGitCheck: boolean +}): boolean { + const { repoIsFolder, blockers, worktree, skipGit, forceGitCheck } = args + if ((skipGit && !forceGitCheck) || repoIsFolder || worktree.isMainWorktree) { + return false + } + if ( + blockers.includes('pinned') || + blockers.includes('main-worktree') || + blockers.includes('folder-repo') + ) { + return false + } + + // Why: inactivity is the only recommendation signal now. Git is read only + // to keep the destructive path from deleting dirty or local-only branch work. + return true +} + +async function readGitEvidence( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + const blockers: WorkspaceCleanupBlocker[] = [] + let status: GitStatusResult + const checkedAt = Date.now() + + try { + status = await withTimeout( + repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path), + GIT_READ_TIMEOUT_MS, + 'Timed out reading git status.' + ) + } catch { + return { + ...createEmptyGitEvidence(), + blockers: ['git-status-error'] + } + } + + if (status.upstreamStatus === undefined) { + return { + ...createEmptyGitEvidence(), + blockers: ['git-status-error'] + } + } + + const clean = status.entries.length === 0 + if (!clean) { + blockers.push('dirty-files') + } + + const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null + const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null + if (upstreamAhead !== null && upstreamAhead > 0) { + blockers.push('unpushed-commits') + } + if (clean && upstreamAhead === null) { + const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider) + if (unpushedCommitCount === null) { + blockers.push('unknown-base') + } else if (unpushedCommitCount > 0) { + blockers.push('unpushed-commits') + } + } + + const evidence: GitEvidence = { + clean, + upstreamAhead, + upstreamBehind, + checkedAt, + blockers + } + + return { ...evidence, blockers: uniqueBlockers(blockers) } +} + +async function readUnpushedCommitCount( + worktree: Worktree, + repo: Repo, + provider: IGitProvider | null +): Promise { + try { + const result = await withTimeout( + repo.connectionId + ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path) + : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path + }), + GIT_READ_TIMEOUT_MS, + 'Timed out checking unpushed commits.' + ) + const count = Number.parseInt(result.stdout.trim(), 10) + return Number.isFinite(count) ? count : null + } catch { + return null + } +} + +function buildCandidateFromError( + repo: Repo, + worktree: Worktree, + scannedAt: number, + _message: string +): WorkspaceCleanupCandidate { + return applyWorkspaceCleanupPolicy({ + worktreeId: worktree.id, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: worktree.displayName, + branch: shortBranchName(worktree.branch), + path: worktree.path, + tier: 'protected', + selectedByDefault: false, + reasons: getInactivityReasons(worktree, scannedAt), + blockers: ['git-status-error'], + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + localContext: buildLocalContext(worktree), + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: scannedAt + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: shortBranchName(worktree.branch), + head: worktree.head, + gitClean: null, + lastActivityAt: worktree.lastActivityAt + }) + }) +} + +function synthesizeDisconnectedSshCandidates( + store: Store, + repo: Repo, + scannedAt: number, + targetWorktreeId?: string +): WorkspaceCleanupCandidate[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + if (!worktreeId.startsWith(`${repo.id}::`)) { + return false + } + if (targetWorktreeId) { + return targetWorktreeId === worktreeId + } + return isWorkspaceInactiveForCleanup(meta, scannedAt) + }) + .map(([worktreeId, meta]) => { + const parsed = splitWorktreeId(worktreeId) + const path = parsed?.worktreePath ?? worktreeId + const reasons = getInactivityReasons(meta, scannedAt) + return applyWorkspaceCleanupPolicy({ + worktreeId, + repoId: repo.id, + repoName: repo.displayName, + connectionId: repo.connectionId ?? null, + displayName: meta.displayName || basename(path), + branch: basename(path), + path, + tier: 'protected', + selectedByDefault: false, + reasons, + blockers: ['ssh-disconnected'], + lastActivityAt: meta.lastActivityAt, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: meta.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestDiffCommentAt(meta.diffComments), + retainedDoneAgentCount: 0 + }, + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }, + fingerprint: createWorkspaceCleanupFingerprint({ + branch: basename(path), + head: '', + gitClean: null, + lastActivityAt: meta.lastActivityAt + }) + }) + }) +} + +function buildLocalContext(worktree: Worktree): WorkspaceCleanupCandidate['localContext'] { + return { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: worktree.diffComments?.length ?? 0, + newestDiffCommentAt: getNewestDiffCommentAt(worktree.diffComments), + retainedDoneAgentCount: 0 + } +} + +function getNewestDiffCommentAt(diffComments: Worktree['diffComments'] | undefined): number | null { + if (!diffComments || diffComments.length === 0) { + return null + } + return Math.max(...diffComments.map((comment) => comment.createdAt)) +} + +function createEmptyGitEvidence(): GitEvidence { + return { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null, + blockers: [] + } +} + +function shortBranchName(branch: string): string { + return branch.replace(/^refs\/heads\//, '') || 'HEAD' +} + +function uniqueBlockers(blockers: WorkspaceCleanupBlocker[]): WorkspaceCleanupBlocker[] { + return [...new Set(blockers)] +} + +function uniqueReasons(reasons: WorkspaceCleanupReason[]): WorkspaceCleanupReason[] { + return [...new Set(reasons)] +} + +function isWorkspaceInactiveForCleanup( + workspace: Pick, + scannedAt: number +): boolean { + return isWorkspaceOldForCleanup(workspace, scannedAt) +} + +function getInactivityReasons( + workspace: Pick, + scannedAt: number +): WorkspaceCleanupReason[] { + return getWorkspaceCleanupInactivityReasons(workspace, scannedAt) +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function createScanError(repo: Repo, message: string): WorkspaceCleanupScanError { + return { + repoId: repo.id, + repoName: repo.displayName || basename(repo.path), + message + } +} + +// Why: git errors often include absolute paths or command output. Keep the +// cause useful without leaking raw local/remote filesystem details to the UI. +function toSafeRepoScanError(error: unknown): string { + const message = toErrorMessage(error) + if (message === 'Timed out listing SSH worktrees.') { + return 'Timed out listing remote worktrees.' + } + if (message === 'Timed out listing worktrees.') { + return 'Timed out listing worktrees.' + } + if (message.startsWith('Timed out ')) { + return message.replace(/\.$/, '') + } + + const lower = message.toLowerCase() + if (lower.includes('not a git repository') || lower.includes('not a git worktree')) { + return 'Repository is not a git checkout.' + } + if ( + lower.includes('enoent') || + lower.includes('no such file') || + lower.includes('cannot find') || + lower.includes('does not exist') + ) { + return 'Repository folder was not found.' + } + if (lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied')) { + return 'Repository folder is not accessible.' + } + return 'Git could not list worktrees.' +} diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 77073c29544..73231eab8fa 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -13,6 +13,7 @@ import { mergeWorktree, parseWorktreeId, formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, isOrphanedWorktreeError, areWorktreePathsEqual } from './worktree-logic' @@ -366,3 +367,35 @@ describe('isOrphanedWorktreeError', () => { expect(isOrphanedWorktreeError(null)).toBe(false) }) }) + +describe('isOrphanCompatiblePreflightError', () => { + it('matches not-a-working-tree errors', () => { + const error = Object.assign(new Error('git failed'), { + stderr: "fatal: '/some/path' is not a working tree" + }) + + expect(isOrphanCompatiblePreflightError(error)).toBe(true) + }) + + it('matches status failures from non-repo directories', () => { + const error = Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git' + }) + + expect(isOrphanCompatiblePreflightError(error)).toBe(true) + }) + + it('matches missing directories by error code', () => { + const error = Object.assign(new Error('spawn git'), { code: 'ENOENT' }) + + expect(isOrphanCompatiblePreflightError(error)).toBe(true) + }) + + it('does not match unrelated subprocess failures', () => { + const error = Object.assign(new Error('status failed'), { + stderr: 'fatal: unable to read current working directory' + }) + + expect(isOrphanCompatiblePreflightError(error)).toBe(false) + }) +}) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 266718fb96f..e689f3ae467 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -176,6 +176,7 @@ export function mergeWorktree( const branchShort = git.branch.replace(/^refs\/heads\//, '') return { id: `${repoId}::${git.path}`, + ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, path: git.path, head: git.head, @@ -238,6 +239,25 @@ export function isOrphanedWorktreeError(error: unknown): boolean { return /is not a working tree/.test(msg) } +export function isOrphanCompatiblePreflightError(error: unknown): boolean { + if (isOrphanedWorktreeError(error)) { + return true + } + if (!(error instanceof Error)) { + return false + } + const errorWithDetails = error as Error & { code?: unknown; stderr?: string; stdout?: string } + const details = [ + errorWithDetails.stderr, + errorWithDetails.stdout, + errorWithDetails.message, + typeof errorWithDetails.code === 'string' ? errorWithDetails.code : undefined + ] + .filter((value): value is string => Boolean(value)) + .join('\n') + return /not a git repository/i.test(details) || /\bENOENT\b/i.test(details) +} + /** * Format a human-readable error message for worktree removal failures. */ diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 27883eec850..a1a35665a24 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -10,6 +10,7 @@ import type { BrowserWindow } from 'electron' import { join } from 'path' import { existsSync } from 'fs' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { CreateWorktreeArgs, @@ -89,6 +90,41 @@ async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise { + if (!branchNameOverride) { + return computeBranchName(sanitizedName, settings, username) + } + if (branchNameOverride.startsWith('-')) { + throw new Error('Branch name must not start with "-"') + } + await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath }) + return branchNameOverride +} + +async function resolveCreateBranchNameSsh( + provider: SshGitProvider, + repoPath: string, + branchNameOverride: string | undefined, + sanitizedName: string, + settings: { branchPrefix: string; branchPrefixCustom?: string }, + username: string | null +): Promise { + if (!branchNameOverride) { + return computeBranchName(sanitizedName, settings, username) + } + if (branchNameOverride.startsWith('-')) { + throw new Error('Branch name must not start with "-"') + } + await provider.exec(['check-ref-format', '--branch', branchNameOverride], repoPath) + return branchNameOverride +} + async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise { const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) const existing = new Set( @@ -304,7 +340,14 @@ export async function createRemoteWorktree( /* no username configured */ } - const branchName = computeBranchName(sanitizedName, settings, username) + const branchName = await resolveCreateBranchNameSsh( + provider, + repo.path, + args.branchNameOverride, + sanitizedName, + settings, + username + ) // Check branch conflict on remote try { @@ -430,6 +473,10 @@ export async function createRemoteWorktree( ) } const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), lastActivityAt: now, // Why: grants the new worktree a short grace window at the top of the // Recent sort. During worktree creation (git fetch + add can take several @@ -448,7 +495,8 @@ export async function createRemoteWorktree( ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}) + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) @@ -572,8 +620,18 @@ export async function createLocalWorktree( ? `${requestedName}-${suffix}` : effectiveSanitizedName - branchName = computeBranchName(effectiveSanitizedName, settings, username) - lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName) + branchName = await resolveCreateBranchName( + repo.path, + suffix === 1 && args.branchNameOverride + ? args.branchNameOverride + : args.branchNameOverride + ? `${args.branchNameOverride}-${suffix}` + : undefined, + effectiveSanitizedName, + settings, + username + ) + lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName, baseBranch) if (lastBranchConflictKind) { continue } @@ -716,6 +774,10 @@ export async function createLocalWorktree( const worktreeId = `${repo.id}::${created.path}` const now = Date.now() const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. @@ -740,7 +802,8 @@ export async function createLocalWorktree( ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}) + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 71d3b459c71..d2d9f4ddb22 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -5,6 +5,7 @@ const { handleMock, removeHandlerMock, listWorktreesMock, + assertWorktreeCleanForRemovalMock, addWorktreeMock, addSparseWorktreeMock, removeWorktreeMock, @@ -31,6 +32,7 @@ const { handleMock: vi.fn(), removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), + assertWorktreeCleanForRemovalMock: vi.fn(), addWorktreeMock: vi.fn(), addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), @@ -64,6 +66,7 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, + assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock, addWorktree: addWorktreeMock, addSparseWorktree: addSparseWorktreeMock, removeWorktree: removeWorktreeMock @@ -155,7 +158,9 @@ describe('registerWorktreeHandlers', () => { getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn() + removeWorktreeMeta: vi.fn(), + getAllWorktreeLineage: vi.fn(), + removeWorktreeLineage: vi.fn() } let runtimeStub: { resolveRemoteTrackingBase: ReturnType @@ -174,6 +179,7 @@ describe('registerWorktreeHandlers', () => { handleMock, removeHandlerMock, listWorktreesMock, + assertWorktreeCleanForRemovalMock, addWorktreeMock, addSparseWorktreeMock, removeWorktreeMock, @@ -205,6 +211,8 @@ describe('registerWorktreeHandlers', () => { store.getAllWorktreeMeta, store.setWorktreeMeta, store.removeWorktreeMeta, + store.getAllWorktreeLineage, + store.removeWorktreeLineage, killAllProcessesForWorktreeMock, getLocalPtyProviderMock ]) { @@ -215,6 +223,7 @@ describe('registerWorktreeHandlers', () => { providerStopped: 0, registryStopped: 0 }) + assertWorktreeCleanForRemovalMock.mockResolvedValue(undefined) getLocalPtyProviderMock.mockReturnValue({} as never) for (const key of Object.keys(handlers)) { @@ -244,6 +253,7 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) + store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') @@ -380,6 +390,81 @@ describe('registerWorktreeHandlers', () => { }) }) + it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-something', + head: 'abc123', + branch: 'feature/something', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature/something', + branchNameOverride: 'feature/something' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['check-ref-format', '--branch', 'feature/something'], + { cwd: '/workspace/repo' } + ) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-something', + 'feature/something', + 'origin/main', + false + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + path: '/workspace/feature-something', + branch: 'feature/something' + }) + }) + }) + + it('suffixes branchNameOverride without flattening slashes when the first branch collides', async () => { + getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) => + branch === 'feature/something' ? 'remote' : null + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-something-2', + head: 'abc123', + branch: 'feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature/something', + branchNameOverride: 'feature/something' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['check-ref-format', '--branch', 'feature/something-2'], + { cwd: '/workspace/repo' } + ) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-something-2', + 'feature/something-2', + 'origin/main', + false + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + path: '/workspace/feature-something-2', + branch: 'feature/something-2' + }) + }) + }) + it('persists a sanitized artifact title as the worktree display name', async () => { listWorktreesMock.mockResolvedValue([ { @@ -628,6 +713,104 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live', + head: 'abc123', + branch: 'refs/heads/live', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/missing-child': { + parentWorktreeId: 'repo-ssh::/remote/live' + }, + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + }, + 'repo-ssh::/remote/live': { + parentWorktreeId: 'other-repo::/elsewhere' + } + }) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === 'repo-ssh::/remote/missing-parent' + ? { instanceId: 'old-parent-instance' } + : undefined + ) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.removeWorktreeLineage).toHaveBeenCalledWith('repo-ssh::/remote/missing-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live') + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + + it('does not repeatedly rotate already-invalid missing parent metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + } + }) + store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' }) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + it('does not await a cold fetch when the remote-tracking base exists locally', async () => { const remoteBase = { remote: 'origin', @@ -1098,6 +1281,7 @@ describe('registerWorktreeHandlers', () => { comment: '', linkedIssue: null, linkedPR: null, + instanceId: 'existing-instance', isArchived: false, isUnread: false, isPinned: false, @@ -1114,6 +1298,44 @@ describe('registerWorktreeHandlers', () => { expect(listed[0].lastActivityAt).toBe(42) }) + it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'new-instance', + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + instanceId?: string + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/existing-wt', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + expect(listed[0].instanceId).toBe('new-instance') + }) + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { // Why: folder repos produce a synthetic worktree that flows through the // same list path. Without the stamp, adding a folder puts its card at the @@ -1650,6 +1872,9 @@ describe('registerWorktreeHandlers', () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) const callOrder: string[] = [] + assertWorktreeCleanForRemovalMock.mockImplementation(async () => { + callOrder.push('preflight') + }) killAllProcessesForWorktreeMock.mockImplementation(async () => { callOrder.push('kill') return { runtimeStopped: 1, providerStopped: 0, registryStopped: 0 } @@ -1669,7 +1894,73 @@ describe('registerWorktreeHandlers', () => { }) ) expect(removeWorktreeMock).toHaveBeenCalled() - expect(callOrder).toEqual(['kill', 'git']) + expect(callOrder).toEqual(['preflight', 'kill', 'git']) + }) + + it('fails dirty non-force deletes before PTY teardown', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + assertWorktreeCleanForRemovalMock.mockRejectedValue( + Object.assign(new Error('Worktree has uncommitted or untracked changes.'), { + stdout: '?? scratch.txt\n' + }) + ) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt') + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('formats preflight subprocess failures and does not tear down PTYs', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + assertWorktreeCleanForRemovalMock.mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: unable to read current working directory\n' + }) + ) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow( + 'Failed to delete worktree at /workspace/feature-wt. fatal: unable to read current working directory' + ) + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('falls through to orphan cleanup when preflight reports missing/non-repo worktree', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + assertWorktreeCleanForRemovalMock.mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: "fatal: '/workspace/feature-wt' is not a working tree" + }) + ) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) }) it('skips the PTY teardown for SSH-backed repos (design §6 out-of-scope)', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a22ad1d258a..1ac4e48217d 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { rm } from 'fs/promises' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { deleteWorktreeHistoryDir } from '../terminal-history' @@ -13,7 +14,11 @@ import type { Repo, WorktreeMeta } from '../../shared/types' -import { listWorktrees as listGitWorktrees, removeWorktree } from '../git/worktree' +import { + assertWorktreeCleanForRemoval, + listWorktrees as listGitWorktrees, + removeWorktree +} from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { getDefaultRemote } from '../git/repo' import { getPullRequestPushTarget, getWorkItem } from '../github/client' @@ -36,6 +41,7 @@ import { parseWorktreeId, areWorktreePathsEqual, formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from './worktree-logic' import { @@ -66,6 +72,12 @@ import { function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) if (existing) { + if (!existing.instanceId) { + // Why: profiles created before lineage shipped already have WorktreeMeta + // rows. Backfill on authoritative discovery so upgraded workspaces can + // immediately participate in instance-validated lineage. + return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + } return existing } return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) @@ -104,6 +116,40 @@ function rememberLocalWorktreeRoots( ]) } +function pruneLineageForMissingRepoWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if ( + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: path-derived IDs can disappear and later be reused by a different + // checkout. Once a successful scan proves the child is gone, drop its + // lineage so a future same-path worktree cannot inherit it. Missing + // parents stay readable so the UI can show the repairable "Missing + // parent" state. + store.removeWorktreeLineage(childId) + } + if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: keep the child lineage so the UI can show "Missing parent", but + // rotate the absent parent's stale identity once. If a different + // checkout later reuses that path, the old lineage stays invalid. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } +} + type SshWorktreeMetaCandidate = { path: string meta: WorktreeMeta @@ -179,6 +225,8 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:remove') ipcMain.removeHandler('worktrees:updateMeta') + ipcMain.removeHandler('worktrees:listLineage') + ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:createIssueCommandRunner') @@ -225,6 +273,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -292,6 +341,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -637,31 +687,6 @@ export function registerWorktreeHandlers( registeredWorktrees ).path - // Why: kill every PTY belonging to this worktree BEFORE git-level - // removal. The renderer pre-kills via shutdownWorktreeTerminals, but - // defensive teardown here protects against: (a) a future renderer bug, - // (b) a disconnected window, (c) an out-of-band window.api.worktrees.remove - // caller. Placement is before the SSH early-return so local-host PTYs - // are still reaped for local repos; SSH-backed PTYs are handled by the - // remote provider's own teardown (design §4.3, §6). - if (!repo.connectionId) { - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - localProvider: getLocalPtyProvider() - }) - .then((r) => { - const total = r.runtimeStopped + r.providerStopped + r.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` - ) - } - }) - .catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - } - if (repo.connectionId) { await provider!.removeWorktree(canonicalWorktreePath, args.force) runtime.clearOptimisticReconcileToken(args.worktreeId) @@ -689,6 +714,40 @@ export function registerWorktreeHandlers( await removeWorktreeSymlinks(canonicalWorktreePath, repo.symlinkPaths) } + let shouldTearDownPtys = true + try { + await assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + // Why: orphan cleanup does not need live shells to be killed first, + // and preflight did not prove the worktree is cleanly removable. + shouldTearDownPtys = false + } + + if (shouldTearDownPtys) { + // Why: once preflight proves normal deletion is clean, kill PTYs before + // git-level removal so shells cannot keep the directory busy. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider() + }) + .then((r) => { + const total = r.runtimeStopped + r.providerStopped + r.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` + ) + } + }) + .catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + } + try { await removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) } catch (error) { @@ -743,6 +802,27 @@ export function registerWorktreeHandlers( } ) + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return store.getAllWorktreeLineage() + }) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + // Why: the renderer continuously snapshots the computed sidebar order into // sortOrder so that it can be restored on cold start (when ephemeral signals // like running jobs and live terminals are gone). A single batch call avoids diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index afc59809973..cafff393e8f 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -100,6 +100,12 @@ export async function searchIssues( } }) ) + // Why: searchIssues returns Linear's relevance ranking. Re-sorting by + // updatedAt would discard relevance order for single-workspace results, + // diverging from Linear's web UI and pre-PR behavior. + if (entries.length === 1) { + return results.flat().slice(0, limit) + } return sortAndLimitIssues(results.flat(), limit) } diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 9d910daff21..e407a525b94 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -28,6 +28,7 @@ function buildMenuOptions() { onCheckForUpdates: vi.fn(), onOpenSettings: vi.fn(), onOpenFeatureTour: vi.fn(), + onOpenCrashReport: vi.fn(), onZoomIn: vi.fn(), onZoomOut: vi.fn(), onZoomReset: vi.fn(), @@ -172,7 +173,9 @@ describe('registerAppMenu', () => { expect(fileLabels).toEqual(expect.arrayContaining(['Export as PDF...', 'Settings', 'Exit'])) const helpLabels = getSubmenu(template, 'Help').map((item) => item.label) - expect(helpLabels).toEqual(expect.arrayContaining(['Feature tour', 'Check for Updates...'])) + expect(helpLabels).toEqual( + expect.arrayContaining(['Report Crash...', 'Feature tour', 'Check for Updates...']) + ) }) it.runIf(isMac)('keeps the macOS app-named menu with Settings and quit roles', () => { @@ -188,7 +191,7 @@ describe('registerAppMenu', () => { expect(fileLabels).not.toContain('Settings') expect(fileLabels).not.toContain('Exit') const helpLabels = getSubmenu(template, 'Help').map((item) => item.label) - expect(helpLabels).toEqual(['Feature tour']) + expect(helpLabels).toEqual(['Report Crash...', undefined, 'Feature tour']) }) it('routes Feature tour through its callback', () => { @@ -207,6 +210,21 @@ describe('registerAppMenu', () => { expect(options.onOpenFeatureTour).toHaveBeenCalledWith(targetWindow) }) + it('routes Report Crash through its callback', () => { + const options = buildMenuOptions() + registerAppMenu(options) + + const crashReportItem = getSubmenu(getTemplate(), 'Help').find( + (entry) => entry.label === 'Report Crash...' + ) + + const targetWindow = {} as Electron.BaseWindow + crashReportItem?.click?.({} as never, targetWindow, {} as Electron.KeyboardEvent) + + expect(options.onOpenCrashReport).toHaveBeenCalledTimes(1) + expect(options.onOpenCrashReport).toHaveBeenCalledWith(targetWindow) + }) + it('exposes an Appearance submenu under View with checkbox items reflecting state', () => { const options = buildMenuOptions() options.getAppearanceState.mockReturnValue({ diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2d14dfc36fc..91d78d88559 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -11,6 +11,7 @@ export type AppearanceMenuKey = keyof AppearanceMenuState type RegisterAppMenuOptions = { onOpenSettings: () => void onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void + onOpenCrashReport: (window?: Electron.BaseWindow | null) => void onCheckForUpdates: (options: { includePrerelease: boolean }) => void onZoomIn: () => void onZoomOut: () => void @@ -25,6 +26,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { const { onOpenSettings, onOpenFeatureTour, + onOpenCrashReport, onCheckForUpdates, onZoomIn, onZoomOut, @@ -80,6 +82,11 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { click: (_menuItem, window) => onOpenFeatureTour(window) } + const crashReportItem: Electron.MenuItemConstructorOptions = { + label: 'Report Crash...', + click: (_menuItem, window) => onOpenCrashReport(window) + } + const exportPdfItem: Electron.MenuItemConstructorOptions = { label: 'Export as PDF...', accelerator: 'CmdOrCtrl+Shift+E', @@ -264,6 +271,8 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { const helpMenu: Electron.MenuItemConstructorOptions = { label: 'Help', submenu: [ + crashReportItem, + { type: 'separator' }, featureTourItem, ...(isMac ? [] diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts new file mode 100644 index 00000000000..625effd4ce6 --- /dev/null +++ b/src/main/opencode-usage/scanner.test.ts @@ -0,0 +1,269 @@ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' + +const WORKTREE = '/workspace/repo' + +let tempDirs: string[] = [] + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function worktrees() { + return [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: WORKTREE, + displayName: 'Repo', + canonicalPath: WORKTREE + } + ] +} + +describe('parseOpenCodeUsageRow', () => { + it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { + const parsed = parseOpenCodeUsageRow({ + id: 'message-1', + session_id: 'session-1', + time_created: 1_777_777_700_000, + time_updated: null, + directory: null, + title: null, + worktree: null, + session_model: null, + data: JSON.stringify({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + path: { cwd: `${WORKTREE}/packages/app` }, + cost: 0.0123, + tokens: { + input: 1000, + output: 250, + reasoning: 100, + total: 1350, + cache: { read: 400, write: 25 } + }, + time: { + completed: 1_777_777_800_000 + } + }) + }) + + expect(parsed).toEqual({ + sessionId: 'session-1', + timestamp: new Date(1_777_777_800_000).toISOString(), + cwd: `${WORKTREE}/packages/app`, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.0123, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + }) + }) +}) + +describe('parseOpenCodeUsageDatabase', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] + }) + + it('uses materialized session token totals when the OpenCode DB has them', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE) + db.prepare( + `INSERT INTO session ( + id, project_id, directory, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'session-1', + 'project-1', + `${WORKTREE}/packages/app`, + 'Build feature', + JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }), + 0.06, + 1000, + 500, + 100, + 250, + 1_777_777_700_000, + 1_777_777_800_000 + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]).toMatchObject({ + sessionId: 'session-1', + primaryModel: 'anthropic/claude-sonnet-4-5', + primaryProjectLabel: 'Repo', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 250, + totalOutputTokens: 500, + totalReasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + expect(parsed.dailyAggregates).toEqual([ + expect.objectContaining({ + projectLabel: 'Repo', + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + ]) + }) + + it('supports session_message tables without a type column', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + cost: 0.03, + tokens: { + input: 800, + output: 200, + reasoning: 50, + cache: { read: 100, write: 0 } + } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]).toMatchObject({ + primaryModel: 'openai/gpt-5.5', + primaryProjectLabel: 'Repo', + totalTokens: 1050, + estimatedCostUsd: 0.03 + }) + }) + + it('prefers session_message rows over legacy message rows to avoid double counting', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + type TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + 'session-message-1', + 'session-1', + 'assistant', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } } + }) + ) + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'legacy-message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + role: 'assistant', + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]?.totalTokens).toBe(120) + expect(parsed.sessions[0]?.eventCount).toBe(1) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts new file mode 100644 index 00000000000..3066d707e7b --- /dev/null +++ b/src/main/opencode-usage/scanner.ts @@ -0,0 +1,931 @@ +/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ +import Database from 'better-sqlite3' +import { existsSync } from 'fs' +import { readdir, realpath, stat } from 'fs/promises' +import { homedir } from 'os' +import { isAbsolute, join, posix, win32 } from 'path' +import type { Repo } from '../../shared/types' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import type { + OpenCodeUsageAttributedEvent, + OpenCodeUsageDailyAggregate, + OpenCodeUsageLocationBreakdown, + OpenCodeUsageLocationModelBreakdown, + OpenCodeUsageModelBreakdown, + OpenCodeUsageParsedEvent, + OpenCodeUsagePersistedDatabase, + OpenCodeUsageProcessedDatabase, + OpenCodeUsageSession +} from './types' + +export type OpenCodeUsageWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +const YIELD_EVERY_DATABASES = 2 + +function ensureNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function normalizeComparablePath(pathValue: string, platform = process.platform): string { + const normalized = pathValue.replace(/\\/g, '/') + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? normalized.toLowerCase() + : normalized +} + +function normalizeFsPath(pathValue: string, platform = process.platform): string { + if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { + return win32.normalize(win32.resolve(pathValue)) + } + return posix.normalize(posix.resolve(pathValue)) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getXdgDataHome(): string { + if (process.env.XDG_DATA_HOME?.trim()) { + return process.env.XDG_DATA_HOME.trim() + } + if (process.platform === 'win32') { + return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') + } + return join(homedir(), '.local', 'share') +} + +function getOpenCodeDataDirectory(): string { + return join(getXdgDataHome(), 'opencode') +} + +function getOpenCodeDatabasePathFromEnv(): string | null { + const raw = process.env.OPENCODE_DB?.trim() + if (!raw) { + return null + } + if (raw === ':memory:') { + return null + } + return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) +} + +export async function listOpenCodeDatabases(): Promise { + const envPath = getOpenCodeDatabasePathFromEnv() + if (envPath) { + return existsSync(envPath) ? [envPath] : [] + } + + try { + const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .sort() + } catch { + return [] + } +} + +export async function getProcessedDatabaseInfo( + dbPath: string +): Promise { + const dbStat = await stat(dbPath) + return { + path: dbPath, + mtimeMs: dbStat.mtimeMs, + size: dbStat.size + } +} + +async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} + +function parseJsonObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string') { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function extractModelLabel(data: Record, sessionModel: unknown): string | null { + const directModel = extractString(data.modelID) ?? extractString(data.modelId) + const directProvider = extractString(data.providerID) ?? extractString(data.providerId) + if (directModel) { + return directProvider ? `${directProvider}/${directModel}` : directModel + } + + const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) + if (!modelObject) { + return null + } + const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) + const providerID = extractString(modelObject.providerID) + if (!modelID) { + return null + } + return providerID ? `${providerID}/${modelID}` : modelID +} + +function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { + const pathData = parseJsonObject(data.path) + return ( + extractString(pathData?.cwd) ?? + extractString(row.directory) ?? + extractString(row.worktree) ?? + null + ) +} + +function normalizeMillis(value: unknown): number | null { + const numeric = ensureNumber(value) + if (numeric <= 0) { + return null + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric +} + +function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { + const timeData = parseJsonObject(data.time) + const millis = + normalizeMillis(timeData?.completed) ?? + normalizeMillis(timeData?.created) ?? + normalizeMillis(row.time_updated) ?? + normalizeMillis(row.time_created) + return millis ? new Date(millis).toISOString() : null +} + +export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { + const data = parseJsonObject(row.data) + if (!data) { + return null + } + + const tokens = parseJsonObject(data.tokens) + if (!tokens) { + return null + } + const cache = parseJsonObject(tokens.cache) + const inputTokens = ensureNumber(tokens.input) + const outputTokens = ensureNumber(tokens.output) + const reasoningOutputTokens = ensureNumber(tokens.reasoning) + const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) + const totalTokens = + ensureNumber(tokens.total) > 0 + ? ensureNumber(tokens.total) + : inputTokens + outputTokens + reasoningOutputTokens + + if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { + return null + } + + const timestamp = extractTimestamp(data, row) + if (!timestamp) { + return null + } + + return { + sessionId: row.session_id, + timestamp, + cwd: extractCwd(data, row), + model: extractModelLabel(data, row.session_model), + estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + } +} + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(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 isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' +} + +async function buildWorktreesWithCanonicalPaths( + worktrees: OpenCodeUsageWorktreeRef[] +): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { + const canonicalized = await Promise.all( + worktrees.map(async (worktree) => ({ + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + })) + ) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +} + +async function canonicalizePath(pathValue: string): Promise { + try { + return normalizeFsPath(await realpath(pathValue)) + } catch { + return normalizeFsPath(pathValue) + } +} + +function findContainingWorktree( + cwd: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): OpenCodeUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeOpenCodeUsageEvent( + event: OpenCodeUsageParsedEvent, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession { + return { + sessionId: event.sessionId, + firstTimestamp: event.timestamp, + lastTimestamp: event.timestamp, + primaryModel: event.model, + hasMixedModels: false, + primaryProjectLabel: event.projectLabel, + hasMixedLocations: false, + primaryWorktreeId: event.worktreeId, + primaryRepoId: event.repoId, + eventCount: 0, + totalInputTokens: 0, + totalCachedInputTokens: 0, + totalOutputTokens: 0, + totalReasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + locationBreakdown: [], + modelBreakdown: [], + locationModelBreakdown: [] + } +} + +function createEmptyDailyAggregate( + event: OpenCodeUsageAttributedEvent +): OpenCodeUsageDailyAggregate { + return { + day: event.day, + model: event.model, + projectKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } +} + +function mergeLocationBreakdown( + target: OpenCodeUsageLocationBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeModelBreakdown( + target: OpenCodeUsageModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const key = event.model ?? 'unknown' + const existing = target.find((entry) => entry.modelKey === key) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + modelKey: key, + modelLabel: event.model ?? 'Unknown model', + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeLocationModelBreakdown( + target: OpenCodeUsageLocationModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const modelKey = event.model ?? 'unknown' + const existing = + target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? + null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + modelKey, + modelLabel: event.model ?? 'Unknown model', + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} { + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const event of events) { + const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) + if (!sessionsById.has(event.sessionId)) { + sessionsById.set(event.sessionId, session) + } + if (event.timestamp < session.firstTimestamp) { + session.firstTimestamp = event.timestamp + } + if (event.timestamp >= session.lastTimestamp) { + session.lastTimestamp = event.timestamp + } + session.eventCount++ + session.totalInputTokens += event.inputTokens + session.totalCachedInputTokens += event.cachedInputTokens + session.totalOutputTokens += event.outputTokens + session.totalReasoningOutputTokens += event.reasoningOutputTokens + session.totalTokens += event.totalTokens + session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd) + mergeLocationBreakdown(session.locationBreakdown, event) + mergeModelBreakdown(session.modelBreakdown, event) + mergeLocationModelBreakdown(session.locationModelBreakdown, event) + + const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') + const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) + if (!dailyByKey.has(dailyKey)) { + dailyByKey.set(dailyKey, daily) + } + daily.eventCount++ + daily.inputTokens += event.inputTokens + daily.cachedInputTokens += event.cachedInputTokens + daily.outputTokens += event.outputTokens + daily.reasoningOutputTokens += event.reasoningOutputTokens + daily.totalTokens += event.totalTokens + daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd) + } + + return { + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +function finalizeSessions(sessionsById: Map): OpenCodeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + const primaryLocation = session.locationBreakdown[0] ?? null + const primaryModel = session.modelBreakdown[0] ?? null + session.primaryProjectLabel = + session.locationBreakdown.length <= 1 + ? (primaryLocation?.projectLabel ?? 'Unknown location') + : 'Multiple locations' + session.hasMixedLocations = session.locationBreakdown.length > 1 + session.primaryWorktreeId = primaryLocation?.worktreeId ?? null + session.primaryRepoId = primaryLocation?.repoId ?? null + session.primaryModel = + session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' + session.hasMixedModels = session.modelBreakdown.length > 1 + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + +function mergeSessions( + target: Map, + sessions: OpenCodeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + existing.firstTimestamp = + session.firstTimestamp < existing.firstTimestamp + ? session.firstTimestamp + : existing.firstTimestamp + existing.lastTimestamp = + session.lastTimestamp > existing.lastTimestamp + ? session.lastTimestamp + : existing.lastTimestamp + existing.eventCount += session.eventCount + existing.totalInputTokens += session.totalInputTokens + existing.totalCachedInputTokens += session.totalCachedInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens + existing.totalTokens += session.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd) + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.eventCount += location.eventCount + existingLocation.inputTokens += location.inputTokens + existingLocation.cachedInputTokens += location.cachedInputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.reasoningOutputTokens += location.reasoningOutputTokens + existingLocation.totalTokens += location.totalTokens + existingLocation.estimatedCostUsd = addCost( + existingLocation.estimatedCostUsd, + location.estimatedCostUsd + ) + } else { + existing.locationBreakdown.push({ ...location }) + } + } + + for (const model of session.modelBreakdown) { + const existingModel = + existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null + if (existingModel) { + existingModel.eventCount += model.eventCount + existingModel.inputTokens += model.inputTokens + existingModel.cachedInputTokens += model.cachedInputTokens + existingModel.outputTokens += model.outputTokens + existingModel.reasoningOutputTokens += model.reasoningOutputTokens + existingModel.totalTokens += model.totalTokens + existingModel.estimatedCostUsd = addCost( + existingModel.estimatedCostUsd, + model.estimatedCostUsd + ) + } else { + existing.modelBreakdown.push({ ...model }) + } + } + + for (const locationModel of session.locationModelBreakdown) { + const existingLocationModel = + existing.locationModelBreakdown.find( + (entry) => + entry.locationKey === locationModel.locationKey && + entry.modelKey === locationModel.modelKey + ) ?? null + if (existingLocationModel) { + existingLocationModel.eventCount += locationModel.eventCount + existingLocationModel.inputTokens += locationModel.inputTokens + existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens + existingLocationModel.outputTokens += locationModel.outputTokens + existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens + existingLocationModel.totalTokens += locationModel.totalTokens + existingLocationModel.estimatedCostUsd = addCost( + existingLocationModel.estimatedCostUsd, + locationModel.estimatedCostUsd + ) + } else { + existing.locationModelBreakdown.push({ ...locationModel }) + } + } + } +} + +function mergeDailyAggregates( + target: Map, + dailyAggregates: OpenCodeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.eventCount += aggregate.eventCount + existing.inputTokens += aggregate.inputTokens + existing.cachedInputTokens += aggregate.cachedInputTokens + existing.outputTokens += aggregate.outputTokens + existing.reasoningOutputTokens += aggregate.reasoningOutputTokens + existing.totalTokens += aggregate.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd) + } +} + +export async function parseOpenCodeUsageDatabase( + dbPath: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const processedDatabase = await getProcessedDatabaseInfo(dbPath) + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + db.pragma('query_only = ON') + const events: OpenCodeUsageAttributedEvent[] = [] + for (const row of selectUsageRows(db)) { + const parsed = parseOpenCodeUsageRow(row) + if (!parsed) { + continue + } + const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) + if (attributed) { + events.push(attributed) + } + } + return { + ...processedDatabase, + ...aggregateOpenCodeUsage(events) + } + } finally { + db.close() + } +} + +export async function scanOpenCodeUsageDatabases( + worktrees: OpenCodeUsageWorktreeRef[], + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] +): Promise<{ + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +}> { + const dbPaths = await listOpenCodeDatabases() + const previousByPath = new Map( + previousProcessedDatabases.map((database) => [database.path, database]) + ) + const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] + const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const [index, dbPath] of dbPaths.entries()) { + const databaseInfo = await getProcessedDatabaseInfo(dbPath) + const previous = previousByPath.get(dbPath) + const canReuse = + previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size + const processed = canReuse + ? previous + : await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths) + + processedDatabases.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + + if ((index + 1) % YIELD_EVERY_DATABASES === 0) { + await yieldToEventLoop() + } + } + + return { + processedDatabases, + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +export function createWorktreeRefs( + repos: Repo[], + worktreesByRepo: Map +): OpenCodeUsageWorktreeRef[] { + const refs: OpenCodeUsageWorktreeRef[] = [] + for (const repo of repos) { + for (const worktree of worktreesByRepo.get(repo.id) ?? []) { + refs.push({ + repoId: repo.id, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + } + } + return refs +} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts new file mode 100644 index 00000000000..bca001390c2 --- /dev/null +++ b/src/main/opencode-usage/store.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedState, + OpenCodeUsageSession +} from './types' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { OpenCodeUsageStore, normalizePersistedState } from './store' + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +function createStoreWithState(state: Partial): OpenCodeUsageStore { + const store = new OpenCodeUsageStore({ + getRepos: () => [], + getWorktreeMeta: () => undefined + } as never) + + ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { + ...getDefaultState(), + ...state + } + + return store +} + +function makeSession(overrides: Partial = {}): OpenCodeUsageSession { + const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo' + const repoId = overrides.primaryRepoId ?? 'repo-1' + const projectLabel = overrides.primaryProjectLabel ?? 'Repo' + const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5' + return { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T10:00:00.000Z', + lastTimestamp: '2026-04-09T10:10:00.000Z', + primaryModel: model, + hasMixedModels: false, + primaryProjectLabel: projectLabel, + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: repoId, + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + locationBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel, + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + modelBreakdown: [ + { + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + locationModelBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + ...overrides + } +} + +function makeDaily( + overrides: Partial = {} +): OpenCodeUsageDailyAggregate { + const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo' + return { + day: '2026-04-09', + model: 'anthropic/claude-sonnet-4-5', + projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel: worktreeId ? 'Repo' : 'outside/repo', + repoId: worktreeId ? 'repo-1' : null, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + ...overrides + } +} + +describe('OpenCodeUsageStore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ + primaryProjectLabel: 'outside/repo', + primaryWorktreeId: null, + primaryRepoId: null + }) + ], + dailyAggregates: [ + makeDaily({ + projectKey: 'cwd:/outside/repo', + projectLabel: 'outside/repo', + repoId: null, + worktreeId: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.hasAnyOpenCodeData).toBe(false) + expect(summary.sessions).toBe(0) + expect(summary.events).toBe(0) + }) + + it('uses recorded OpenCode costs and token totals without model pricing inference', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ sessionId: 'session-1' }), + makeSession({ + sessionId: 'session-2', + primaryModel: 'openai/gpt-5.5', + totalTokens: 2000, + estimatedCostUsd: null, + modelBreakdown: [ + { + modelKey: 'openai/gpt-5.5', + modelLabel: 'openai/gpt-5.5', + eventCount: 1, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + } + ] + }) + ], + dailyAggregates: [ + makeDaily(), + makeDaily({ + model: 'openai/gpt-5.5', + eventCount: 2, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + const daily = await store.getDaily('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary).toMatchObject({ + sessions: 2, + events: 3, + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350, + estimatedCostUsd: 0.05, + topModel: 'openai/gpt-5.5', + topProject: 'Repo', + hasAnyOpenCodeData: true + }) + expect(daily).toEqual([ + { + day: '2026-04-09', + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350 + } + ]) + expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({ + sessions: 1, + estimatedCostUsd: null + }) + }) + + it('returns recent sessions with OpenCode event and token fields', async () => { + const store = createStoreWithState({ + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + + const sessions = await store.getRecentSessions('orca', '30d', 5) + + expect(sessions).toEqual([ + { + sessionId: 'session-1', + lastActiveAt: '2026-04-09T10:10:00.000Z', + durationMinutes: 10, + projectLabel: 'Repo', + model: 'anthropic/claude-sonnet-4-5', + events: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + } + ]) + }) + + it('normalizes persisted OpenCode state by schema version', () => { + expect( + normalizePersistedState({ + ...getDefaultState(), + schemaVersion: 0, + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + } + ], + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + ).toEqual(getDefaultState()) + + expect( + normalizePersistedState({ + ...getDefaultState(), + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [], + dailyAggregates: [] + } + ] + }).processedDatabases + ).toHaveLength(1) + }) +}) diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts new file mode 100644 index 00000000000..1fd7d50830c --- /dev/null +++ b/src/main/opencode-usage/store.ts @@ -0,0 +1,464 @@ +/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +import { app } from 'electron' +import { dirname, join } from 'path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../shared/opencode-usage-types' +import type { Store } from '../persistence' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' +import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' + +const SCHEMA_VERSION = 1 +const STALE_MS = 5 * 60_000 + +let _openCodeUsageFile: string | null = null + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizePersistedState( + state: OpenCodeUsagePersistedState +): OpenCodeUsagePersistedState { + if (state.schemaVersion !== SCHEMA_VERSION) { + return getDefaultState() + } + return { + ...state, + processedDatabases: (state.processedDatabases ?? []).map((database) => ({ + ...database, + sessions: (database.sessions ?? []).map(normalizeSessionCost), + dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) + })), + sessions: state.sessions.map(normalizeSessionCost), + dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) + } +} + +export function initOpenCodeUsagePath(): void { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') +} + +function getOpenCodeUsageFile(): string { + if (!_openCodeUsageFile) { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') + } + return _openCodeUsageFile +} + +function getRangeCutoff(range: OpenCodeUsageRange): 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 { + 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) +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function normalizeDailyAggregateCost( + entry: OpenCodeUsageDailyAggregate +): OpenCodeUsageDailyAggregate { + return { + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + } +} + +function normalizeSessionCost( + session: OpenCodeUsagePersistedState['sessions'][number] +): OpenCodeUsagePersistedState['sessions'][number] { + return { + ...session, + estimatedCostUsd: session.estimatedCostUsd ?? null, + locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })) + } +} + +export class OpenCodeUsageStore { + private state: OpenCodeUsagePersistedState + private readonly store: Store + private scanPromise: Promise | null = null + + constructor(store: Store) { + this.store = store + this.state = this.load() + } + + private load(): OpenCodeUsagePersistedState { + try { + const usageFile = getOpenCodeUsageFile() + if (!existsSync(usageFile)) { + return getDefaultState() + } + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState + return normalizePersistedState({ + ...getDefaultState(), + ...parsed, + scanState: { + ...getDefaultState().scanState, + ...parsed.scanState + } + }) + } catch (error) { + console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) + return getDefaultState() + } + } + + private writeToDisk(): void { + const usageFile = getOpenCodeUsageFile() + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) + } + + async setEnabled(enabled: boolean): Promise { + this.state.scanState.enabled = enabled + this.writeToDisk() + return this.getScanState() + } + + getScanState(): OpenCodeUsageScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } + } + + async refresh(force = false): Promise { + 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 { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) + const result = await scanOpenCodeUsageDatabases( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state.processedDatabases + : [] + ) + this.state.processedDatabases = result.processedDatabases + 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 + this.writeToDisk() + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + this.writeToDisk() + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + async getSummary( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd: number | null = null + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + } + + const topModel = + [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd, + topModel, + topProject, + hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } + } + + async getDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const byDay = new Map() + for (const row of this.getFilteredDaily(scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + } + + async getBreakdown( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + kind: OpenCodeUsageBreakdownKind + ): Promise { + await this.refresh(false) + const rows = new Map() + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) + rows.set(key, existing) + } + + if (kind === 'model') { + for (const session of filteredSessions) { + for (const entry of session.modelBreakdown) { + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + } + } + } else { + for (const session of filteredSessions) { + for (const entry of session.locationBreakdown) { + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + } + } + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + } + + async getRecentSessions( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + limit = 10 + ): Promise { + await this.refresh(false) + return this.getFilteredSessions(scope, range) + .slice(0, limit) + .map( + (session): OpenCodeUsageSessionRow => ({ + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - + new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ), + projectLabel: session.primaryProjectLabel, + model: session.primaryModel, + events: session.eventCount, + inputTokens: session.totalInputTokens, + cachedInputTokens: session.totalCachedInputTokens, + outputTokens: session.totalOutputTokens, + reasoningOutputTokens: session.totalReasoningOutputTokens, + totalTokens: session.totalTokens + }) + ) + } + + private getFilteredDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): OpenCodeUsageDailyAggregate[] { + const cutoff = getRangeCutoff(range) + return this.state.dailyAggregates.filter((row) => { + if (scope === 'orca' && !row.worktreeId) { + return false + } + if (cutoff && row.day < cutoff) { + return false + } + return true + }) + } + + private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { + const cutoff = getRangeCutoff(range) + return this.state.sessions.filter((session) => { + if (scope === 'orca' && !session.primaryWorktreeId) { + return false + } + if (cutoff) { + const day = getLocalDay(session.lastTimestamp) + if (!day || day < cutoff) { + return false + } + } + return true + }) + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts new file mode 100644 index 00000000000..ed7f3d2143c --- /dev/null +++ b/src/main/opencode-usage/types.ts @@ -0,0 +1,124 @@ +export type OpenCodeUsageProcessedDatabase = { + path: string + mtimeMs: number + size: number +} + +export type OpenCodeUsageLocationBreakdown = { + locationKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageModelBreakdown = { + modelKey: string + modelLabel: string + estimatedCostUsd: number | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageLocationModelBreakdown = { + locationKey: string + modelKey: string + modelLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSession = { + sessionId: string + firstTimestamp: string + lastTimestamp: string + primaryModel: string | null + hasMixedModels: boolean + primaryProjectLabel: string + hasMixedLocations: boolean + primaryWorktreeId: string | null + primaryRepoId: string | null + eventCount: number + totalInputTokens: number + totalCachedInputTokens: number + totalOutputTokens: number + totalReasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + locationBreakdown: OpenCodeUsageLocationBreakdown[] + modelBreakdown: OpenCodeUsageModelBreakdown[] + locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[] +} + +export type OpenCodeUsageDailyAggregate = { + day: string + model: string | null + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} + +export type OpenCodeUsagePersistedState = { + schemaVersion: number + worktreeFingerprint: string | null + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] + scanState: { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + } +} + +export type OpenCodeUsageParsedEvent = { + sessionId: string + timestamp: string + model: string | null + cwd: string | null + estimatedCostUsd: number | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ec9aceee279..79ca0852ee2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5,8 +5,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types' -import { isTerminalLeafId } from '../shared/stable-pane-id' +import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types' +import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' // Shared mutable state so the electron mock can reference a per-test directory @@ -15,6 +15,34 @@ const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111' const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222' const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333' const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444' +const REORDERED_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }, + { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, + { + id: 'in-progress', + label: 'In progress', + color: 'conductor-progress', + icon: 'conductor-progress' + }, + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } +] +const LEGACY_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, + { id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }, + { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, + { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } +] +const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' }, + { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, + { + id: 'in-progress', + label: 'In progress', + color: 'conductor-progress', + icon: 'conductor-progress' + }, + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } +] vi.mock('electron', () => ({ app: { @@ -79,6 +107,17 @@ const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ( ...overrides }) +const makeWorktreeLineage = (overrides: Partial = {}): WorktreeLineage => ({ + worktreeId: 'r1::/path/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'r1::/path/parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1, + ...overrides +}) + function makeSessionWithTerminalBuffers(): WorkspaceSessionState { return { activeRepoId: 'local-repo', @@ -163,8 +202,11 @@ describe('Store', () => { expect(settings.editorAutoSaveDelayMs).toBe(1000) expect(settings.terminalFontSize).toBe(14) expect(settings.terminalFontWeight).toBe(500) + expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) + expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) + expect(settings.openInApplications).toEqual([]) expect(settings.experimentalActivity).toBe(true) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) @@ -181,6 +223,33 @@ describe('Store', () => { expect(ui.lastUpdateCheckAt).toBeNull() }) + it('preserves legacy none grouping as ungrouped workspaces', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { groupBy: 'none' } + }) + const store = await createStore() + expect(store.getUI().groupBy).toBe('none') + }) + + it('normalizes interim flat grouping back to none', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { groupBy: 'flat' } + }) + const store = await createStore() + expect(store.getUI().groupBy).toBe('none') + }) + + it('preserves explicit workspace status grouping', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { groupBy: 'workspace-status' } + }) + const store = await createStore() + expect(store.getUI().groupBy).toBe('workspace-status') + }) + // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { @@ -220,6 +289,135 @@ describe('Store', () => { expect(store.getRepos()).toHaveLength(1) }) + it('remaps persisted agent acknowledgement pane keys when terminal leaves migrate to UUIDs', async () => { + const acknowledgedAt = 1_700_000_000_000 + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: {}, + ui: { + acknowledgedAgentsByPaneKey: { + 'tab1:0': acknowledgedAt, + 'tab1:pane:1': acknowledgedAt - 1_000, + 'other-tab:0': acknowledgedAt - 2_000 + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'repo1::/worktree', + activeTabId: 'tab1', + tabsByWorktree: { + 'repo1::/worktree': [ + makeTerminalTab({ + id: 'tab1', + ptyId: 'pty1', + worktreeId: 'repo1::/worktree' + }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '0' }, + second: { type: 'leaf', leafId: 'pane:1' } + }, + activeLeafId: '0', + expandedLeafId: null, + ptyIdsByLeafId: { '0': 'pty1', 'pane:1': 'pty2' } + } + } + } + }) + + const store = await createStore() + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const migratedLeafIds = Object.keys(layout.ptyIdsByLeafId ?? {}) + + expect(migratedLeafIds).toHaveLength(2) + expect(migratedLeafIds.every(isTerminalLeafId)).toBe(true) + + const ui = store.getUI() + expect(ui.acknowledgedAgentsByPaneKey).toEqual({ + [makePaneKey('tab1', migratedLeafIds[0])]: acknowledgedAt, + [makePaneKey('tab1', migratedLeafIds[1])]: acknowledgedAt - 1_000, + 'other-tab:0': acknowledgedAt - 2_000 + }) + }) + + it('keeps the newest acknowledgement when legacy and migrated pane keys collide', async () => { + const legacyAcknowledgedAt = 1_700_000_000_000 + const migratedAcknowledgedAt = legacyAcknowledgedAt + 5_000 + const migratedPaneKey = makePaneKey('tab1', TEST_LEAF_1) + + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: {}, + ui: { + acknowledgedAgentsByPaneKey: { + 'tab1:0': legacyAcknowledgedAt, + [migratedPaneKey]: migratedAcknowledgedAt + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'repo1::/worktree', + activeTabId: 'tab1', + tabsByWorktree: { + 'repo1::/worktree': [ + makeTerminalTab({ + id: 'tab1', + ptyId: 'pty1', + worktreeId: 'repo1::/worktree' + }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty1' } + } + } + } + }) + + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'repo1::/worktree', + activeTabId: 'tab1', + tabsByWorktree: { + 'repo1::/worktree': [ + makeTerminalTab({ + id: 'tab1', + ptyId: 'pty1', + worktreeId: 'repo1::/worktree' + }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: '0' }, + activeLeafId: '0', + expandedLeafId: null, + ptyIdsByLeafId: { '0': 'pty1' } + } + } + }) + + expect(store.getUI().acknowledgedAgentsByPaneKey).toEqual({ + [migratedPaneKey]: migratedAcknowledgedAt + }) + }) + it('can clear an automation back to the project default branch', async () => { const store = await createStore() store.addRepo(makeRepo({ worktreeBaseRef: 'origin/main' })) @@ -271,6 +469,98 @@ describe('Store', () => { expect(second.title).toBe('Nightly run 2') }) + it('snapshots automation run workspace names for deleted-workspace history', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + store.setWorktreeMeta('wt1', { displayName: 'Nightly workspace' }) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + store.removeWorktreeMeta('wt1') + + expect(run.workspaceDisplayName).toBe('Nightly workspace') + expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe( + 'Nightly workspace' + ) + }) + + it('backfills automation run workspace names before workspace deletion', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + + const updatedCount = store.snapshotAutomationRunWorkspaceDisplayName('wt1', 'Deleted workspace') + + expect(updatedCount).toBe(1) + expect(store.listAutomationRuns(automation.id)[0].workspaceDisplayName).toBe( + 'Deleted workspace' + ) + }) + + it('persists automation run output snapshots across later status updates', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Nightly', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + + store.updateAutomationRun({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + outputSnapshot: { + format: 'plain_text', + content: 'Run finished', + capturedAt: 1, + truncated: false + }, + error: null + }) + store.updateAutomationRun({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + usage: null, + error: null + }) + + expect(store.listAutomationRuns(automation.id)[0].outputSnapshot).toMatchObject({ + content: 'Run finished', + truncated: false + }) + }) + // ── 3. Corrupt JSON → falls back to defaults ──────────────────────── it('falls back to defaults when data file contains invalid JSON', async () => { @@ -305,13 +595,56 @@ describe('Store', () => { expect(store.getSettings().editorAutoSaveDelayMs).toBe(1000) expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) + expect(store.getSettings().showGitIgnoredFiles).toBe(true) expect(store.getSettings().showTasksButton).toBe(true) + expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false) + expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(store.getSettings().experimentalActivity).toBe(true) expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) }) + it('normalizes malformed visible task providers on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { visibleTaskProviders: ['gitlab', 'unknown', 'gitlab'] }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab']) + }) + + it('normalizes persisted open-in applications on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + openInApplications: [ + { id: 'cursor', label: ' Cursor ', command: ' cursor ' }, + { id: 'cursor', label: 'Dup', command: 'dup' }, + { id: '', label: 'Zed', command: 'zed' }, + { id: 'bad', label: ' ', command: 'bad' } + ] + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().openInApplications).toEqual([ + { id: 'cursor', label: 'Cursor', command: 'cursor' }, + { id: 'open-in-3', label: 'Zed', command: 'zed' } + ]) + }) + it('migrates the legacy floating terminal disabled default to enabled', async () => { writeDataFile({ schemaVersion: 1, @@ -417,6 +750,21 @@ describe('Store', () => { expect(store.getSettings().rightSidebarOpenByDefault).toBe(true) }) + it('preserves terminalUseSeparateLightTheme when persisted as false', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalUseSeparateLightTheme: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { @@ -454,6 +802,40 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('removeRepo deletes child and parent lineage for the repo', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + store.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) + + store.setWorktreeLineage( + 'r1::/path/child', + makeWorktreeLineage({ + worktreeId: 'r1::/path/child', + parentWorktreeId: 'r1::/path/parent' + }) + ) + store.setWorktreeLineage( + 'r2::/other-child', + makeWorktreeLineage({ + worktreeId: 'r2::/other-child', + parentWorktreeId: 'r1::/path/parent' + }) + ) + store.setWorktreeLineage( + 'r2::/other', + makeWorktreeLineage({ + worktreeId: 'r2::/other', + parentWorktreeId: 'r2::/parent' + }) + ) + + store.removeRepo('r1') + + expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined() + expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined() + expect(store.getWorktreeLineage('r2::/other')).toBeDefined() + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { @@ -546,6 +928,20 @@ describe('Store', () => { expect(updated.branchPrefix).toBe('git-username') }) + it('updateSettings normalizes open-in applications', async () => { + const store = await createStore() + const updated = store.updateSettings({ + openInApplications: [ + { id: 'cursor', label: ' Cursor ', command: ' cursor ' }, + { id: 'cursor', label: 'Dup', command: 'dup' }, + { id: 'bad', label: '', command: 'bad' } + ] + }) + expect(updated.openInApplications).toEqual([ + { id: 'cursor', label: 'Cursor', command: 'cursor' } + ]) + }) + it('updateSettings toggles editorAutoSave', async () => { const store = await createStore() expect(store.getSettings().editorAutoSave).toBe(false) @@ -733,6 +1129,127 @@ describe('Store', () => { expect(store.getUI().sortBy).toBe('recent') }) + it('repairs the known-bad reordered default workspace statuses once on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + const ui = store.getUI() + expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([ + 'completed', + 'in-review', + 'in-progress', + 'todo' + ]) + expect(ui.workspaceStatuses?.[0]?.label).toBe('Done') + expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true) + expect(ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) + + store.flush() + const persisted = readDataFile() as { + ui?: { + workspaceStatuses?: typeof REORDERED_DEFAULT_WORKSPACE_STATUSES + _workspaceStatusesDefaultOrderMigrated?: boolean + _workspaceStatusesDefaultWorkflowMigrated?: boolean + _workspaceStatusesDefaultVisualsMigrated?: boolean + } + } + expect(persisted.ui?._workspaceStatusesDefaultOrderMigrated).toBe(true) + expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) + expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) + expect(persisted.ui?.workspaceStatuses?.map((status) => status.id)).toEqual([ + 'completed', + 'in-review', + 'in-progress', + 'todo' + ]) + expect(persisted.ui?.workspaceStatuses?.[0]?.label).toBe('Done') + }) + + it('migrates legacy default workspace status visuals and workflow once on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES, + _workspaceStatusesDefaultOrderMigrated: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses).toEqual(WORKFLOW_DEFAULT_WORKSPACE_STATUSES) + expect(store.getUI()._workspaceStatusesDefaultWorkflowMigrated).toBe(true) + expect(store.getUI()._workspaceStatusesDefaultVisualsMigrated).toBe(true) + + store.flush() + const persisted = readDataFile() as { + ui?: { + _workspaceStatusesDefaultWorkflowMigrated?: boolean + _workspaceStatusesDefaultVisualsMigrated?: boolean + } + } + expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) + expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) + }) + + it('preserves legacy-looking workspace status visuals after the load migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + const inProgress = store + .getUI() + .workspaceStatuses?.find((status) => status.id === 'in-progress') + expect(inProgress).toMatchObject({ color: 'blue', icon: 'circle-dot' }) + }) + + it('preserves intentionally reordered default workspace statuses after the load migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([ + 'completed', + 'in-review', + 'in-progress', + 'todo' + ]) + }) + // ── terminalMacOptionAsAlt migration (issue #903) ─────────────────── it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => { @@ -1224,6 +1741,567 @@ describe('Store', () => { expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) }) + it('hydrates legacy numeric agent status cache through the pane identity migration', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() - 1000, + payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null + if (leafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', leafId) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + tabId: 'tab1', + worktreeId: 'wt1', + state: 'working', + prompt: 'legacy numeric prompt', + agentType: 'claude' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty-1', 'pane:2': 'local-pty-2' } + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left legacy prompt', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left legacy prompt', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right legacy prompt', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy status rows even when PTY leaf bindings are absent', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left no binding', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right no binding', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left no binding', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right no binding', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('persists legacy pane-key aliases after the layout has been normalized', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + + const firstStore = await createStore() + const root = firstStore.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + const stableLeafId = root?.type === 'leaf' ? root.leafId : null + if (stableLeafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', stableLeafId) + firstStore.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: [ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ] + }) + ) + + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'working', prompt: 'post-normalize legacy prompt' } + } + } + }), + 'utf-8' + ) + + await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + state: 'working', + prompt: 'post-normalize legacy prompt' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + + const store = await createStore() + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if ( + !firstLeafId || + !secondLeafId || + !isTerminalLeafId(firstLeafId) || + !isTerminalLeafId(secondLeafId) + ) { + throw new Error('Expected remapped split leaf ids') + } + const activePaneKey = makePaneKey('tab1', secondLeafId) + const firstPaneKey = makePaneKey('tab1', firstLeafId) + const secondPaneKey = makePaneKey('tab1', secondLeafId) + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey: activePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey: firstPaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:2', + stablePaneKey: secondPaneKey + }) + ]) + }) + ) + }) + + it('converts unambiguous dev migration rows into persisted aliases', async () => { + const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } + } + } + }, + migrationUnsupportedPtyEntries: [ + { + ptyId: 'local-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + paneKey: stablePaneKey, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + } + ] + }) + + const store = await createStore() + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + migrationUnsupportedPtyEntries: [], + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ]) + }) + ) + }) + it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { writeDataFile({ schemaVersion: 1, @@ -2423,6 +3501,35 @@ describe('Store', () => { expect(store.getWorktreeMeta('b')).toBeDefined() }) + it('stores and removes worktree lineage independently from metadata', async () => { + const store = await createStore() + const lineage = makeWorktreeLineage() + + store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) + store.setWorktreeLineage(lineage.worktreeId, lineage) + + expect(store.getWorktreeLineage(lineage.worktreeId)).toEqual(lineage) + expect(store.getAllWorktreeLineage()).toEqual({ [lineage.worktreeId]: lineage }) + + store.removeWorktreeLineage(lineage.worktreeId) + + expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() + expect(store.getWorktreeMeta(lineage.worktreeId)).toBeDefined() + }) + + it('removeWorktreeMeta deletes that worktree lineage entry', async () => { + const store = await createStore() + const lineage = makeWorktreeLineage() + + store.setWorktreeMeta(lineage.worktreeId, { displayName: 'child' }) + store.setWorktreeLineage(lineage.worktreeId, lineage) + + store.removeWorktreeMeta(lineage.worktreeId) + + expect(store.getWorktreeMeta(lineage.worktreeId)).toBeUndefined() + expect(store.getWorktreeLineage(lineage.worktreeId)).toBeUndefined() + }) + // ── Rolling backups (issue #1158) ────────────────────────────────── describe('rolling backups', () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 00bb9bc4409..9ba04be5016 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -20,6 +20,7 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + AutomationRunOutputSnapshot, AutomationRun, AutomationRunTrigger, AutomationUpdateInput @@ -33,10 +34,12 @@ import type { Repo, SparsePreset, WorktreeMeta, + WorktreeLineage, GlobalSettings, OnboardingChecklistState, OnboardingOutcome, OnboardingState, + LegacyPaneKeyAliasEntry, TerminalPaneLayoutNode, TerminalLayoutSnapshot, TerminalTab, @@ -57,19 +60,29 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' -import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from './agent-hooks/migration-unsupported-pty-state' +import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' -import { getRepoIdFromWorktreeId } from '../shared/worktree-id' +import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' +import { normalizeVisibleTaskProviders } from '../shared/task-providers' +import { normalizeOpenInApplications } from '../shared/open-in-applications' import { DEFAULT_WORKSPACE_STATUS_ID, + clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, normalizeWorkspaceBoardCompact, + normalizePersistedWorkspaceStatuses, normalizeWorkspaceStatuses } from '../shared/workspace-statuses' @@ -148,10 +161,15 @@ function backupPath(dataFile: string, index: number): string { } function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { - if (groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') { + if ( + groupBy === 'none' || + groupBy === 'workspace-status' || + groupBy === 'repo' || + groupBy === 'pr-status' + ) { return groupBy } - if (groupBy === 'workspace-status') { + if (groupBy === 'flat') { return 'none' } return getDefaultUIState().groupBy @@ -164,6 +182,32 @@ function normalizeSortBy(sortBy: unknown): 'name' | 'smart' | 'recent' | 'repo' return getDefaultUIState().sortBy } +function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function normalizeAutomationRunOutputSnapshot( + value: AutomationRunOutputSnapshot | null | undefined +): AutomationRunOutputSnapshot | null { + if (!value || value.format !== 'plain_text') { + return null + } + const content = typeof value.content === 'string' ? value.content : '' + if (!content.trim()) { + return null + } + return { + format: 'plain_text', + content, + capturedAt: + typeof value.capturedAt === 'number' && Number.isFinite(value.capturedAt) + ? value.capturedAt + : Date.now(), + truncated: value.truncated === true + } +} + // Why: old persisted targets predate configHost. Default to label-based lookup // so imported SSH aliases keep resolving through ssh -G after upgrade. function normalizeSshTarget(t: SshTarget): SshTarget { @@ -412,64 +456,135 @@ function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): st return undefined } +type PaneIdentityMigrationEntries = { + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] +} + function collectMigrationUnsupportedPtyEntries(args: { session: WorkspaceSessionState tabId: string inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' -}): MigrationUnsupportedPtyEntry[] { - const entries: MigrationUnsupportedPtyEntry[] = [] +}): PaneIdentityMigrationEntries { const worktreeId = findWorktreeIdForTab(args.session, args.tabId) const tab = worktreeId ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) : undefined - const pushEntry = (ptyId: string, leafId: string): void => { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] + const registeredLegacyPaneKeys = new Set() + const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 + const fallbackPtyId = + !hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined + const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { if (!isTerminalLeafId(leafId)) { - return + return false } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return + return false } - entries.push({ - ptyId, - ...(worktreeId ? { worktreeId } : {}), - tabId: args.tabId, - leafId, - paneKey, - reason: 'legacy-numeric-pane-key', - source: args.sourceForPtyId(ptyId), - updatedAt: Date.now() - }) + const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] + if (!numeric) { + return false + } + // Why: persisted PaneManager ids are 1-based. A zero-based alias in split + // layouts would make tab:1 ambiguous and can route the first pane to the second. + const legacyPaneKey = `${args.tabId}:${numeric}` + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) + registeredLegacyPaneKeys.add(legacyPaneKey) + if (ptyId) { + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + return true + } + return false } - for (const [inputLeafId, ptyId] of Object.entries(args.inputLayout.ptyIdsByLeafId ?? {})) { + const inputLeafIds = new Set([ + ...collectLayoutLeafIdsInOrder(args.inputLayout.root), + ...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}) + ]) + for (const inputLeafId of inputLeafIds) { if (isTerminalLeafId(inputLeafId)) { continue } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - pushEntry(ptyId, leafId) + registerLegacyAlias( + inputLeafId, + leafId, + args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId + ) } } - if ( - entries.length === 0 && - tab?.ptyId && - Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length === 0 - ) { + if (tab?.ptyId && !hasLeafPtyBindings) { const fallbackLeafId = args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) - if (fallbackLeafId) { - // Why: older single-pane sessions can reattach from tab.ptyId only, with - // no leaf binding to migrate. Their live shell env can still hold the - // legacy pane key, so surface the restart-required row for that PTY. - pushEntry(tab.ptyId, fallbackLeafId) + if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) { + const paneKey = makePaneKey(args.tabId, fallbackLeafId) + for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) { + if (registeredLegacyPaneKeys.has(legacyPaneKey)) { + continue + } + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId) + legacyPaneKeyAliasEntries.push({ + ptyId: tab.ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + } } } - return entries + // Why: legacy numeric pane keys are now bridged by aliases instead of + // persisted as restart-required rows. Existing saved rows are pruned during + // normalizePersistedPaneIdentityState. + return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } +} + +function legacyMigrationUnsupportedRowsToAliasEntries( + entries: MigrationUnsupportedPtyEntry[] +): LegacyPaneKeyAliasEntry[] { + const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter( + (entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey) + ) + const entriesByTabId = new Map() + for (const entry of normalizedEntries) { + const tabId = entry.tabId + if (!tabId) { + continue + } + entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry]) + } + const aliasEntries: LegacyPaneKeyAliasEntry[] = [] + for (const [tabId, tabEntries] of entriesByTabId) { + if (tabEntries.length !== 1) { + continue + } + const [entry] = tabEntries + if (!entry.paneKey) { + continue + } + // Why: pre-stable dev/RC migration rows did not store the old numeric + // key. Only synthesize the single-pane aliases when the row is unambiguous + // for its tab; split rows need layout-derived aliases instead of a guess. + for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) { + aliasEntries.push({ + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.paneKey, + updatedAt: entry.updatedAt + }) + } + } + return aliasEntries } function normalizeTerminalLayoutSnapshotForPersistence( @@ -595,19 +710,20 @@ function normalizeTerminalLayoutSnapshotForPersistence( function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, - priorLayoutsByTabId: Record = {}, - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local' + priorLayoutsByTabId: Record = {} ): { session: WorkspaceSessionState changed: boolean leafIdByInputLeafIdByTabId: Map> leafIdByPtyIdByTabId: Map> migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const normalized = normalizeTerminalLayoutSnapshotForPersistence( @@ -616,16 +732,15 @@ function normalizeWorkspaceSessionPaneIdentities( ) terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) - migrationUnsupportedEntries.push( - ...collectMigrationUnsupportedPtyEntries({ - session, - tabId, - inputLayout: layout, - normalizedLayout: normalized.snapshot, - leafIdByInputLeafId: normalized.leafIdByInputLeafId, - sourceForPtyId - }) - ) + const migrationEntries = collectMigrationUnsupportedPtyEntries({ + session, + tabId, + inputLayout: layout, + normalizedLayout: normalized.snapshot, + leafIdByInputLeafId: normalized.leafIdByInputLeafId + }) + migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries) + legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries) const leafIdByPtyId = new Map() const duplicatePtyIds = new Set() for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { @@ -647,7 +762,8 @@ function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries + migrationUnsupportedEntries, + legacyPaneKeyAliasEntries } } @@ -685,31 +801,44 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { - const sshPtyIds = new Set((state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) - const normalizedSession = normalizeWorkspaceSessionPaneIdentities( - state.workspaceSession, - {}, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') - ) + const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], normalizedSession.leafIdByInputLeafIdByTabId, normalizedSession.leafIdByPtyIdByTabId ) - const mergedMigrationUnsupportedEntries = mergeMigrationUnsupportedPtyEntries([ - ...(state.migrationUnsupportedPtyEntries ?? []), - ...normalizedSession.migrationUnsupportedEntries + const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ + ...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries), + ...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []), + ...normalizedSession.legacyPaneKeyAliasEntries ]) + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + state.ui?.acknowledgedAgentsByPaneKey, + normalizedSession.leafIdByInputLeafIdByTabId + ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries ) - if (!normalizedSession.changed && !remappedLeases.changed && !migrationUnsupportedChanged) { + const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual( + state.legacyPaneKeyAliasEntries ?? [], + mergedLegacyPaneKeyAliasEntries + ) + if ( + !normalizedSession.changed && + !remappedLeases.changed && + !migrationUnsupportedChanged && + !legacyAliasesChanged && + !remappedAcknowledgements.changed + ) { return { state, changed: false, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } return { @@ -717,24 +846,70 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { ...state, workspaceSession: normalizedSession.session, sshRemotePtyLeases: remappedLeases.leases, - migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, + ...(remappedAcknowledgements.changed + ? { + ui: { + ...state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + : {}) }, changed: true, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } -function mergeMigrationUnsupportedPtyEntries( - entries: MigrationUnsupportedPtyEntry[] -): MigrationUnsupportedPtyEntry[] { - const byPtyId = new Map() - for (const entry of entries) { - const existing = byPtyId.get(entry.ptyId) - if (!existing || existing.updatedAt <= entry.updatedAt) { - byPtyId.set(entry.ptyId, entry) +function remapAcknowledgedAgentPaneKeys( + acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], + leafIdByInputLeafIdByTabId: Map> +): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { + if (!acknowledgements || Object.keys(acknowledgements).length === 0) { + return { acknowledgements, changed: false } + } + + let changed = false + const next: NonNullable = {} + const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => { + const existing = next[paneKey] + next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt) + } + for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) { + const parsed = parsePaneKey(paneKey) + if (parsed) { + setAcknowledgement(paneKey, acknowledgedAt) + continue + } + + const delimiter = paneKey.indexOf(':') + if (delimiter <= 0 || delimiter === paneKey.length - 1) { + setAcknowledgement(paneKey, acknowledgedAt) + continue + } + + const tabId = paneKey.slice(0, delimiter) + const legacyLeafId = paneKey.slice(delimiter + 1) + const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) + if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { + setAcknowledgement(paneKey, acknowledgedAt) + continue + } + + try { + // Why: UI acks are keyed by paneKey just like hook rows. When a legacy + // numeric/pane:* leaf is promoted to a UUID, carry the read marker over + // so already-seen Activity/sidebar rows do not come back unread. + setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) + changed = true + } catch { + setAcknowledgement(paneKey, acknowledgedAt) } } - return [...byPtyId.values()] + + return { acknowledgements: next, changed } } function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsupportedPtyEntry[] { @@ -760,6 +935,57 @@ function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsup }) } +function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is LegacyPaneKeyAliasEntry => { + if (!entry || typeof entry !== 'object') { + return false + } + const candidate = entry as Partial + if ( + typeof candidate.ptyId !== 'string' || + candidate.ptyId.trim().length === 0 || + typeof candidate.legacyPaneKey !== 'string' || + typeof candidate.stablePaneKey !== 'string' || + !Number.isFinite(candidate.updatedAt) + ) { + return false + } + const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey) + const stable = parsePaneKey(candidate.stablePaneKey) + return Boolean(legacy && stable && legacy.tabId === stable.tabId) + }) +} + +function mergeLegacyPaneKeyAliasEntries( + entries: LegacyPaneKeyAliasEntry[] +): LegacyPaneKeyAliasEntry[] { + const byLegacyPaneKey = new Map() + for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) { + const existing = byLegacyPaneKey.get(entry.legacyPaneKey) + if (!existing || existing.updatedAt <= entry.updatedAt) { + byLegacyPaneKey.set(entry.legacyPaneKey, entry) + } + } + return [...byLegacyPaneKey.values()] +} + +function legacyPaneKeyAliasEntriesEqual( + left: LegacyPaneKeyAliasEntry[], + right: LegacyPaneKeyAliasEntry[] +): boolean { + if (left.length !== right.length) { + return false + } + const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry])) + return left.every((entry) => { + const other = rightByLegacyPaneKey.get(entry.legacyPaneKey) + return other ? JSON.stringify(entry) === JSON.stringify(other) : false + }) +} + function migrationUnsupportedEntriesEqual( left: MigrationUnsupportedPtyEntry[], right: MigrationUnsupportedPtyEntry[] @@ -806,6 +1032,7 @@ export class Store { private pendingWrite: Promise | null = null private writeGeneration = 0 private gitUsernameCache = new Map() + private loadNeedsSave = false constructor() { const loaded = this.load() @@ -814,13 +1041,28 @@ export class Store { for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries this.scheduleSave() }) - if (normalized.changed) { + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + this.scheduleSave() + }) + if (normalized.changed || this.loadNeedsSave) { // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at // the main persistence boundary so older renderer writes cannot revive them. + // Other one-shot load migrations also set loadNeedsSave to persist their + // guard flags before the next restart. this.scheduleSave() } } @@ -971,6 +1213,7 @@ export class Store { result = { ...defaults, ...parsed, + worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { ...defaults.settings, ...parsed.settings, @@ -989,6 +1232,10 @@ export class Store { terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), + visibleTaskProviders: normalizeVisibleTaskProviders( + parsed.settings?.visibleTaskProviders + ), + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications), notifications: { ...getDefaultNotificationSettings(), ...parsed.settings?.notifications @@ -1008,6 +1255,32 @@ export class Store { const rawSort = parsed.ui?.sortBy const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: the default workflow changed to Done -> Review -> Progress -> Todo. + // Only exact legacy default payloads are migrated; users who + // customized status labels, colors, icons, or order keep theirs. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true + // Why: visual migration has its own guard so later user choices + // of valid legacy color/icon IDs are preserved by runtime writes. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses( + parsed.ui?.workspaceStatuses, + { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + } + ) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + this.loadNeedsSave = true + } // Why: the 'inline-agents' card property was added after the // feature shipped behind an experimental toggle. Now that the // feature is default-on for everyone, every existing user needs @@ -1055,6 +1328,10 @@ export class Store { ...defaults.ui, ...parsed.ui, sortBy: migrate ? ('smart' as const) : sort, + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, _sortBySmartMigrated: true, ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } @@ -1094,6 +1371,9 @@ export class Store { migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( + parsed.legacyPaneKeyAliasEntries + ), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [], onboarding: (() => { @@ -1438,6 +1718,11 @@ export class Store { delete this.state.worktreeMeta[key] } } + for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { + if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) { + delete this.state.worktreeLineageById[childId] + } + } this.scheduleSave() } @@ -1649,9 +1934,12 @@ export class Store { status: 'pending', trigger, workspaceId: automation.workspaceId, + workspaceDisplayName: this.getAutomationRunWorkspaceDisplayName(automation.workspaceId), sessionKind: 'terminal', chatSessionId: null, terminalSessionId: null, + outputSnapshot: null, + usage: null, error: null, startedAt: null, dispatchedAt: null, @@ -1669,11 +1957,23 @@ export class Store { } const now = Date.now() const current = this.state.automationRuns[index] + const workspaceId = result.workspaceId ?? current.workspaceId + const workspaceDisplayName = Object.hasOwn(result, 'workspaceDisplayName') + ? normalizeAutomationRunWorkspaceDisplayName(result.workspaceDisplayName ?? null) + : null const updated: AutomationRun = { ...current, status: result.status, - workspaceId: result.workspaceId ?? current.workspaceId, + workspaceId, + workspaceDisplayName: + workspaceDisplayName ?? + normalizeAutomationRunWorkspaceDisplayName(current.workspaceDisplayName ?? null) ?? + this.getAutomationRunWorkspaceDisplayName(workspaceId), terminalSessionId: result.terminalSessionId ?? current.terminalSessionId, + outputSnapshot: Object.hasOwn(result, 'outputSnapshot') + ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) + : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), + usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt @@ -1688,6 +1988,37 @@ export class Store { return updated } + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + const normalizedDisplayName = normalizeAutomationRunWorkspaceDisplayName(displayName) + if (!normalizedDisplayName) { + return 0 + } + let updatedCount = 0 + this.state.automationRuns = (this.state.automationRuns ?? []).map((run) => { + if (run.workspaceId !== workspaceId || run.workspaceDisplayName === normalizedDisplayName) { + return run + } + updatedCount += 1 + return { ...run, workspaceDisplayName: normalizedDisplayName } + }) + if (updatedCount > 0) { + this.flush() + } + return updatedCount + } + + private getAutomationRunWorkspaceDisplayName( + workspaceId: string | null | undefined + ): string | null { + if (!workspaceId) { + return null + } + return normalizeAutomationRunWorkspaceDisplayName( + this.state.worktreeMeta[workspaceId]?.displayName ?? + getWorktreePathBasenameFromId(workspaceId) + ) + } + advanceAutomationNextRun(id: string, now = Date.now()): Automation { const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id) if (index === -1) { @@ -1718,6 +2049,9 @@ export class Store { setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } this.state.worktreeMeta[worktreeId] = updated this.scheduleSave() return updated @@ -1725,6 +2059,26 @@ export class Store { removeWorktreeMeta(worktreeId: string): void { delete this.state.worktreeMeta[worktreeId] + delete this.state.worktreeLineageById[worktreeId] + this.scheduleSave() + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this.state.worktreeLineageById[worktreeId] = lineage + this.scheduleSave() + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this.state.worktreeLineageById[worktreeId] this.scheduleSave() } @@ -1741,6 +2095,14 @@ export class Store { updates.terminalQuickCommands ) } + if ('visibleTaskProviders' in updates) { + sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders( + updates.visibleTaskProviders + ) + } + if ('openInApplications' in updates) { + sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) + } // Why: `telemetry` is deep-merged for the same reason `notifications` is — // partial updates from the Privacy pane / consent flow (e.g., flipping // only `optedIn`) must not clobber sibling fields like `installId` or @@ -1774,7 +2136,10 @@ export class Store { sortBy: normalizeSortBy(this.state.ui?.sortBy), workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), - workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact) + workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( + this.state.ui?.workspaceBoardColumnWidth + ) } } @@ -1788,14 +2153,18 @@ export class Store { sortBy: updates.sortBy ? normalizeSortBy(updates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), - workspaceStatuses: normalizeWorkspaceStatuses( - updates.workspaceStatuses ?? this.state.ui?.workspaceStatuses - ), + workspaceStatuses: + updates.workspaceStatuses !== undefined + ? normalizeWorkspaceStatuses(updates.workspaceStatuses) + : normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity( updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity ), workspaceBoardCompact: normalizeWorkspaceBoardCompact( updates.workspaceBoardCompact ?? this.state.ui?.workspaceBoardCompact + ), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( + updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ) } this.scheduleSave() @@ -1863,15 +2232,32 @@ export class Store { // the durable binding and re-open the orphan window. Merge in any // existing bindings whenever the incoming snapshot's binding is empty. const prior = this.state.workspaceSession - const sshPtyIds = new Set((this.state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) const normalized = normalizeWorkspaceSessionPaneIdentities( session, - prior?.terminalLayoutsByTabId, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') + prior?.terminalLayoutsByTabId ) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + this.state.ui?.acknowledgedAgentsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed) { + this.state.ui = { + ...this.state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } session = normalized.session const remappedLeases = remapSshRemotePtyLeaseLeafIds( this.state.sshRemotePtyLeases ?? [], @@ -2398,6 +2784,7 @@ export class Store { function getDefaultWorktreeMeta(): WorktreeMeta { return { + instanceId: randomUUID(), displayName: '', comment: '', linkedIssue: null, diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index fe660ed6dfa..f186a705dc6 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -140,6 +140,70 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + it('does not inherit parent Orca pane identity when caller omits pane env', async () => { + const saved = { + ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, + ORCA_TAB_ID: process.env.ORCA_TAB_ID, + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + } + process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' + process.env.ORCA_TAB_ID = 'parent-tab' + process.env.ORCA_WORKTREE_ID = 'parent-worktree' + + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.ORCA_PANE_KEY).toBeUndefined() + expect(spawnCall[2].env.ORCA_TAB_ID).toBeUndefined() + expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBeUndefined() + }) + + it('preserves explicit child Orca pane identity over parent env', async () => { + const saved = { + ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, + ORCA_TAB_ID: process.env.ORCA_TAB_ID, + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + } + process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' + process.env.ORCA_TAB_ID = 'parent-tab' + process.env.ORCA_WORKTREE_ID = 'parent-worktree' + + try { + await provider.spawn({ + cols: 80, + rows: 24, + env: { + ORCA_PANE_KEY: 'child-tab:child-leaf', + ORCA_TAB_ID: 'child-tab', + ORCA_WORKTREE_ID: 'child-worktree' + } + }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].env.ORCA_PANE_KEY).toBe('child-tab:child-leaf') + expect(spawnCall[2].env.ORCA_TAB_ID).toBe('child-tab') + expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBe('child-worktree') + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') const originalUserProfile = process.env.USERPROFILE diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3ce4c5423b6..74576c68059 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -31,6 +31,8 @@ import { } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' +const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const + let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() @@ -66,6 +68,17 @@ function getDefaultCwd(): string { return 'C:\\' } +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -236,6 +249,9 @@ export class LocalPtyProvider implements IPtyProvider { // restores clickable refs like `owner/repo#123` / `PR#123`. FORCE_HYPERLINK: '1' } as Record + // Why: Orca can be launched from an Orca terminal while developing. Pane + // identity belongs to the child PTY, not the parent shell that spawned app. + removeUnspecifiedPaneIdentityEnv(spawnEnv, args.env) removeInheritedNoColor(spawnEnv) for (const key of args.envToDelete ?? []) { delete spawnEnv[key] diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index b9df5792b8f..6410929c52a 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -242,6 +242,8 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8') + expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"') + expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]') expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;') }) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 1b0a4d57040..68f3365c9c7 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -217,11 +217,39 @@ function ensureShellReadyWrappers(): void { const bashDir = `${root}/bash` const zshEnv = `# Orca zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "\${ORCA_ORIG_ZDOTDIR%/}" in +_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}" +# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom +# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its +# preferred dir; pre-setting it (even to HOME) defeats that default. +unset ZDOTDIR +# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper. +# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and +# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\` +# or \`export\` to escape. +__orca_source_user_zshenv() { + # Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent + # shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still + # get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env + # ZDOTDIR was inherited. + local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}" + [[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv" +} +__orca_source_user_zshenv +unfunction __orca_source_user_zshenv +# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve +# the spawn-env value (an inherited resolution from a parent Orca PTY); +# else HOME. +export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}" +unset _orca_spawn_orig_zdotdir +# Why: strip trailing slashes (matches Node-side normalizer) before the +# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing +# slashes still gets normalized away from .zprofile/.zshrc/.zlogin. +while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do + ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}" +done +case "\${ORCA_ORIG_ZDOTDIR}" in */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; esac -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" export ZDOTDIR=${quotePosixSingle(zshDir)} ` const zshProfile = `# Orca zsh shell-ready wrapper diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 636fa229d60..4983f142927 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this suite covers the SSH git provider's one-RPC-per-method contract; splitting it would duplicate the shared mux fixture. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshGitProvider } from './ssh-git-provider' @@ -41,6 +42,57 @@ describe('SshGitProvider', () => { expect(result).toEqual(statusResult) }) + it('getStatus forwards includeIgnored only when requested', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] } + mux.request.mockResolvedValue(statusResult) + + await provider.getStatus('/home/user/repo', { includeIgnored: true }) + await provider.getStatus('/home/user/repo', { includeIgnored: false }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', { + worktreePath: '/home/user/repo', + includeIgnored: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/repo' + }) + }) + + it('checkIgnoredPaths sends git.checkIgnored request', async () => { + mux.request.mockResolvedValue(['dist/bundle.js']) + + const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js']) + + expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', { + worktreePath: '/home/user/repo', + paths: ['dist/bundle.js'] + }) + expect(result).toEqual(['dist/bundle.js']) + }) + + it('getHistory sends git.history request', async () => { + const historyResult = { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + } + mux.request.mockResolvedValue(historyResult) + + const result = await provider.getHistory('/home/user/repo', { + limit: 25, + baseRef: 'origin/main' + }) + + expect(mux.request).toHaveBeenCalledWith('git.history', { + worktreePath: '/home/user/repo', + limit: 25, + baseRef: 'origin/main' + }) + expect(result).toEqual(historyResult) + }) + it('commit sends git.commit request', async () => { const commitResult = { success: true } mux.request.mockResolvedValue(commitResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e73e92f7658..f1c3b07085d 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -9,11 +9,13 @@ import type { GitStatusResult, GitDiffResult, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitPushTarget, GitUpstreamStatus, GitWorktreeInfo } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -31,8 +33,32 @@ export class SshGitProvider implements IGitProvider { return this.connectionId } - async getStatus(worktreePath: string): Promise { - return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult + async getStatus( + worktreePath: string, + options?: { includeIgnored?: boolean } + ): Promise { + const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + return (await this.mux.request('git.status', { + worktreePath, + ...includeIgnoredArgs + })) as GitStatusResult + } + + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult } async commit( @@ -144,6 +170,13 @@ export class SshGitProvider implements IGitProvider { })) as GitBranchCompareResult } + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } + async getUpstreamStatus(worktreePath: string): Promise { return (await this.mux.request('git.upstreamStatus', { worktreePath @@ -178,6 +211,16 @@ export class SshGitProvider implements IGitProvider { })) as GitDiffResult[] } + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return (await this.mux.request('git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + } + async listWorktrees( repoPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 39775d028bd..e266edb27bb 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -95,9 +95,8 @@ export class SshPtyProvider implements IPtyProvider { } } catch (err) { // Why: pty.attach fails when the relay grace window has elapsed. - // Do not silently replace the saved tab with a fresh shell; the - // renderer should show an expired-session state so users know the - // original remote process is gone. + // Surface the exact condition so the renderer can clear the stale + // binding before replacing the dead relay PTY in the same pane. console.warn(`[ssh-pty] pty.attach FAILED for ${opts.sessionId}:`, err) if (isSshPtyNotFoundError(err)) { throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${opts.sessionId}`) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index da8e8758ac6..32c6fdfb6ba 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -4,6 +4,7 @@ import type { GitStatusResult, GitDiffResult, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitPushTarget, GitUpstreamStatus, @@ -11,6 +12,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' @@ -142,7 +144,9 @@ export type IFilesystemProvider = { // ─── Git Provider ─────────────────────────────────────────────────── export type IGitProvider = { - getStatus(worktreePath: string): Promise + getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise + getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> getStagedCommitContext(worktreePath: string): Promise getDiff( @@ -159,6 +163,7 @@ export type IGitProvider = { bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise detectConflictOperation(worktreePath: string): Promise getBranchCompare(worktreePath: string, baseRef: string): Promise + getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string): Promise pushBranch(worktreePath: string, publish?: boolean, pushTarget?: GitPushTarget): Promise pullBranch(worktreePath: string): Promise @@ -168,6 +173,10 @@ export type IGitProvider = { baseRef: string, options?: { includePatch?: boolean; filePath?: string; oldPath?: string } ): Promise + getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise addWorktree( repoPath: string, diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 7b86f4765d7..46631c59f8e 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -135,7 +135,8 @@ describe('fetchClaudeRateLimits', () => { 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ headers: expect.objectContaining({ - Authorization: 'Bearer oauth-token' + Authorization: 'Bearer oauth-token', + 'User-Agent': 'claude-code/2.1.0' }) }) ) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 8622d420564..9bce7642898 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -17,6 +17,7 @@ import { const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' +const CLAUDE_CODE_USER_AGENT = 'claude-code/2.1.0' const API_TIMEOUT_MS = 10_000 let proxyConfigured = false @@ -266,7 +267,10 @@ async function fetchViaOAuth(token: string): Promise { const res = await net.fetch(OAUTH_USAGE_URL, { headers: { Authorization: `Bearer ${token}`, - 'anthropic-beta': OAUTH_BETA_HEADER + 'anthropic-beta': OAUTH_BETA_HEADER, + // Why: Claude's OAuth usage endpoint is the Claude Code usage API; + // matching the CLI user-agent keeps Orca aligned with that contract. + 'User-Agent': CLAUDE_CODE_USER_AGENT }, signal: controller.signal }) diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a3e0d80f8bd..4466f0d0b06 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -19,6 +19,29 @@ function makeDisposable() { return { dispose: vi.fn() } } +type MockTerm = { + onData: ReturnType + onExit: ReturnType + write: ReturnType + kill: ReturnType +} + +function makeMockTerm(): MockTerm & { + emitData: (data: string) => void +} { + let dataHandler: ((data: string) => void) | null = null + return { + onData: vi.fn((handler: (data: string) => void) => { + dataHandler = handler + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data) + } +} + describe('fetchViaPty', () => { beforeEach(() => { vi.useFakeTimers() @@ -49,4 +72,64 @@ describe('fetchViaPty', () => { term.kill.mock.invocationCallOrder[0] ) }) + + it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + expect(term.write).toHaveBeenCalledWith('/usage\r') + + term.emitData(` + Settings Status Config Usage Stats + + Session + Total cost: $0.0000 + Usage: 0 input, 0 output, 0 cache read, 0 cache write + `) + + await vi.advanceTimersByTimeAsync(8_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + session: null, + weekly: null, + error: 'Claude plan usage is unavailable for this Claude CLI session.' + }) + expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') + }) + + it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Settings Status Config Usage Stats + Session + Total cost: $0.0000 + `) + + await vi.advanceTimersByTimeAsync(1_000) + term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12, + resetDescription: '4:00pm' + }, + weekly: { + usedPercent: 34 + }, + error: null + }) + }) }) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 53a4b4e5504..8948e42455c 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -17,6 +17,14 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /current\s*week/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') +const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') + +function stripTerminalControlSequences(output: string): string { + return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') +} /** * Extract percent-left from lines following a label match. @@ -60,7 +68,7 @@ function parsePtyUsage(output: string): { session: RateLimitWindow | null weekly: RateLimitWindow | null } { - const lines = output.split(/\r?\n/) + const lines = output.split(/\r\n|\n|\r/) const sessionPct = extractPercentAfterLabel(lines, SESSION_RE) const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE) @@ -110,8 +118,11 @@ const COMMAND_PALETTE_RE = /show plan|usage limits/i const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i const LOAD_FAILED_RE = /failed to load usage data/i +const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i +const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 +const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 function describeClaudeUsageFailure(output: string): string { if (RATE_LIMITED_RE.test(output)) { @@ -122,6 +133,10 @@ function describeClaudeUsageFailure(output: string): string { return 'Claude usage is unavailable right now.' } + if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { + return 'Claude plan usage is unavailable for this Claude CLI session.' + } + // Why: parser failures are an implementation detail of Orca's PTY fallback. // The UI should explain the user-visible outcome, not leak internal parsing // mechanics that the user cannot act on. @@ -138,6 +153,8 @@ export async function fetchViaPty(options?: { let resolved = false let sentUsage = false let stopDetected = false + let claude21UsageDetected = false + let claude21UsageSettleTimer: ReturnType | null = null const claudeCommand = resolveClaudeCommand() @@ -173,11 +190,18 @@ export async function fetchViaPty(options?: { // Why: node-pty's NAPI callbacks can outlive the Electron JS // environment if we kill the hidden PTY without disposing them first, // which matches Orca's documented SIGABRT failure mode on shutdown. + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } disposeTermListeners() term.kill() // Even on timeout, try to parse whatever we collected - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) if (session || weekly) { resolve({ @@ -194,7 +218,10 @@ export async function fetchViaPty(options?: { session: null, weekly: null, updatedAt: Date.now(), - error: 'PTY timeout — /usage panel did not render', + error: + CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean) + ? describeClaudeUsageFailure(clean) + : 'PTY timeout — /usage panel did not render', status: 'error' }) } @@ -222,14 +249,17 @@ export async function fetchViaPty(options?: { } resolved = true clearTimeout(timeout) + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } if (enterInterval) { clearInterval(enterInterval) } disposeTermListeners() term.kill() - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) if (!session && !weekly) { @@ -271,8 +301,7 @@ export async function fetchViaPty(options?: { output = output.slice(-MAX_OUTPUT_LENGTH) } - // eslint-disable-next-line no-control-regex - const cleanChunk = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const cleanChunk = stripTerminalControlSequences(data) // Why: the Claude CLI may prompt for first-run setup (trust files, // workspace directory). Auto-accept so we can reach /usage. @@ -290,8 +319,21 @@ export async function fetchViaPty(options?: { // Check if we've hit a stop substring indicating the panel rendered if (sentUsage && !stopDetected) { - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) + if ( + !claude21UsageDetected && + (CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)) + ) { + claude21UsageDetected = true + if (enterInterval) { + clearInterval(enterInterval) + enterInterval = null + } + // Why: Claude 2.1 may render session stats without subscription + // plan windows. Give async usage loading a grace period, then finish + // with a user-facing unavailable state instead of a false PTY timeout. + claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) + } for (const sub of STOP_SUBSTRINGS) { if (clean.includes(sub)) { stopDetected = true @@ -309,14 +351,18 @@ export async function fetchViaPty(options?: { const onExitDisposable = term.onExit(() => { disposeTermListeners() + if (claude21UsageSettleTimer) { + clearTimeout(claude21UsageSettleTimer) + claude21UsageSettleTimer = null + } if (enterInterval) { clearInterval(enterInterval) + enterInterval = null } if (!resolved) { resolved = true clearTimeout(timeout) - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + const clean = stripTerminalControlSequences(output) const { session, weekly } = parsePtyUsage(clean) resolve({ provider: 'claude', diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index cc37c13353d..1eb64c38c3d 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ @@ -24,6 +25,20 @@ function makeDisposable() { return { dispose: vi.fn() } } +function makeRpcChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { write: ReturnType } + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { write: vi.fn() } + child.kill = vi.fn() + return child +} + describe('fetchCodexRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -57,4 +72,84 @@ describe('fetchCodexRateLimits', () => { term.kill.mock.invocationCallOrder[0] ) }) + + it('falls back to the PTY status reader when RPC exits before returning usage', async () => { + const rpcChild = makeRpcChild() + const ptyHandlers: { onData?: (data: string) => void } = {} + + childSpawnMock.mockReturnValue(rpcChild) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + rpcChild.emit('close') + await vi.advanceTimersByTimeAsync(0) + + expect(ptySpawnMock).toHaveBeenCalled() + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + onPtyData('>') + onPtyData('5h limit: 7%\nWeekly limit: 12%\n') + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + session: { usedPercent: 7 }, + weekly: { usedPercent: 12 }, + status: 'ok', + error: null + }) + }) + + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + primary: { usedPercent: 0, windowDurationMins: 299 }, + secondary: { usedPercent: 0, windowDurationMins: 10079 } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result.session?.windowMinutes).toBe(300) + expect(result.weekly?.windowMinutes).toBe(10080) + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 0c1cd50a31c..a3143daf1ba 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -44,7 +44,10 @@ function buildRpcMessage(id: number, method: string, params?: unknown): string { return `${JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })}\n` } -function mapRpcWindow(raw: RpcRateWindow | undefined): RateLimitWindow | null { +function mapRpcWindow( + raw: RpcRateWindow | undefined, + expectedWindowMinutes: number +): RateLimitWindow | null { if (!raw || typeof raw.usedPercent !== 'number') { return null } @@ -70,7 +73,9 @@ function mapRpcWindow(raw: RpcRateWindow | undefined): RateLimitWindow | null { return { usedPercent: Math.min(100, Math.max(0, raw.usedPercent)), - windowMinutes: raw.windowDurationMins ?? 300, + // Why: Codex currently reports remaining minutes in `windowDurationMins`. + // Orca's UI needs the fixed bucket duration so labels stay "5h" / "wk". + windowMinutes: expectedWindowMinutes, resetsAt, resetDescription } @@ -199,8 +204,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { // Path A: try RPC first try { - return await fetchViaRpc(options) + const rpcResult = await fetchViaRpc(options) + if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') { + return rpcResult + } + // Why: app-server can fail independently of the interactive CLI. Keep the + // status-bar useful by trying the older /status PTY reader on RPC errors. } catch { // RPC failed — fall through to PTY } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e77943d1049..e18e287f1ee 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -3,11 +3,14 @@ import type * as Fs from 'fs' import type * as FsPromises from 'fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' -const { resolveAuthorizedPathMock, statMock, watchMock } = vi.hoisted(() => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - watchMock: vi.fn() -})) +const { resolveAuthorizedPathMock, statMock, subscribeParcelWatcherMock, watchMock } = vi.hoisted( + () => ({ + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + subscribeParcelWatcherMock: vi.fn(), + watchMock: vi.fn() + }) +) vi.mock('fs', async () => { const actual = await vi.importActual('fs') @@ -25,6 +28,10 @@ vi.mock('fs/promises', async () => { } }) +vi.mock('@parcel/watcher', () => ({ + subscribe: subscribeParcelWatcherMock +})) + vi.mock('../ipc/filesystem-auth', async () => { const actual = await vi.importActual('../ipc/filesystem-auth') return { @@ -37,7 +44,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn() })) -import { RuntimeFileCommands } from './orca-runtime-files' +import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -46,6 +53,7 @@ describe('RuntimeFileCommands', () => { vi.useFakeTimers() resolveAuthorizedPathMock.mockReset() statMock.mockReset() + subscribeParcelWatcherMock.mockReset() watchMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, @@ -53,7 +61,8 @@ describe('RuntimeFileCommands', () => { }) }) - afterEach(() => { + afterEach(async () => { + await awaitRuntimeFileWatcherUnsubscribes() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -109,4 +118,46 @@ describe('RuntimeFileCommands', () => { unsubscribe() expect(close).toHaveBeenCalledTimes(1) }) + + it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => { + const store = { getRepo: vi.fn(() => undefined) } + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolveUnsubscribe: () => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock }) + + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + unsubscribe() + + let drained = false + const drainPromise = awaitRuntimeFileWatcherUnsubscribes().then(() => { + drained = true + }) + await Promise.resolve() + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + expect(drained).toBe(false) + + resolveUnsubscribe() + await drainPromise + expect(drained).toBe(true) + }) }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index ca326ae42ad..2346e4c02ff 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -56,6 +56,9 @@ const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +// Why: runtime files.watch subscriptions are cleaned up through synchronous RPC +// callbacks. Track native Parcel unsubscribe work so app shutdown can drain it. +const pendingRuntimeFileWatcherUnsubscribes = new Set>() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -84,6 +87,25 @@ const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } +function trackRuntimeFileWatcherUnsubscribe( + rootPath: string, + unsubscribe: () => Promise +): void { + const promise = Promise.resolve() + .then(unsubscribe) + .catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + .finally(() => { + pendingRuntimeFileWatcherUnsubscribes.delete(promise) + }) + pendingRuntimeFileWatcherUnsubscribes.add(promise) +} + +export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { + await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) +} + export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } export type RuntimeFileCommandHost = { @@ -269,9 +291,7 @@ export class RuntimeFileCommands { } ) return () => { - void subscription.unsubscribe().catch((err: unknown) => { - console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) - }) + trackRuntimeFileWatcherUnsubscribe(rootPath, () => subscription.unsubscribe()) } } diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts new file mode 100644 index 00000000000..c05c67676fa --- /dev/null +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import type * as GitStatusModule from '../git/status' +import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' +import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' + +const mocks = vi.hoisted(() => ({ + getStagedCommitContext: vi.fn(), + generateCommitMessageFromContext: vi.fn(), + resolveCommitMessageSettings: vi.fn() +})) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + getStagedCommitContext: mocks.getStagedCommitContext +})) + +vi.mock('../text-generation/commit-message-text-generation', async () => ({ + ...(await vi.importActual( + '../text-generation/commit-message-text-generation' + )), + generateCommitMessageFromContext: mocks.generateCommitMessageFromContext, + resolveCommitMessageSettings: mocks.resolveCommitMessageSettings +})) + +const tempDirs: string[] = [] + +function makeWorktree(path: string): ResolvedRuntimeGitWorktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path, + git: { + path, + branch: 'main', + bare: false, + detached: false, + head: 'a'.repeat(40) + } + } as unknown as ResolvedRuntimeGitWorktree +} + +function makeCommands(worktreePath: string): RuntimeGitCommands { + return new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) +} + +describe('RuntimeGitCommands', () => { + beforeEach(() => { + mocks.getStagedCommitContext.mockReset() + mocks.generateCommitMessageFromContext.mockReset() + mocks.resolveCommitMessageSettings.mockReset() + }) + + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } + }) + + it('rejects slash-only git mutation paths before they can target the worktree root', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const commands = makeCommands(worktreePath) + + await expect(commands.bulkDiscardRuntimeGitPaths('id:wt-1', ['///'])).rejects.toThrow( + 'invalid_relative_path' + ) + await expect(commands.discardRuntimeGitPath('id:wt-1', '///')).rejects.toThrow( + 'invalid_relative_path' + ) + }) + + it('prepares the selected local agent environment before generating commit messages', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ + success: true, + message: 'docs: update readme' + }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath) }), + getRuntimeSettings: () => + ({ + commitMessageAi: { enabled: true, agentId: 'codex' }, + agentCmdOverrides: {}, + enableGitHubAttribution: false + }) as GlobalSettings, + getCommitMessageAgentEnvironment: () => ({ + prepareForCodexLaunch: () => '/managed/codex-home' + }) + }) + + await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({ + success: true, + message: 'docs: update readme' + }) + + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'local', + cwd: worktreePath, + env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' }) + }) + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 9c405a04599..7aaad5bc7b3 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -1,15 +1,21 @@ +/* eslint-disable max-lines -- Why: runtime git dispatch stays in one boundary so local, SSH, and runtime-environment behavior remains comparable. */ import type { GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, GitStatusResult, GitUpstreamStatus, GitWorktreeInfo, + GlobalSettings, Worktree } from '../../shared/types' +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { getRemoteFileUrl } from '../git/repo' import { + bulkDiscardChanges, bulkStageFiles, bulkUnstageFiles, commitChanges, @@ -17,37 +23,107 @@ import { discardChanges, getBranchCompare, getBranchDiff, + getCommitCompare, + getCommitDiff, getDiff, + getStagedCommitContext, getStatus as getGitStatus, stageFile, unstageFile } from '../git/status' +import { getHistory as getGitHistory } from '../git/history' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { checkIgnoredPaths } from '../git/check-ignored-paths' +import { + cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, + generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult +} from '../text-generation/commit-message-text-generation' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { gitExecFileAsync } from '../git/runner' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } +type RuntimeCommitMessageSettingsOverride = Partial< + Pick +> + +function normalizeRuntimeGitRelativePath(filePath: string): string { + const relativePath = normalizeRuntimeRelativePath(filePath) + if (relativePath === '') { + // Why: git mutation APIs treat an empty pathspec as the worktree root; + // runtime RPC must never let malformed file paths discard whole worktrees. + throw new Error('invalid_relative_path') + } + return relativePath +} export type RuntimeGitCommandHost = { resolveRuntimeGitTarget( selector: string ): Promise<{ worktree: ResolvedRuntimeGitWorktree; connectionId?: string }> + getRuntimeSettings(): GlobalSettings + getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined } export class RuntimeGitCommands { constructor(private readonly host: RuntimeGitCommandHost) {} - async getRuntimeGitStatus(worktreeSelector: string): Promise { + async getRuntimeGitStatus( + worktreeSelector: string, + options?: { includeIgnored?: boolean } + ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { throw new Error('remote_git_unavailable') } - return provider.getStatus(target.worktree.path) + return options + ? provider.getStatus(target.worktree.path, options) + : provider.getStatus(target.worktree.path) } - return getGitStatus(target.worktree.path) + return options + ? getGitStatus(target.worktree.path, options) + : getGitStatus(target.worktree.path) + } + + async checkRuntimeGitIgnoredPaths( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.checkIgnoredPaths(target.worktree.path, relativePaths) + } + return checkIgnoredPaths(target.worktree.path, relativePaths) + } + + async getRuntimeGitHistory( + worktreeSelector: string, + options: GitHistoryOptions = {} + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getHistory(target.worktree.path, options) + } + return getGitHistory(target.worktree.path, options) } async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { @@ -69,7 +145,7 @@ export class RuntimeGitCommands { compareAgainstHead?: boolean ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePath = normalizeRuntimeRelativePath(filePath) + const relativePath = normalizeRuntimeGitRelativePath(filePath) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -95,6 +171,21 @@ export class RuntimeGitCommands { return getBranchCompare(target.worktree.path, baseRef) } + async getRuntimeGitCommitCompare( + worktreeSelector: string, + commitId: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getCommitCompare(target.worktree.path, commitId) + } + return getCommitCompare(target.worktree.path, commitId) + } + async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -160,8 +251,8 @@ export class RuntimeGitCommands { oldPath?: string ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePath = normalizeRuntimeRelativePath(filePath) - const oldRelativePath = oldPath ? normalizeRuntimeRelativePath(oldPath) : undefined + const relativePath = normalizeRuntimeGitRelativePath(filePath) + const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -190,6 +281,33 @@ export class RuntimeGitCommands { }) } + async getRuntimeGitCommitDiff( + worktreeSelector: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(args.filePath) + const oldRelativePath = args.oldPath ? normalizeRuntimeRelativePath(args.oldPath) : undefined + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getCommitDiff(target.worktree.path, { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + return getCommitDiff(target.worktree.path, { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + async commitRuntimeGit( worktreeSelector: string, message: string @@ -208,9 +326,158 @@ export class RuntimeGitCommands { return commitChanges(target.worktree.path, message) } + async generateRuntimeCommitMessage( + worktreeSelector: string, + settingsOverride?: RuntimeCommitMessageSettingsOverride + ): Promise { + const resolvedSettings = resolveCommitMessageSettings({ + ...this.host.getRuntimeSettings(), + ...settingsOverride + }) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + return { + success: false, + error: `No git provider for connection "${target.connectionId}"` + } + } + let context: CommitMessageDraftContext | null + try { + context = await provider.getStagedCommitContext(target.worktree.path) + } catch (error) { + console.error('[runtime-git] Failed to read remote staged commit context:', error) + return { success: false, error: 'Failed to read staged changes.' } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: target.worktree.path, + execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + let context: CommitMessageDraftContext | null + try { + context = await getStagedCommitContext(target.worktree.path) + } catch (error) { + console.error('[runtime-git] Failed to read staged commit context:', error) + return { success: false, error: 'Failed to read staged changes.' } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + this.host.getCommitMessageAgentEnvironment?.() + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: target.worktree.path, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + + async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + await provider?.cancelGenerateCommitMessage(target.worktree.path) + return { ok: true } + } + cancelGenerateCommitMessageLocal(target.worktree.path) + return { ok: true } + } + + async generateRuntimePullRequestFields( + worktreeSelector: string, + input: { base: string; title: string; body: string; draft: boolean }, + settingsOverride?: RuntimeCommitMessageSettingsOverride + ): Promise { + const resolvedSettings = resolveCommitMessageSettings({ + ...this.host.getRuntimeSettings(), + ...settingsOverride + }) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId && !provider) { + return { + success: false, + error: `No git provider for connection "${target.connectionId}"` + } + } + const context = target.connectionId + ? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + }) + : await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }), + { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + + if (target.connectionId) { + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: target.worktree.path, + execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + this.host.getCommitMessageAgentEnvironment?.() + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: target.worktree.path, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + + async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + await provider?.cancelGenerateCommitMessage(target.worktree.path) + return { ok: true } + } + cancelGeneratePullRequestFieldsLocal(target.worktree.path) + return { ok: true } + } + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePath = normalizeRuntimeRelativePath(filePath) + const relativePath = normalizeRuntimeGitRelativePath(filePath) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -225,7 +492,7 @@ export class RuntimeGitCommands { async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePath = normalizeRuntimeRelativePath(filePath) + const relativePath = normalizeRuntimeGitRelativePath(filePath) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -243,7 +510,7 @@ export class RuntimeGitCommands { filePaths: string[] ): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path)) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -261,7 +528,7 @@ export class RuntimeGitCommands { filePaths: string[] ): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePaths = filePaths.map((path) => normalizeRuntimeRelativePath(path)) + const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path)) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -274,9 +541,27 @@ export class RuntimeGitCommands { return { ok: true } } + async bulkDiscardRuntimeGitPaths( + worktreeSelector: string, + filePaths: string[] + ): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path)) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + await provider.bulkDiscardChanges(target.worktree.path, relativePaths) + return { ok: true } + } + await bulkDiscardChanges(target.worktree.path, relativePaths) + return { ok: true } + } + async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const relativePath = normalizeRuntimeRelativePath(filePath) + const relativePath = normalizeRuntimeGitRelativePath(filePath) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -295,7 +580,7 @@ export class RuntimeGitCommands { line: number ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) - const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) + const normalizedRelativePath = normalizeRuntimeGitRelativePath(relativePath) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0b89b417f0e..2d0a98e62bf 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,7 +1,15 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeMeta } from '../../shared/types' -import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' +import { EventEmitter } from 'events' +import { mkdtemp, rm } from 'fs/promises' +import type { WorktreeLineage, WorktreeMeta } from '../../shared/types' +import { + addWorktree, + assertWorktreeCleanForRemoval, + listWorktrees, + removeWorktree +} from '../git/worktree' +import * as gitRunner from '../git/runner' import { createSetupRunnerScript, getEffectiveHooks, @@ -10,7 +18,7 @@ import { runHook, shouldRunSetupForCreate } from '../hooks' -import { getDefaultBaseRef } from '../git/repo' +import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' import { @@ -18,6 +26,7 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' const { MOCK_GIT_WORKTREES, @@ -25,30 +34,59 @@ const { removeWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - invalidateAuthorizedRootsCacheMock -} = vi.hoisted(() => ({ - MOCK_GIT_WORKTREES: [ - { - path: '/tmp/worktree-a', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - } - ], - addWorktreeMock: vi.fn(), - removeWorktreeMock: vi.fn(), - computeWorktreePathMock: vi.fn(), - ensurePathWithinWorkspaceMock: vi.fn(), - invalidateAuthorizedRootsCacheMock: vi.fn() -})) + sshGitProviders, + getSshGitProviderMock, + registerSshGitProviderMock, + unregisterSshGitProviderMock, + invalidateAuthorizedRootsCacheMock, + createHostedReviewMock, + getHostedReviewCreationEligibilityMock +} = vi.hoisted(() => { + // Why: SSH runtime tests register providers through the public dispatcher API, + // so the mock needs the same registry semantics as the real module. + const sshGitProviders = new Map() + + return { + MOCK_GIT_WORKTREES: [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ], + addWorktreeMock: vi.fn(), + removeWorktreeMock: vi.fn(), + computeWorktreePathMock: vi.fn(), + ensurePathWithinWorkspaceMock: vi.fn(), + sshGitProviders, + getSshGitProviderMock: vi.fn((connectionId: string) => sshGitProviders.get(connectionId)), + registerSshGitProviderMock: vi.fn((connectionId: string, provider: unknown) => { + sshGitProviders.set(connectionId, provider) + }), + unregisterSshGitProviderMock: vi.fn((connectionId: string) => { + sshGitProviders.delete(connectionId) + }), + invalidateAuthorizedRootsCacheMock: vi.fn(), + createHostedReviewMock: vi.fn(), + getHostedReviewCreationEligibilityMock: vi.fn() + } +}) vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), + assertWorktreeCleanForRemoval: vi.fn().mockResolvedValue(undefined), addWorktree: addWorktreeMock, removeWorktree: removeWorktreeMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + registerSshGitProvider: registerSshGitProviderMock, + unregisterSshGitProvider: unregisterSshGitProviderMock +})) + vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), @@ -71,7 +109,14 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { }) vi.mock('../ipc/filesystem-auth', () => ({ - invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock, + isENOENT: (error: unknown) => + Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') +})) + +vi.mock('../source-control/hosted-review-creation', () => ({ + createHostedReview: createHostedReviewMock, + getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock })) // Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a @@ -92,7 +137,22 @@ vi.mock('../git/repo', async (importOriginal) => { afterEach(() => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() + vi.mocked(assertWorktreeCleanForRemoval).mockReset() + vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined) vi.mocked(removeWorktree).mockReset() + sshGitProviders.clear() + getSshGitProviderMock.mockReset() + getSshGitProviderMock.mockImplementation((connectionId: string) => + sshGitProviders.get(connectionId) + ) + registerSshGitProviderMock.mockReset() + registerSshGitProviderMock.mockImplementation((connectionId: string, provider: unknown) => { + sshGitProviders.set(connectionId, provider) + }) + unregisterSshGitProviderMock.mockReset() + unregisterSshGitProviderMock.mockImplementation((connectionId: string) => { + sshGitProviders.delete(connectionId) + }) vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(hasHooksFile).mockReset() @@ -106,6 +166,25 @@ afterEach(() => { computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() + createHostedReviewMock.mockReset() + createHostedReviewMock.mockResolvedValue({ + ok: true, + provider: 'github', + number: 1, + url: 'https://example.com/pull/1' + }) + getHostedReviewCreationEligibilityMock.mockReset() + getHostedReviewCreationEligibilityMock.mockResolvedValue({ + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'main', + head: 'feature/foo', + title: null, + body: null + }) }) function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { @@ -152,6 +231,24 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + function deferred(): { promise: Promise resolve: (value: T) => void @@ -485,6 +582,46 @@ describe('OrcaRuntimeService', () => { }) }) + it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => { + const runtime = new OrcaRuntimeService(store) + vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' }) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/feature-something') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/feature-something') + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/feature-something', + head: 'def', + branch: 'feature/something', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'feature/something', + baseBranch: 'origin/feature/something', + branchNameOverride: 'feature/something' + }) + + expect(getBranchConflictKind).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/something', + 'origin/feature/something' + ) + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + '/tmp/workspaces/feature-something', + 'feature/something', + 'origin/feature/something', + false + ) + expect(result.worktree).toMatchObject({ + path: '/tmp/workspaces/feature-something', + branch: 'feature/something' + }) + }) + it('does not run local git when runtime worktree creation targets an SSH repo', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(addWorktree).mockClear() @@ -741,6 +878,73 @@ describe('OrcaRuntimeService', () => { ) }) + it('rejects hosted review worktree selectors outside the selected repo', async () => { + vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => { + if (repoPath === '/tmp/repo-b') { + return [ + { + path: '/tmp/worktree-b', + head: 'def', + branch: 'feature/bar', + isBare: false, + isMainWorktree: false + } + ] + } + return MOCK_GIT_WORKTREES + }) + const repos = [ + { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: 'repo-2', + path: '/tmp/repo-b', + displayName: 'repo-b', + badgeColor: 'green', + addedAt: 2 + } + ] + const multiRepoStore = { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id) + } + const runtime = new OrcaRuntimeService(multiRepoStore as never) + + await expect( + runtime.getHostedReviewCreationEligibility({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + branch: 'feature/bar', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 1, + behind: 0 + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + await expect( + runtime.createHostedReview({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + provider: 'github', + base: 'main', + head: 'feature/bar', + title: 'Create PR', + body: '', + draft: false + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + + expect(getHostedReviewCreationEligibilityMock).not.toHaveBeenCalled() + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) + it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(getDefaultBaseRef).mockClear() @@ -811,6 +1015,136 @@ describe('OrcaRuntimeService', () => { expect(added).toHaveLength(0) }) + it('defaults runtime addRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(colorStore as never) + + const repo = await runtime.addRepo('/tmp/runtime-add-default', 'folder') + + expect(repo.badgeColor).toBe(DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + }) + + it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(colorStore as never) + const parentDir = await mkdtemp('/tmp/orca-runtime-create-') + try { + const result = await runtime.createRepo(parentDir, 'runtime-create-default', 'folder') + if ('error' in result) { + throw new Error(result.error) + } + + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('preserves existing badgeColor on runtime createRepo dedupe', async () => { + const existing = { + id: 'runtime-existing-create', + path: '/tmp/runtime-existing-create', + displayName: 'runtime-existing-create', + badgeColor: '#14b8a6', + addedAt: 1, + kind: 'folder' as const + } + const colorStore = { + ...store, + getRepos: () => [existing] + } + const runtime = new OrcaRuntimeService(colorStore as never) + + const result = await runtime.createRepo('/tmp', 'runtime-existing-create', 'folder') + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#14b8a6') + }) + + it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc as never + }) + const runtime = new OrcaRuntimeService(colorStore as never) + + try { + const repo = await runtime.cloneRepo('https://example.com/repo-badge-color.git', '/tmp') + expect(repo.badgeColor).toBe(DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + } finally { + spawnSpy.mockRestore() + } + }) + + it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc as never + }) + const existing = { + id: 'runtime-folder-upgrade', + path: '/tmp/repo-badge-color', + displayName: 'repo-badge-color', + badgeColor: '#ec4899', + addedAt: 1, + kind: 'folder' as const + } + const updates: { id: string; updates: Record }[] = [] + const upgraded = { ...existing, kind: 'git' as const } + const colorStore = { + ...store, + getRepos: () => [existing], + updateRepo: (id: string, repoUpdates: Record) => { + updates.push({ id, updates: repoUpdates }) + return upgraded as never + } + } + const runtime = new OrcaRuntimeService(colorStore as never) + + try { + const repo = await runtime.cloneRepo('https://example.com/repo-badge-color.git', '/tmp') + expect(updates).toEqual([{ id: existing.id, updates: { kind: 'git' } }]) + expect(repo).toEqual(upgraded) + expect(repo.badgeColor).toBe('#ec4899') + } finally { + spawnSpy.mockRestore() + } + }) + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { vi.mocked(listWorktrees).mockResolvedValue([ { @@ -1423,6 +1757,49 @@ describe('OrcaRuntimeService', () => { } }) + it('does not replay an already-delivered message on a later idle transition', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + await vi.advanceTimersByTimeAsync(500) + + const firstInjections = write.mock.calls.filter( + (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + ).length + expect(firstInjections).toBe(1) + + // Second idle transition: the row is still unread (no check caller has + // consumed it), but it has been delivered. Push-on-idle must skip it to + // avoid the replay bug. + runtime.deliverPendingMessagesForHandle(terminal.handle) + await vi.advanceTimersByTimeAsync(500) + + const totalInjections = write.mock.calls.filter( + (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + ).length + expect(totalInjections).toBe(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => { const runtime = new OrcaRuntimeService(store) const handle = runtime.preAllocateHandleForPty('pty-1') @@ -1955,6 +2332,8 @@ describe('OrcaRuntimeService', () => { repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', + parentWorktreeId: null, + childWorktreeIds: [], displayName: 'foo', linkedIssue: 123, linkedPR: null, @@ -2151,6 +2530,955 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) + it('resolves SSH worktrees when manually updating lineage', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const childId = `${remoteRepo.id}::/home/user/repo-child` + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const metaById: Record = { + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const listSshWorktrees = vi.fn().mockResolvedValue([ + { + path: '/home/user/repo-child', + head: 'abc', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: '/home/user/repo-parent', + head: 'def', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual' + }) + ) + }) + + it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { + const parentPath = '/tmp/worktree-a' + const childPath = '/tmp/worktree-b' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => + worktreeId === parentId + ? { + worktreeId: parentId, + worktreeInstanceId: 'old-parent-instance', + parentWorktreeId: childId, + parentWorktreeInstanceId: 'child-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + : undefined, + setWorktreeLineage + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/a', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/b', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'new-parent-instance' + }) + ) + }) + + it('backfills instanceId during runtime selector resolution for upgraded metadata', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta(), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { + ...metaById[worktreeId], + ...meta, + instanceId: meta.instanceId ?? metaById[worktreeId]?.instanceId ?? 'backfilled-instance' + } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage: vi.fn((_worktreeId: string, lineage) => lineage) + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(runtimeStore.setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'backfilled-instance' + }) + ) + }) + + it('rotates a missing parent instance during runtime selector scans before same-path reuse', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'old-parent-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId], + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage: vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }), + setWorktreeLineage + } + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValue([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.showManagedWorktree(`id:${childId}`) + const rotatedParentInstance = metaById[parentId].instanceId + expect(rotatedParentInstance).toBeTruthy() + expect(rotatedParentInstance).not.toBe('old-parent-instance') + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'rescanned' }) + expect(metaById[parentId].instanceId).toBe(rotatedParentInstance) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'touch' }) + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: rotatedParentInstance + }) + ) + }) + + it('does not prune lineage when a runtime local worktree scan fails', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + vi.mocked(listWorktrees).mockRejectedValueOnce(new Error('git unavailable')) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('does not prune lineage when an SSH runtime provider is unavailable', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const childId = `${remoteRepo.id}::/home/user/repo-child` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('exposes valid parent and child lineage in CLI worktree records', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'parent' + }), + [childId]: makeWorktreeMeta({ + instanceId: 'child-instance', + displayName: 'child' + }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const listed = await runtime.listManagedWorktrees('id:repo-1') + const parent = listed.worktrees.find((worktree) => worktree.id === parentId) + const child = listed.worktrees.find((worktree) => worktree.id === childId) + + expect(parent).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + expect(child).toMatchObject({ + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: lineageById[childId] + }) + await expect(runtime.showManagedWorktree(`id:${childId}`)).resolves.toMatchObject({ + id: childId, + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: lineageById[childId] + }) + }) + + it('keeps valid orchestration lineage when caller terminal context is stale', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/worker-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }) + } + const setWorktreeLineage = vi.fn((worktreeId: string, lineage) => { + metaById[worktreeId] = metaById[worktreeId] ?? makeWorktreeMeta() + return lineage + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta({ instanceId: 'child-instance' }) + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'abc', + branch: 'feature/coordinator', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { + callerTerminalHandle: 'term_stale', + orchestrationContext: { + parentWorktreeId: parentId, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + } + } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + }) + expect(result.lineage).not.toHaveProperty('createdByTerminalHandle') + expect(result.warnings).toEqual([]) + expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + }) + + it('enriches caller-terminal lineage with active orchestration dispatch context', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }), + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn(() => ({ + task_id: 'task-1' + })), + getActiveCoordinatorRun: vi.fn(() => ({ + id: 'run-1', + coordinator_handle: coordinatorHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + title: 'Coordinator', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-coordinator', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + ...MOCK_GIT_WORKTREES, + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { callerTerminalHandle: workerHandle } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle, + createdByTerminalHandle: workerHandle + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: expect.not.stringMatching(/^old-/), + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('falls back to cwd lineage when the caller terminal handle is stale', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/cwd-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'cwd-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cwd-child', + lineage: { + callerTerminalHandle: 'term_stale', + cwdParentWorktree: `id:${parentId}` + } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' } + }) + expect(result.worktree).toMatchObject({ + parentWorktreeId: parentId, + childWorktreeIds: [], + lineage: result.lineage + }) + expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + }) + + it('keeps cwd-inferred lineage best-effort when the cwd parent cannot be resolved', async () => { + const childPath = '/tmp/workspaces/no-cwd-parent' + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce(MOCK_GIT_WORKTREES) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'no-cwd-parent', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(store) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'no-cwd-parent', + lineage: { + cwdParentWorktree: 'id:repo-1::/tmp/missing-parent' + } + }) + + expect(result.lineage).toBeNull() + expect(result.worktree).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + }) + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the current directory as a parent workspace.' + }) + ]) + }) + + it('infers orchestration lineage from task-id comments when dispatch is completed', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => ({ + task_id: 'task_abc123', + assignee_handle: workerHandle, + status: 'completed' + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + comment: 'Created via orchestration task task_abc123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_abc123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('infers orchestration lineage from task creator when no dispatch context exists', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/parent-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = `${TEST_REPO_ID}::${parentPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'parent' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const parentHandle = runtime.preAllocateHandleForPty('pty-parent') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => undefined), + getTask: vi.fn(() => ({ + id: 'task_creator123', + created_by_terminal_handle: parentHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + title: 'Parent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-parent', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'fed', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'parent-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'parent-child', + comment: 'Created via orchestration task task_creator123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_creator123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'parent-instance' + }) + ) + }) + it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -2635,6 +3963,73 @@ describe('OrcaRuntimeService', () => { ) }) + it('fails dirty non-force deletes before PTY teardown', async () => { + const runtime = new OrcaRuntimeService(store) + const killSpy = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write: () => true, + kill: (id) => killSpy(id), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('Worktree has uncommitted or untracked changes.'), { + stdout: '?? scratch.txt\n' + }) + ) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + `Failed to delete worktree at ${TEST_WORKTREE_PATH}. ?? scratch.txt` + ) + + expect(killSpy).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('formats preflight subprocess failures and skips PTY teardown', async () => { + const runtime = new OrcaRuntimeService(store) + const killSpy = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write: () => true, + kill: (id) => killSpy(id), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: unable to read current working directory\n' + }) + ) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + `Failed to delete worktree at ${TEST_WORKTREE_PATH}. fatal: unable to read current working directory` + ) + + expect(killSpy).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('falls through to orphan cleanup when preflight reports missing/non-repo worktree', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${TEST_WORKTREE_PATH}' is not a working tree` + }) + ) + vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' }) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({}) + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + }) + it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -3043,6 +4438,9 @@ describe('OrcaRuntimeService', () => { const killSpy = vi.fn().mockReturnValue(true) const localProvider = createProviderStub(async () => []) const callOrder: string[] = [] + vi.mocked(assertWorktreeCleanForRemoval).mockImplementation(async () => { + callOrder.push('preflight') + }) vi.mocked(removeWorktree).mockImplementation(async () => { callOrder.push('git-removeWorktree') }) @@ -3068,8 +4466,11 @@ describe('OrcaRuntimeService', () => { expect(killSpy).toHaveBeenCalledWith('pty-1') // The provider-prefix sweep and the git removal must happen AFTER the // runtime-graph kill. Git removal must NOT happen before any kill. + const preflightIdx = callOrder.indexOf('preflight') const killIdx = callOrder.indexOf('kill:pty-1') const gitIdx = callOrder.indexOf('git-removeWorktree') + expect(preflightIdx).toBeGreaterThanOrEqual(0) + expect(killIdx).toBeGreaterThan(preflightIdx) expect(killIdx).toBeGreaterThanOrEqual(0) expect(gitIdx).toBeGreaterThan(killIdx) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d589fbeab65..408e6dbdcfc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15,6 +15,7 @@ import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { + BaseRefSearchResult, CreateWorktreeResult, GitPushTarget, GitWorktreeInfo, @@ -22,6 +23,8 @@ import type { Repo, StatsSummary, Worktree, + WorktreeLineage, + WorktreeLineageWarning, WorktreeMeta, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, @@ -95,6 +98,7 @@ import { getPRComments, getIssue, resolveReviewThread, + setPRFileViewed, getWorkItemByOwnerRepo, updatePRTitle, mergePR, @@ -191,17 +195,23 @@ import { getBranchConflictKind, isGitRepo, getRepoName, - searchBaseRefs, + searchBaseRefDetails, getRemoteCount, normalizeRefSearchQuery, - parseAndFilterSearchRefs, + parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' -import { listWorktrees, addWorktree, addSparseWorktree, removeWorktree } from '../git/worktree' +import { + listWorktrees, + addWorktree, + addSparseWorktree, + assertWorktreeCleanForRemoval, + removeWorktree +} from '../git/worktree' import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, @@ -216,7 +226,7 @@ import { shouldRunSetupForCreate, writeIssueCommand } from '../hooks' -import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' import { @@ -232,6 +242,7 @@ import { computeWorktreePath, ensurePathWithinWorkspace, formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, isOrphanedWorktreeError, mergeWorktree, sanitizeWorktreeName, @@ -253,6 +264,7 @@ import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from ' import type { RateLimitState } from '../../shared/rate-limit-types' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService @@ -286,6 +298,10 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getWorktreeLineage?: Store['getWorktreeLineage'] + getAllWorktreeLineage?: Store['getAllWorktreeLineage'] + setWorktreeLineage?: Store['setWorktreeLineage'] + removeWorktreeLineage?: Store['removeWorktreeLineage'] getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { @@ -455,6 +471,7 @@ type TerminalWaiter = { reject: (error: Error) => void timeout: NodeJS.Timeout | null pollInterval: NodeJS.Timeout | null + abortCleanup: (() => void) | null } type MessageWaiter = { @@ -470,7 +487,86 @@ function omitUndefinedProperties>(value: T): P ) as Partial } -type ResolvedWorktree = Worktree & { git: GitWorktreeInfo } +async function resolveCreateBranchName( + repoPath: string, + branchNameOverride: string | undefined, + sanitizedName: string, + settings: { branchPrefix: string; branchPrefixCustom?: string }, + username: string | null +): Promise { + if (!branchNameOverride) { + return computeBranchName(sanitizedName, settings, username) + } + if (branchNameOverride.startsWith('-')) { + throw new Error('Branch name must not start with "-"') + } + await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath }) + return branchNameOverride +} + +type ResolvedWorktree = Worktree & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null + git: GitWorktreeInfo +} + +type WorktreeLineageInput = { + parentWorktree?: string + cwdParentWorktree?: string + noParent?: boolean + callerTerminalHandle?: string + comment?: string + orchestrationContext?: { + parentWorktreeId?: string + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + } +} + +type WorktreeLineageResolution = + | { + kind: 'lineage' + parent: ResolvedWorktree + origin: WorktreeLineage['origin'] + capture: WorktreeLineage['capture'] + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + } + | { + kind: 'none' + warnings: WorktreeLineageWarning[] + } + +type RuntimeWorktreeScanResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; worktrees: [] } + +type WorktreeLineageCandidate = { + source: 'cwd-context' | 'terminal-context' | 'orchestration-context' + parent: ResolvedWorktree + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string +} + +function extractOrchestrationTaskId(text?: string): string | undefined { + return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] +} + +class RuntimeLineageError extends Error { + code: string + data?: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } +} type ResolvedWorktreeCache = { expiresAt: number @@ -778,6 +874,7 @@ export class OrcaRuntimeService { private optimisticReconcileTokens = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null + private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private mobileDictation: { id: string owner: string @@ -1121,17 +1218,25 @@ export class OrcaRuntimeService { ) private readonly gitCommands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector) + resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), + getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, + getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined }) getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] = + this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands) + getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = + this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) + getRuntimeGitCommitCompare: RuntimeGitCommands['getRuntimeGitCommitCompare'] = + this.gitCommands.getRuntimeGitCommitCompare.bind(this.gitCommands) getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( @@ -1145,9 +1250,19 @@ export class OrcaRuntimeService { ) getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) + getRuntimeGitCommitDiff: RuntimeGitCommands['getRuntimeGitCommitDiff'] = + this.gitCommands.getRuntimeGitCommitDiff.bind(this.gitCommands) commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( this.gitCommands ) + generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = + this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) + cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = + this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) + generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = + this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) + cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = + this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = @@ -1156,6 +1271,8 @@ export class OrcaRuntimeService { this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) + bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] = + this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands) discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = @@ -1747,6 +1864,12 @@ export class OrcaRuntimeService { this.accountServices = services } + setCommitMessageAgentEnvironmentResolvers( + resolvers: CommitMessageAgentEnvironmentResolvers + ): void { + this.commitMessageAgentEnv = resolvers + } + async startMobileDictation(params: { dictationId: string modelId?: string @@ -3683,6 +3806,7 @@ export class OrcaRuntimeService { options?: { condition?: RuntimeTerminalWaitCondition timeoutMs?: number + signal?: AbortSignal } ): Promise { const condition = options?.condition ?? 'exit' @@ -3707,7 +3831,12 @@ export class OrcaRuntimeService { resolve, reject, timeout: null, - pollInterval: null + pollInterval: null, + abortCleanup: null + } + if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { + reject(new Error('request_aborted')) + return } if (effectiveTimeoutMs > 0) { waiter.timeout = setTimeout(() => { @@ -3764,7 +3893,13 @@ export class OrcaRuntimeService { resolve, reject, timeout: null, - pollInterval: null + pollInterval: null, + abortCleanup: null + } + + if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { + reject(new Error('request_aborted')) + return } if (effectiveTimeoutMs > 0) { @@ -3852,6 +3987,8 @@ export class OrcaRuntimeService { repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, + parentWorktreeId: worktree.parentWorktreeId, + childWorktreeIds: worktree.childWorktreeIds, displayName: worktree.displayName, linkedIssue: worktree.linkedIssue, linkedPR, @@ -3975,7 +4112,7 @@ export class OrcaRuntimeService { id: randomUUID(), path, displayName: getRepoName(path), - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind } @@ -4083,7 +4220,7 @@ export class OrcaRuntimeService { id: randomUUID(), path: targetPath, displayName: trimmedName, - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -4150,7 +4287,7 @@ export class OrcaRuntimeService { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git' } @@ -4266,12 +4403,13 @@ export class OrcaRuntimeService { truncated: false } } - const refs = repo.connectionId + const refDetails = repo.connectionId ? await this.searchRemoteRepoRefs(repo, query, limit + 1) - : await searchBaseRefs(repo.path, query, limit + 1) + : await searchBaseRefDetails(repo.path, query, limit + 1) return { - refs: refs.slice(0, limit), - truncated: refs.length > limit + refs: refDetails.slice(0, limit).map((entry) => entry.refName), + refDetails: refDetails.slice(0, limit), + truncated: refDetails.length > limit } } @@ -4327,7 +4465,11 @@ export class OrcaRuntimeService { return { defaultBaseRef, remoteCount } } - private async searchRemoteRepoRefs(repo: Repo, query: string, limit: number): Promise { + private async searchRemoteRepoRefs( + repo: Repo, + query: string, + limit: number + ): Promise { const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null if (!provider) { return [] @@ -4337,8 +4479,15 @@ export class OrcaRuntimeService { return [] } try { - const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path) - return parseAndFilterSearchRefs(result.stdout, limit) + const [result, remotesResult] = await Promise.all([ + provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path), + provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) + ]) + const remotes = remotesResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) } catch (err) { console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { path: repo.path, @@ -4354,6 +4503,22 @@ export class OrcaRuntimeService { } } + private async resolveHostedReviewTarget(args: { + repoSelector: string + worktreeSelector?: string + }): Promise<{ repo: Repo; repoPath: string }> { + const repo = await this.resolveRepoSelector(args.repoSelector) + if (!args.worktreeSelector) { + return { repo, repoPath: repo.path } + } + + const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) + if (worktree.repoId !== repo.id) { + throw new Error('Access denied: worktree does not belong to repository') + } + return { repo, repoPath: worktree.path } + } + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') @@ -4444,6 +4609,7 @@ export class OrcaRuntimeService { linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) @@ -4454,6 +4620,7 @@ export class OrcaRuntimeService { linkedGitHubPR: args.linkedGitHubPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null }) if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { @@ -4468,12 +4635,15 @@ export class OrcaRuntimeService { } async getHostedReviewCreationEligibility( - args: Omit & { repoSelector: string } + args: Omit & { + repoSelector: string + worktreeSelector?: string + } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') return getHostedReviewCreationEligibilityFromRepo({ - repoPath: repo.path, + repoPath, branch: args.branch, base: args.base ?? null, hasUncommittedChanges: args.hasUncommittedChanges, @@ -4483,16 +4653,17 @@ export class OrcaRuntimeService { linkedGitHubPR: args.linkedGitHubPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null }) } async createHostedReview( - args: CreateHostedReviewInput & { repoSelector: string } + args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') - const result = await createHostedReviewFromRepo(repo.path, { + const result = await createHostedReviewFromRepo(repoPath, { provider: args.provider, base: args.base, head: args.head, @@ -4567,6 +4738,19 @@ export class OrcaRuntimeService { return resolveReviewThread(repo.path, threadId, resolve) } + async setRepoPRFileViewed( + repoSelector: string, + args: { + pullRequestId: string + path: string + viewed: boolean + } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_file_viewed') + return setPRFileViewed({ repoPath: repo.path, ...args }) + } + async updateRepoPRTitle( repoSelector: string, prNumber: number, @@ -4995,11 +5179,13 @@ export class OrcaRuntimeService { repoSelector: string name: string baseBranch?: string + branchNameOverride?: string linkedIssue?: number | null linkedPR?: number | null linkedLinearIssue?: string comment?: string displayName?: string + workspaceStatus?: string sparseCheckout?: { directories: string[]; presetId?: string } pushTarget?: GitPushTarget runHooks?: boolean @@ -5007,6 +5193,7 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch + lineage?: WorktreeLineageInput }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -5022,14 +5209,34 @@ export class OrcaRuntimeService { // Runtime RPC must not fall through to local git against server paths. throw new Error('SSH-backed worktree creation is not supported through runtime RPC yet.') } + const lineageInput = + args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined + const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) const settings = this.store.getSettings() const requestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) const username = getGitUsername(repo.path) - const branchName = computeBranchName(sanitizedName, settings, username) + const branchName = await resolveCreateBranchName( + repo.path, + args.branchNameOverride, + sanitizedName, + settings, + username + ) - const branchConflictKind = await getBranchConflictKind(repo.path, branchName) + const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + if (!baseBranch) { + // Why: getDefaultBaseRef returns null when no suitable ref exists. + // Don't fabricate 'origin/main' — passing it to addWorktree would + // produce an opaque git failure. Surface a clear error so the CLI + // caller can pick an explicit --base ref. + throw new Error( + 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' + ) + } + + const branchConflictKind = await getBranchConflictKind(repo.path, branchName, baseBranch) if (branchConflictKind) { throw new Error( `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` @@ -5056,17 +5263,6 @@ export class OrcaRuntimeService { const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) - if (!baseBranch) { - // Why: getDefaultBaseRef returns null when no suitable ref exists. - // Don't fabricate 'origin/main' — passing it to addWorktree would - // produce an opaque git failure. Surface a clear error so the CLI - // caller can pick an explicit --base ref. - throw new Error( - 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' - ) - } - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' // Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back // CLI creates on the same repo don't each pay the round-trip, and so a @@ -5137,6 +5333,10 @@ export class OrcaRuntimeService { ? { displayName: requestedName } : {} const meta = this.store.setWorktreeMeta(worktreeId, { + // Why: worktree IDs are path-derived. If a path is deleted outside Orca + // and later recreated, creation must mint a fresh instance identity so + // stale lineage records tied to the old occupant fail validation. + instanceId: randomUUID(), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't @@ -5159,9 +5359,48 @@ export class OrcaRuntimeService { ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.comment !== undefined ? { comment: args.comment } : {}) + ...(args.comment !== undefined ? { comment: args.comment } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) + let lineage: WorktreeLineage | null = null + const lineageWarnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] + if (lineageResolution.kind === 'lineage') { + const childInstanceId = meta.instanceId + const parentInstanceId = lineageResolution.parent.instanceId + if (childInstanceId && parentInstanceId && this.store.setWorktreeLineage) { + lineage = this.store.setWorktreeLineage(worktreeId, { + worktreeId, + worktreeInstanceId: childInstanceId, + parentWorktreeId: lineageResolution.parent.id, + parentWorktreeInstanceId: parentInstanceId, + origin: lineageResolution.origin, + capture: lineageResolution.capture, + ...(lineageResolution.orchestrationRunId + ? { orchestrationRunId: lineageResolution.orchestrationRunId } + : {}), + ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), + ...(lineageResolution.coordinatorHandle + ? { coordinatorHandle: lineageResolution.coordinatorHandle } + : {}), + ...(lineageResolution.createdByTerminalHandle + ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } + : {}), + createdAt: now + }) + } else { + lineageWarnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', + details: { + childHasInstanceId: Boolean(childInstanceId), + parentHasInstanceId: Boolean(parentInstanceId), + storeSupportsLineage: Boolean(this.store.setWorktreeLineage) + } + }) + } + } if ( settings.experimentalWorktreeSymlinks && @@ -5296,7 +5535,14 @@ export class OrcaRuntimeService { } } return { - worktree, + worktree: { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: [], + lineage, + git: created + }, + ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), ...(warning ? { warning } : {}) } @@ -5641,17 +5887,53 @@ export class OrcaRuntimeService { return { base, behind: drift.behind, recentSubjects } } - async updateManagedWorktreeMeta(worktreeSelector: string, updates: Partial) { + async updateManagedWorktreeMeta( + worktreeSelector: string, + updates: Partial & { + lineage?: { + parentWorktree?: string + noParent?: boolean + } + } + ) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const meta = this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(updates)) + const { lineage, ...metaUpdates } = updates + if (lineage?.noParent === true) { + this.store.removeWorktreeLineage?.(worktree.id) + } else if (lineage?.parentWorktree) { + const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) + this.validateLineageParent(worktree, parent) + if (!worktree.instanceId || !parent.instanceId) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace instance identity was unavailable.' + ) + } + if (!this.store.setWorktreeLineage) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace lineage storage was unavailable.' + ) + } + this.store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: Date.now() + }) + } + this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(metaUpdates)) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(worktree.repoId) - return mergeWorktree(worktree.repoId, worktree.git, meta) + return await this.showManagedWorktree(`id:${worktree.id}`) } persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { @@ -5805,15 +6087,36 @@ export class OrcaRuntimeService { return {} } - // Why: kill every PTY belonging to this worktree BEFORE the git-level - // removal. Some shells keep the worktree directory busy, and `git worktree - // remove` throws a confusing error if PTYs still hold it open. This also - // closes the headless-CLI leak (design §2a/§2b): without this call, the - // CLI path runs git removal and never touches PTYs, leaving zombies - // behind. Best-effort: any failure here must not prevent git removal — - // the worst case without the call is the status quo. + const hooks = getEffectiveHooks(repo) + let warning: string | undefined + if (hooks?.scripts.archive && runHooks) { + const result = await runHook('archive', worktree.path, repo) + if (!result.success) { + console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output) + } + } else if (hooks?.scripts.archive) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + warning = `orca.yaml archive hook skipped for ${worktree.path}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) + } + + let shouldTearDownPtys = true + try { + await assertWorktreeCleanForRemoval(worktree.path, force) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) + } + // Why: orphan cleanup does not need live shells to be killed first, + // and preflight did not prove the worktree is cleanly removable. + shouldTearDownPtys = false + } + const localProvider = this.getLocalProvider() - if (localProvider) { + if (localProvider && shouldTearDownPtys) { + // Why: once preflight proves normal deletion is clean, kill PTYs before + // git-level removal so shells cannot keep the directory busy. This also + // closes the headless-CLI leak for confirmed-removable worktrees. await killAllProcessesForWorktree(worktree.id, { runtime: this, localProvider @@ -5836,19 +6139,6 @@ export class OrcaRuntimeService { }) } - const hooks = getEffectiveHooks(repo) - let warning: string | undefined - if (hooks?.scripts.archive && runHooks) { - const result = await runHook('archive', worktree.path, repo) - if (!result.success) { - console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output) - } - } else if (hooks?.scripts.archive) { - // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - warning = `orca.yaml archive hook skipped for ${worktree.path}; pass --run-hooks to run it.` - console.warn(`[hooks] ${warning}`) - } - try { await removeWorktree(repo.path, worktree.path, force) } catch (error) { @@ -5912,14 +6202,17 @@ export class OrcaRuntimeService { env?: Record title?: string focus?: boolean + rendererBacked?: boolean + activate?: boolean tabId?: string leafId?: string } = {} ): Promise { - if (opts.focus !== true) { - if (!worktreeSelector) { - throw new Error('MISSING_WORKTREE') - } + // Why: pre-diff createTerminal fell back to the renderer's active worktree + // when no selector was provided. The new background-spawn branch hard- + // requires a resolvable selector, so route the no-selector case through + // the renderer IPC path to preserve that behavior. + if (opts.focus !== true && opts.rendererBacked !== true && worktreeSelector) { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } @@ -6027,7 +6320,8 @@ export class OrcaRuntimeService { requestId, worktreeId, command: opts.command, - title: opts.title + title: opts.title, + activate: opts.focus === true || opts.activate === true }) }) @@ -6466,6 +6760,23 @@ export class OrcaRuntimeService { return { stopped } } + async hasTerminalsForWorktree(worktreeSelector: string): Promise { + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === worktree.id && leaf.ptyId) { + return true + } + } + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktree.id && pty.connected) { + return true + } + } + return false + } + markRendererReloading(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return @@ -6577,6 +6888,305 @@ export class OrcaRuntimeService { throw new Error('selector_not_found') } + private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { + const childWorktreeId = child.id + const parentWorktreeId = parent.id + if (childWorktreeId === parentWorktreeId) { + throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.') + } + const instanceByWorktreeId = new Map( + this.resolvedWorktreeCache?.worktrees.map((worktree) => [ + worktree.id, + worktree.instanceId + ]) ?? [ + [child.id, child.instanceId], + [parent.id, parent.instanceId] + ] + ) + let cursor: string | undefined = parentWorktreeId + const visited = new Set([childWorktreeId]) + while (cursor) { + if (visited.has(cursor)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CYCLE', + 'Parent workspace would create a lineage cycle.' + ) + } + visited.add(cursor) + const lineage = this.store?.getWorktreeLineage?.(cursor) + if (!lineage) { + break + } + const cursorInstanceId = instanceByWorktreeId.get(cursor) + const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) + if ( + cursorInstanceId !== lineage.worktreeInstanceId || + parentInstanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + cursor = lineage.parentWorktreeId + } + } + + private async resolveLineageForWorktreeCreate( + input?: WorktreeLineageInput + ): Promise { + if (!input) { + return { kind: 'none', warnings: [] } + } + + if (input.noParent === true && input.parentWorktree) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Choose either --parent-worktree or --no-parent, not both.' + ) + } + + if (input.noParent === true) { + return { kind: 'none', warnings: [] } + } + + if (input.parentWorktree) { + try { + const parent = await this.resolveWorktreeSelector(input.parentWorktree) + return { + kind: 'lineage', + parent, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + } catch { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_NOT_FOUND', + 'Parent workspace was not found.', + { + nextSteps: [ + 'Run `orca worktree list` and pass a valid --parent-worktree selector.', + 'Retry with --no-parent to create without lineage.' + ] + } + ) + } + } + + const warnings: WorktreeLineageWarning[] = [] + const candidates: WorktreeLineageCandidate[] = [] + let cwdCandidate: WorktreeLineageCandidate | null = null + let terminalContextResolved = false + + if (input.orchestrationContext?.parentWorktreeId) { + try { + candidates.push({ + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector( + `id:${input.orchestrationContext.parentWorktreeId}` + ) + }) + } catch { + // Keep creation recoverable; the warning below covers missing inferred context. + } + } + + const commentTaskId = extractOrchestrationTaskId(input.comment) + if (commentTaskId) { + const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) + if (candidate) { + candidates.push(candidate) + } + } + + if (input.callerTerminalHandle) { + try { + const terminal = await this.showTerminal(input.callerTerminalHandle) + const terminalParent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( + input.callerTerminalHandle + ) + const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() + if (activeDispatch) { + candidates.push({ + source: 'orchestration-context', + parent: terminalParent, + taskId: activeDispatch.task_id, + ...(activeRun + ? { + orchestrationRunId: activeRun.id, + coordinatorHandle: activeRun.coordinator_handle + } + : {}) + }) + } else { + candidates.push({ + source: 'terminal-context', + parent: terminalParent + }) + } + terminalContextResolved = true + } catch { + // Why: terminal handles can go stale during reloads or SSH reconnects. + // A valid orchestration parent is still authoritative, so keep resolving + // other inferred candidates instead of dropping lineage completely. + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the caller terminal as a parent workspace.', + details: { callerTerminalHandle: input.callerTerminalHandle } + }) + } + } + + if (input.cwdParentWorktree) { + try { + cwdCandidate = { + source: 'cwd-context', + parent: await this.resolveWorktreeSelector(input.cwdParentWorktree) + } + } catch { + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the current directory as a parent workspace.', + details: { cwdParentWorktree: input.cwdParentWorktree } + }) + } + } + + if (candidates.length === 0 && cwdCandidate) { + candidates.push(cwdCandidate) + } + + if (candidates.length === 0) { + return { kind: 'none', warnings } + } + + const [first] = candidates + const conflict = candidates.find((candidate) => candidate.parent.id !== first.parent.id) + if (conflict) { + return { + kind: 'none', + warnings: [ + { + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: 'Worktree created, but Orca could not prove which parent workspace caused it.', + details: { + terminalParentWorktreeId: candidates.find((c) => c.source === 'terminal-context') + ?.parent.id, + orchestrationParentWorktreeId: candidates.find( + (c) => c.source === 'orchestration-context' + )?.parent.id + } + } + ] + } + } + + const preferred = + candidates.find((candidate) => candidate.source === 'orchestration-context') ?? first + return { + kind: 'lineage', + parent: preferred.parent, + origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', + capture: { source: preferred.source, confidence: 'inferred' }, + ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) + ? { + orchestrationRunId: + preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId + } + : {}), + ...((preferred.taskId ?? input.orchestrationContext?.taskId) + ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } + : {}), + ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) + ? { + coordinatorHandle: + preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle + } + : {}), + ...(terminalContextResolved && input.callerTerminalHandle + ? { createdByTerminalHandle: input.callerTerminalHandle } + : {}) + } + } + + private async resolveLineageCandidateForTaskId( + taskId: string + ): Promise { + const db = this.getOrchestrationDbIfAvailable() + const dispatch = db?.getDispatchContext(taskId) + // Why: agent-created task records may never be dispatched, but the + // creating terminal still identifies the parent workspace for descendants. + const parentHandle = + dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle + if (!parentHandle) { + return null + } + try { + const terminal = await this.showTerminal(parentHandle) + return { + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`), + taskId + } + } catch { + return null + } + } + + private getOrchestrationDbIfAvailable(): OrchestrationDb | null { + try { + return this._orchestrationDb ?? this.getOrchestrationDb() + } catch { + return this._orchestrationDb + } + } + + async hydrateInferredWorktreeLineage(): Promise { + const store = this.store + if ( + !store || + typeof store.getWorktreeLineage !== 'function' || + typeof store.setWorktreeLineage !== 'function' + ) { + return + } + + const worktrees = await this.listResolvedWorktrees() + for (const worktree of worktrees) { + if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { + continue + } + const taskId = extractOrchestrationTaskId(worktree.comment) + if (!taskId) { + continue + } + const candidate = await this.resolveLineageCandidateForTaskId(taskId) + if (!candidate?.parent.instanceId || candidate.parent.id === worktree.id) { + continue + } + try { + this.validateLineageParent(worktree, candidate.parent) + } catch { + continue + } + store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: candidate.parent.id, + parentWorktreeInstanceId: candidate.parent.instanceId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId, + createdAt: Date.now() + }) + } + } + + async listWorktreeLineage(): Promise> { + await this.hydrateInferredWorktreeLineage() + return this.store?.getAllWorktreeLineage?.() ?? {} + } + private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') @@ -6644,21 +7254,35 @@ export class OrcaRuntimeService { return [] } const now = Date.now() - const metaById = this.store.getAllWorktreeMeta() const perRepoWorktrees = await Promise.all( this.store.getRepos().map(async (repo) => { // Why: mobile startup RPCs share this path. A slow repo scan should // degrade one repo's metadata, not block all terminal/session loading. - const gitWorktrees = await withTimeout( - listRepoWorktrees(repo), + const scan = await withTimeout( + this.listRepoWorktreesForResolution(repo), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, - [] + { ok: false, worktrees: [] } ) + const gitWorktrees = scan.worktrees + if (scan.ok) { + this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) + } + const metaById = this.store?.getAllWorktreeMeta() ?? {} return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName) + // Why: lineage validation needs a durable instance ID even when the + // runtime sees a workspace before the renderer's discovery-stamp path. + const existingMeta = metaById[worktreeId] + const meta = + existingMeta && existingMeta.instanceId + ? existingMeta + : this.store?.setWorktreeMeta(worktreeId, {}) + const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) return { ...merged, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, git: { path: gitWorktree.path, head: gitWorktree.head, @@ -6672,7 +7296,7 @@ export class OrcaRuntimeService { }) }) ) - const worktrees = perRepoWorktrees.flat() + const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat()) // Why: terminal polling can be frequent, but git worktree state is still // allowed to change outside Orca. A short TTL avoids shelling out on every // read without pretending the cache is authoritative for long. @@ -6685,6 +7309,86 @@ export class OrcaRuntimeService { return worktrees } + private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] { + const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} + const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) + const validLineageByChildId = new Map() + const childIdsByParentId = new Map() + + for (const [childId, lineage] of Object.entries(lineageById)) { + const child = worktreeById.get(childId) + const parent = worktreeById.get(lineage.parentWorktreeId) + if ( + !child || + !parent || + child.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + // Why: worktree IDs are path-derived. Instance checks keep replacement + // checkouts from appearing as children of stale same-path lineage. + continue + } + validLineageByChildId.set(childId, lineage) + const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] + children.push(childId) + childIdsByParentId.set(lineage.parentWorktreeId, children) + } + + return worktrees.map((worktree) => { + const lineage = validLineageByChildId.get(worktree.id) ?? null + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], + lineage + } + }) + } + + private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { + const store = this.store + if ( + !store || + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: runtime selector scans can be the only scan before a path is + // reused. Once a successful scan proves the child is gone, stale + // lineage must not survive into the replacement checkout. + store.removeWorktreeLineage(childId) + } + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + !liveIds.has(lineage.parentWorktreeId) + ) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: preserving child lineage powers the repair UI, but a missing + // parent path only needs one fresh identity to keep same-path + // replacement checkouts from validating old lineage. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } + } + + private async listRepoWorktreesForResolution(repo: Repo): Promise { + if (!repo.connectionId) { + return { ok: true, worktrees: await listRepoWorktrees(repo) } + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { ok: false, worktrees: [] } + } + return { ok: true, worktrees: await provider.listWorktrees(repo.path) } + } + private async getResolvedWorktreeMap(): Promise> { return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) } @@ -7490,7 +8194,7 @@ export class OrcaRuntimeService { return } - const unread = this._orchestrationDb.getUnreadMessages(handle) + const unread = this._orchestrationDb.getUndeliveredUnreadMessages(handle) if (unread.length === 0) { return } @@ -7539,6 +8243,25 @@ export class OrcaRuntimeService { waiter.resolve(result) } + private bindTerminalWaiterAbort( + waiter: TerminalWaiter, + signal: AbortSignal | undefined + ): boolean { + if (!signal) { + return true + } + if (signal.aborted) { + return false + } + const onAbort = (): void => { + this.removeWaiter(waiter) + waiter.reject(new Error('request_aborted')) + } + waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + return true + } + private rejectWaitersForHandle(handle: string, code: string): void { const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { @@ -7563,6 +8286,10 @@ export class OrcaRuntimeService { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) } + if (waiter.abortCleanup) { + waiter.abortCleanup() + waiter.abortCleanup = null + } const waiters = this.waitersByHandle.get(waiter.handle) if (!waiters) { return diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 9e9d5d941b3..4607afb4bc8 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -61,6 +61,46 @@ describe('OrchestrationDb', () => { expect(filtered[0].type).toBe('worker_done') }) + it('excludes already-delivered rows from getUndeliveredUnreadMessages', () => { + const d = createDb() + const m1 = d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + const m2 = d.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + d.markAsDelivered([m1.id]) + + // Push delivery query: only undelivered, unread. + const pending = d.getUndeliveredUnreadMessages('b') + expect(pending).toHaveLength(1) + expect(pending[0].id).toBe(m2.id) + + // Explicit `check` still sees both (they are still unread). + const unread = d.getUnreadMessages('b') + expect(unread).toHaveLength(2) + }) + + it('creates the undelivered inbox index used by push delivery', () => { + const d = createDb() + const sqlite = (d as unknown as { db: Database.Database }).db + + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'messages' AND name = 'idx_messages_undelivered_inbox'` + ) + .all() + + expect(indexes).toHaveLength(1) + }) + + it('filters getUndeliveredUnreadMessages by type', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 's', type: 'status' }) + const wd = d.insertMessage({ from: 'a', to: 'b', subject: 'd', type: 'worker_done' }) + + const filtered = d.getUndeliveredUnreadMessages('b', ['worker_done']) + expect(filtered).toHaveLength(1) + expect(filtered[0].id).toBe(wd.id) + }) + it('marks messages as read', () => { const d = createDb() const m1 = d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) @@ -132,6 +172,17 @@ describe('OrchestrationDb', () => { expect(task.deps).toBe('[]') }) + it('persists the creating terminal handle for task-created worktrees', () => { + const d = createDb() + const task = d.createTask({ + spec: 'spawn related workspace', + createdByTerminalHandle: 'term_creator' + }) + + expect(task.created_by_terminal_handle).toBe('term_creator') + expect(d.getTask(task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('creates a task with deps as pending', () => { const d = createDb() const parent = d.createTask({ spec: 'parent' }) @@ -729,6 +780,7 @@ describe('OrchestrationDb', () => { const names = new Set(indexes.map((r) => r.name)) expect(names.has('idx_messages_id')).toBe(true) expect(names.has('idx_inbox')).toBe(true) + expect(names.has('idx_messages_undelivered_inbox')).toBe(true) expect(names.has('idx_thread')).toBe(true) // v1 data preserved diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index e8daa9f1708..570a1aa0404 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -36,10 +36,10 @@ function generateId(prefix: string): string { // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged -// messages without touching the `read` bit (check-wait PR). Bumping together -// since both PRs ship in the same release — existing on-disk DBs must clear -// both gaps in one atomic migration. -const SCHEMA_VERSION = 3 +// messages without touching the `read` bit (check-wait PR). v3 → v4 records +// the terminal that created a task so task-record worktree creation can infer +// the parent workspace even when no dispatch context exists. +const SCHEMA_VERSION = 4 export class OrchestrationDb { private db: Database.Database @@ -83,6 +83,7 @@ export class OrchestrationDb { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, parent_id TEXT, + created_by_terminal_handle TEXT, spec TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ( @@ -141,6 +142,7 @@ export class OrchestrationDb { completed_at TEXT ); `) + this.createUndeliveredInboxIndexIfPossible() } // Why: `CREATE TABLE IF NOT EXISTS` is a no-op against an existing on-disk @@ -169,14 +171,12 @@ export class OrchestrationDb { if (!this.messagesTypeCheckAllowsHeartbeat()) { // Why — index list is not optional. createTables() already attached - // idx_messages_id / idx_inbox / idx_thread to the old messages table; - // DROP TABLE removes those indexes with it. CREATE INDEX IF NOT - // EXISTS in createTables() only runs on the next process startup, - // so skipping explicit recreation here would leave every - // getUnreadMessages / getMessageById call full-scanning for the - // rest of this process's lifetime — a silent O(N) perf regression. - // The three CREATE INDEX statements below mirror createTables() - // verbatim so the two definitions cannot drift. + // idx_messages_id / idx_inbox / idx_messages_undelivered_inbox / + // idx_thread to the old messages table; DROP TABLE removes those + // indexes with it. CREATE INDEX IF NOT EXISTS in createTables() only + // runs on the next process startup, so skipping explicit recreation + // here would leave message lookups full-scanning for the rest of this + // process's lifetime — a silent O(N) perf regression. this.db.exec(` CREATE TABLE messages_new ( id TEXT NOT NULL, @@ -211,6 +211,8 @@ export class OrchestrationDb { CREATE UNIQUE INDEX idx_messages_id ON messages(id); CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_messages_undelivered_inbox + ON messages(to_handle, read, delivered_at, sequence); CREATE INDEX idx_thread ON messages(thread_id); `) } @@ -227,6 +229,12 @@ export class OrchestrationDb { this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`) } } + if (current < 4) { + if (!this.hasColumn('tasks', 'created_by_terminal_handle')) { + this.db.exec(`ALTER TABLE tasks ADD COLUMN created_by_terminal_handle TEXT`) + } + } + this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') @@ -241,6 +249,16 @@ export class OrchestrationDb { return rows.some((r) => r.name === column) } + private createUndeliveredInboxIndexIfPossible(): void { + if (!this.hasColumn('messages', 'delivered_at')) { + return + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_messages_undelivered_inbox + ON messages(to_handle, read, delivered_at, sequence) + `) + } + // Why: sqlite_master stores the original CREATE TABLE SQL including the // CHECK clause. Inspecting that text is the cheapest reliable way to tell // whether the pre-rebuild schema already knows about 'heartbeat' without @@ -297,6 +315,28 @@ export class OrchestrationDb { .all(toHandle) as MessageRow[] } + // Why: push-on-idle delivery must not replay messages that were already + // injected into the PTY. `read` flips only when a check-caller consumes a + // message, so delivered-but-unread rows would otherwise be re-injected on + // every later idle transition (the replay bug). Filter on + // `delivered_at IS NULL` so each row is auto-pushed at most once; explicit + // `check` still sees them via getUnreadMessages. + getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + } + return this.db + .prepare( + 'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence' + ) + .all(toHandle) as MessageRow[] + } + getAllMessages(toHandle: string, limit = 20): MessageRow[] { return this.db .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') @@ -367,14 +407,28 @@ export class OrchestrationDb { // ── Tasks ── - createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { + createTask(task: { + spec: string + deps?: string[] + parentId?: string + createdByTerminalHandle?: string + }): TaskRow { const id = generateId('task') const depsJson = JSON.stringify(task.deps ?? []) const hasDeps = (task.deps ?? []).length > 0 const status: TaskStatus = hasDeps ? 'pending' : 'ready' this.db - .prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)') - .run(id, task.parentId ?? null, task.spec, status, depsJson) + .prepare( + 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?)' + ) + .run( + id, + task.parentId ?? null, + task.createdByTerminalHandle ?? null, + task.spec, + status, + depsJson + ) return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 03a31391cc9..f966368b937 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -37,6 +37,7 @@ export type MessageRow = { export type TaskRow = { id: string parent_id: string | null + created_by_terminal_handle: string | null spec: string status: TaskStatus deps: string diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts new file mode 100644 index 00000000000..bf11965bf07 --- /dev/null +++ b/src/main/runtime/rpc/errors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { mapRuntimeError } from './errors' + +class LineageError extends Error { + code = 'LINEAGE_PARENT_NOT_FOUND' + data = { + nextSteps: ['Run `orca worktree list`.', 'Retry with --no-parent.'] + } +} + +describe('mapRuntimeError', () => { + it('preserves structured lineage error codes and data for CLI recovery hints', () => { + const response = mapRuntimeError( + 'req_1', + { runtimeId: 'runtime-1' }, + new LineageError('Parent workspace was not found.') + ) + + expect(response).toEqual({ + id: 'req_1', + ok: false, + error: { + code: 'LINEAGE_PARENT_NOT_FOUND', + message: 'Parent workspace was not found.', + data: { + nextSteps: ['Run `orca worktree list`.', 'Retry with --no-parent.'] + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index da19f1cba1a..98937e6a1b3 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -59,6 +59,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow ) { return errorResponse(id, meta, (error as { code: string }).code, message) } + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + (error as { code: string }).code.startsWith('LINEAGE_') + ) { + return errorResponse( + id, + meta, + (error as { code: string }).code, + message, + (error as { data?: unknown }).data + ) + } if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts new file mode 100644 index 00000000000..41f1207bcdb --- /dev/null +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const GitStatusParams = WorktreeSelector.extend({ + includeIgnored: z.boolean().optional() +}) + +export const GitCheckIgnored = WorktreeSelector.extend({ + paths: z.array(z.string().min(1, 'Missing path')).max(2000) +}) + +export const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +export const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +export const GitBranchCompare = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +export const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +export const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + +export const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +export const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + +export const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +const CommitMessageAiSettings = z.object({ + enabled: z.boolean(), + agentId: z.string().nullable(), + selectedModelByAgent: z.record(z.string(), z.string()), + selectedThinkingByModel: z.record(z.string(), z.string()), + customPrompt: z.string(), + customAgentCommand: z.string() +}) + +export const GitGenerateCommitMessage = WorktreeSelector.extend({ + commitMessageAi: CommitMessageAiSettings.optional(), + agentCmdOverrides: z.record(z.string(), z.string()).optional(), + enableGitHubAttribution: z.boolean().optional() +}) + +export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ + base: z.string().min(1, 'Missing base branch'), + title: z.string(), + body: z.string(), + draft: z.boolean() +}) + +export const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string().min(1, 'Missing file path')) +}) + +export const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + pushTarget: z.unknown().optional() +}) + +export const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 0bd8a810710..5a23d49f08b 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: git RPC methods share one dispatcher fixture, and keeping the contract cases together makes method coverage easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' @@ -30,6 +31,54 @@ describe('git RPC methods', () => { }) }) + it('forwards includeIgnored for status requests', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown', + ignoredPaths: ['dist/'] + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', includeIgnored: true }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + includeIgnored: true + }) + expect(response).toMatchObject({ + ok: true, + result: { ignoredPaths: ['dist/'] } + }) + }) + + it('returns ignored paths for selected explorer rows', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + checkRuntimeGitIgnoredPaths: vi.fn().mockResolvedValue(['dist/bundle.js']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.checkIgnored', { + worktree: 'id:wt-1', + paths: ['dist/bundle.js', 'src/index.ts'] + }) + ) + + expect(runtime.checkRuntimeGitIgnoredPaths).toHaveBeenCalledWith('id:wt-1', [ + 'dist/bundle.js', + 'src/index.ts' + ]) + expect(response).toMatchObject({ + ok: true, + result: ['dist/bundle.js'] + }) + }) + it('returns a worktree file diff', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -59,12 +108,42 @@ describe('git RPC methods', () => { }) }) + it('returns bounded git history for a selected worktree', async () => { + const history = { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + } + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitHistory: vi.fn().mockResolvedValue(history) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.history', { + worktree: 'id:wt-1', + limit: 25, + baseRef: 'origin/main' + }) + ) + + expect(runtime.getRuntimeGitHistory).toHaveBeenCalledWith('id:wt-1', { + limit: 25, + baseRef: 'origin/main' + }) + expect(response).toMatchObject({ ok: true, result: history }) + }) + it('routes common mutations to the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', stageRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }), bulkUnstageRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }), - discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }) + discardRuntimeGitPath: vi.fn().mockResolvedValue({ ok: true }), + bulkDiscardRuntimeGitPaths: vi.fn().mockResolvedValue({ ok: true }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) @@ -77,16 +156,42 @@ describe('git RPC methods', () => { await dispatcher.dispatch( makeRequest('git.discard', { worktree: 'id:wt-1', filePath: 'src/a.ts' }) ) + await dispatcher.dispatch( + makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: ['src/a.ts', 'b.ts'] }) + ) expect(runtime.stageRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') expect(runtime.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts']) expect(runtime.discardRuntimeGitPath).toHaveBeenCalledWith('id:wt-1', 'src/a.ts') + expect(runtime.bulkDiscardRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['src/a.ts', 'b.ts']) + }) + + it('rejects empty bulk mutation paths before calling the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + bulkDiscardRuntimeGitPaths: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.bulkDiscard', { worktree: 'id:wt-1', filePaths: [''] }) + ) + + expect(response.ok).toBe(false) + expect(response).toMatchObject({ + error: expect.objectContaining({ code: 'invalid_argument' }) + }) + expect(runtime.bulkDiscardRuntimeGitPaths).not.toHaveBeenCalled() }) it('routes remote operations to the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', commitRuntimeGit: vi.fn().mockResolvedValue({ success: true }), + generateRuntimeCommitMessage: vi + .fn() + .mockResolvedValue({ success: true, message: 'feat: test' }), + cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }), pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3') } as unknown as OrcaRuntimeService @@ -95,6 +200,10 @@ describe('git RPC methods', () => { await dispatcher.dispatch( makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' }) ) + await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' })) + await dispatcher.dispatch( + makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' }) + ) await dispatcher.dispatch( makeRequest('git.push', { worktree: 'id:wt-1', @@ -111,10 +220,68 @@ describe('git RPC methods', () => { ) expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test') + expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1') + expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' }) expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' }) }) + it('forwards commit-message settings to the runtime', async () => { + const commitMessageAi = { + enabled: true, + agentId: 'codex', + selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' }, + selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' }, + customPrompt: '', + customAgentCommand: '' + } + const agentCmdOverrides = { codex: 'codex --profile work' } + const runtime = { + getRuntimeId: () => 'test-runtime', + generateRuntimeCommitMessage: vi.fn().mockResolvedValue({ success: true, message: 'test' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.generateCommitMessage', { + worktree: 'id:wt-1', + commitMessageAi, + agentCmdOverrides, + enableGitHubAttribution: true + }) + ) + + expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', { + commitMessageAi, + agentCmdOverrides, + enableGitHubAttribution: true + }) + }) + + it('rejects malformed commit-message settings before calling the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + generateRuntimeCommitMessage: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.generateCommitMessage', { + worktree: 'id:wt-1', + commitMessageAi: { + enabled: true, + agentId: 'codex' + } + }) + ) + + expect(response.ok).toBe(false) + expect(response).toMatchObject({ + error: expect.objectContaining({ code: 'invalid_argument' }) + }) + expect(runtime.generateRuntimeCommitMessage).not.toHaveBeenCalled() + }) + it('rejects branch diff revisions that are not full object ids', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -154,4 +321,22 @@ describe('git RPC methods', () => { expect(response.ok).toBe(false) expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() }) + + it('rejects git history limits above the runtime cap', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitHistory: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.history', { + worktree: 'id:wt-1', + limit: 201 + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitHistory).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 3d4e6274046..64e7c63c895 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,80 +1,47 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' - -const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -const GitFilePath = WorktreeSelector.extend({ - filePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing file path')) -}) - -const GitDiff = GitFilePath.extend({ - staged: z.boolean(), - compareAgainstHead: z.boolean().optional() -}) - -const GitBranchCompare = WorktreeSelector.extend({ - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -const FullGitObjectId = z - .string() - .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') - -const GitBranchDiff = GitFilePath.extend({ - compare: z.object({ - baseRef: z.string().optional(), - baseOid: FullGitObjectId.optional(), - headOid: FullGitObjectId, - mergeBase: FullGitObjectId - }), - oldPath: z.string().optional() -}) - -const GitCommit = WorktreeSelector.extend({ - message: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing commit message')) -}) - -const GitBulkPaths = WorktreeSelector.extend({ - filePaths: z.array(z.string()) -}) - -const GitPush = WorktreeSelector.extend({ - publish: z.boolean().optional(), - pushTarget: z.unknown().optional() -}) - -const GitRemoteFileUrl = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')), - line: z.number().int().min(1) -}) +import type { GlobalSettings } from '../../../../shared/types' +import { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCheckIgnored, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitFilePath, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRemoteFileUrl, + GitStatusParams, + WorktreeSelector +} from './git-params' export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', - params: WorktreeSelector, - handler: async (params, { runtime }) => runtime.getRuntimeGitStatus(params.worktree) + params: GitStatusParams, + handler: async (params, { runtime }) => + params.includeIgnored === undefined + ? runtime.getRuntimeGitStatus(params.worktree) + : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) + }), + defineMethod({ + name: 'git.checkIgnored', + params: GitCheckIgnored, + handler: async (params, { runtime }) => + runtime.checkRuntimeGitIgnoredPaths(params.worktree, params.paths) + }), + defineMethod({ + name: 'git.history', + params: GitHistory, + handler: async (params, { runtime }) => + runtime.getRuntimeGitHistory(params.worktree, { + limit: params.limit, + baseRef: params.baseRef + }) }), defineMethod({ name: 'git.conflictOperation', @@ -98,6 +65,12 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) }), + defineMethod({ + name: 'git.commitCompare', + params: GitCommitCompare, + handler: async (params, { runtime }) => + runtime.getRuntimeGitCommitCompare(params.worktree, params.commitId) + }), defineMethod({ name: 'git.upstreamStatus', params: WorktreeSelector, @@ -130,12 +103,93 @@ export const GIT_METHODS: RpcMethod[] = [ params.oldPath ) }), + defineMethod({ + name: 'git.commitDiff', + params: GitCommitDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitCommitDiff(params.worktree, { + commitOid: params.commitOid, + parentOid: params.parentOid, + filePath: params.filePath, + oldPath: params.oldPath + }) + }), defineMethod({ name: 'git.commit', params: GitCommit, handler: async (params, { runtime }) => runtime.commitRuntimeGit(params.worktree, params.message) }), + defineMethod({ + name: 'git.generateCommitMessage', + params: GitGenerateCommitMessage, + handler: async (params, { runtime }) => { + if ( + params.commitMessageAi === undefined && + params.agentCmdOverrides === undefined && + params.enableGitHubAttribution === undefined + ) { + return runtime.generateRuntimeCommitMessage(params.worktree) + } + return runtime.generateRuntimeCommitMessage(params.worktree, { + ...(params.commitMessageAi !== undefined + ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } + : {}), + ...(params.agentCmdOverrides !== undefined + ? { + agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] + } + : {}), + ...(params.enableGitHubAttribution !== undefined + ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}) + }) + } + }), + defineMethod({ + name: 'git.cancelGenerateCommitMessage', + params: WorktreeSelector, + handler: async (params, { runtime }) => + runtime.cancelRuntimeGenerateCommitMessage(params.worktree) + }), + defineMethod({ + name: 'git.generatePullRequestFields', + params: GitGeneratePullRequestFields, + handler: async (params, { runtime }) => { + const input = { + base: params.base, + title: params.title, + body: params.body, + draft: params.draft + } + if ( + params.commitMessageAi === undefined && + params.agentCmdOverrides === undefined && + params.enableGitHubAttribution === undefined + ) { + return runtime.generateRuntimePullRequestFields(params.worktree, input) + } + return runtime.generateRuntimePullRequestFields(params.worktree, input, { + ...(params.commitMessageAi !== undefined + ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } + : {}), + ...(params.agentCmdOverrides !== undefined + ? { + agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] + } + : {}), + ...(params.enableGitHubAttribution !== undefined + ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}) + }) + } + }), + defineMethod({ + name: 'git.cancelGeneratePullRequestFields', + params: WorktreeSelector, + handler: async (params, { runtime }) => + runtime.cancelRuntimeGeneratePullRequestFields(params.worktree) + }), defineMethod({ name: 'git.stage', params: GitFilePath, @@ -166,6 +220,12 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.discardRuntimeGitPath(params.worktree, params.filePath) }), + defineMethod({ + name: 'git.bulkDiscard', + params: GitBulkPaths, + handler: async (params, { runtime }) => + runtime.bulkDiscardRuntimeGitPaths(params.worktree, params.filePaths) + }), defineMethod({ name: 'git.remoteFileUrl', params: GitRemoteFileUrl, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index cff2852341b..c72537b2c6f 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -219,6 +219,30 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: true }) }) + it('marks PR files viewed on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRFileViewed: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRFileViewed', { + repo: 'repo-1', + pullRequestId: 'PR_kwDO123', + path: 'src/app.ts', + viewed: true + }) + ) + + expect(runtime.setRepoPRFileViewed).toHaveBeenCalledWith('repo-1', { + pullRequestId: 'PR_kwDO123', + path: 'src/app.ts', + viewed: true + }) + expect(response).toMatchObject({ ok: true, result: true }) + }) + it('updates PR titles on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 137a1d9f641..0aee0943201 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -71,6 +71,12 @@ const PullRequestFileContents = RepoSelector.extend({ baseSha: requiredString('Missing base SHA') }) +const PullRequestFileViewed = RepoSelector.extend({ + pullRequestId: requiredString('Missing pull request ID'), + path: requiredString('Missing file path'), + viewed: z.boolean() +}) + const ReviewThread = RepoSelector.extend({ threadId: requiredString('Missing thread ID'), resolve: z.boolean() @@ -313,6 +319,16 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.resolveRepoReviewThread(params.repo, params.threadId, params.resolve) }), + defineMethod({ + name: 'github.setPRFileViewed', + params: PullRequestFileViewed, + handler: async (params, { runtime }) => + runtime.setRepoPRFileViewed(params.repo, { + pullRequestId: params.pullRequestId, + path: params.path, + viewed: params.viewed + }) + }), defineMethod({ name: 'github.updatePRTitle', params: UpdatePrTitle, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 0474ccaa2e1..63f3d2a8d9f 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -39,6 +39,7 @@ describe('hosted review RPC methods', () => { linkedGitHubPR: 12, linkedGitLabMR: null, linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, linkedGiteaPR: null }) expect(response).toMatchObject({ @@ -66,6 +67,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.getCreationEligibility', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -78,6 +80,7 @@ describe('hosted review RPC methods', () => { expect(runtime.getHostedReviewCreationEligibility).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -87,6 +90,7 @@ describe('hosted review RPC methods', () => { linkedGitHubPR: null, linkedGitLabMR: null, linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, linkedGiteaPR: null }) expect(response).toMatchObject({ @@ -109,6 +113,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.create', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', @@ -120,6 +125,7 @@ describe('hosted review RPC methods', () => { expect(runtime.createHostedReview).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 3416e9939fc..cd2ccf4550a 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -8,11 +8,13 @@ const HostedReviewForBranch = z.object({ linkedGitHubPR: z.number().int().positive().nullable().optional(), linkedGitLabMR: z.number().int().positive().nullable().optional(), linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), linkedGiteaPR: z.number().int().positive().nullable().optional() }) const HostedReviewCreationEligibility = z.object({ repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), branch: requiredString('Missing branch'), base: z.string().nullable().optional(), hasUncommittedChanges: z.boolean().optional(), @@ -22,12 +24,14 @@ const HostedReviewCreationEligibility = z.object({ linkedGitHubPR: z.number().int().positive().nullable().optional(), linkedGitLabMR: z.number().int().positive().nullable().optional(), linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), linkedGiteaPR: z.number().int().positive().nullable().optional() }) const HostedReviewCreate = z.object({ repo: requiredString('Missing repo selector'), - provider: z.enum(['github', 'gitlab', 'bitbucket', 'gitea', 'unsupported']), + worktree: z.string().min(1, 'Missing worktree selector').optional(), + provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), base: requiredString('Missing base branch'), head: z.string().optional(), title: requiredString('Missing title'), @@ -46,6 +50,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ linkedGitHubPR: params.linkedGitHubPR ?? null, linkedGitLabMR: params.linkedGitLabMR ?? null, linkedBitbucketPR: params.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: params.linkedAzureDevOpsPR ?? null, linkedGiteaPR: params.linkedGiteaPR ?? null }) }), @@ -55,6 +60,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getHostedReviewCreationEligibility({ repoSelector: params.repo, + worktreeSelector: params.worktree, branch: params.branch, base: params.base ?? null, hasUncommittedChanges: params.hasUncommittedChanges, @@ -64,6 +70,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ linkedGitHubPR: params.linkedGitHubPR ?? null, linkedGitLabMR: params.linkedGitLabMR ?? null, linkedBitbucketPR: params.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: params.linkedAzureDevOpsPR ?? null, linkedGiteaPR: params.linkedGiteaPR ?? null }) }), @@ -73,6 +80,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.createHostedReview({ repoSelector: params.repo, + worktreeSelector: params.worktree, provider: params.provider, base: params.base, head: params.head, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 93856e98adb..27cc27cfff7 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -477,6 +477,16 @@ describe('orchestration RPC methods', () => { expect(result.task.status).toBe('pending') }) + it('records the caller terminal handle when creating a task', async () => { + setup() + const result = (await call('orchestration.taskCreate', { + spec: 'spawn related workspace', + callerTerminalHandle: 'term_creator' + })) as { task: { id: string } } + + expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('rejects invalid deps JSON', async () => { setup() await expect( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 0fb5a6f8680..b2d31e98902 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -81,7 +81,8 @@ const InboxParams = z.object({ const TaskCreateParams = z.object({ spec: requiredString('Missing --spec'), deps: OptionalString, - parent: OptionalString + parent: OptionalString, + callerTerminalHandle: OptionalString }) const TaskListParams = z.object({ @@ -328,7 +329,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const task = db.createTask({ spec: params.spec, deps, - parentId: params.parent + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle }) return { task } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 81f59d22bb8..982be3d9f23 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -319,6 +319,8 @@ const TerminalCreateParams = z.object({ env: z.record(z.string(), z.string()).optional(), title: OptionalString, focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), tabId: OptionalString, leafId: OptionalString }) @@ -529,10 +531,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.wait', params: TerminalWait, - handler: async (params, { runtime }) => ({ + handler: async (params, { runtime, signal }) => ({ wait: await runtime.waitForTerminal(params.terminal, { condition: params.for, - timeoutMs: params.timeoutMs + timeoutMs: params.timeoutMs, + signal }) }) }), @@ -545,6 +548,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ env: params.env, title: params.title, focus: params.focus === true, + rendererBacked: params.rendererBacked === true, + activate: params.activate === true, tabId: params.tabId, leafId: params.leafId }) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index e0da750b5c4..cf5e520be64 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -20,33 +20,111 @@ describe('worktree RPC methods', () => { makeRequest('worktree.create', { repo: 'repo-1', name: 'feature', + branchNameOverride: 'feature/something', baseBranch: 'origin/main', setupDecision: 'skip', displayName: 'Feature title', + workspaceStatus: 'in-review', linkedIssue: 123, linkedPR: 456, sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, - pushTarget: { remoteName: 'fork', branchName: 'feature' } + pushTarget: { remoteName: 'fork', branchName: 'feature' }, + parentWorktree: 'id:parent' }) ) expect(runtime.createManagedWorktree).toHaveBeenCalledWith({ repoSelector: 'repo-1', name: 'feature', + branchNameOverride: 'feature/something', baseBranch: 'origin/main', linkedIssue: 123, linkedPR: 456, + linkedLinearIssue: undefined, comment: undefined, displayName: 'Feature title', + workspaceStatus: 'in-review', sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, pushTarget: { remoteName: 'fork', branchName: 'feature' }, runHooks: false, activate: false, setupDecision: 'skip', - startup: undefined + createdWithAgent: undefined, + startup: undefined, + lineage: { + parentWorktree: 'id:parent', + noParent: false, + callerTerminalHandle: undefined, + orchestrationContext: undefined + } }) }) + it('rejects worktree.create when both parent and no-parent are supplied', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'child', + parentWorktree: 'id:parent', + noParent: true + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('Choose either --parent-worktree or --no-parent') + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) + + it('rejects worktree.set when both parent and no-parent are supplied', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateManagedWorktreeMeta: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.set', { + worktree: 'id:child', + parentWorktree: 'id:parent', + noParent: true + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(JSON.stringify(response)).toContain('Choose either --parent-worktree or --no-parent') + expect(runtime.updateManagedWorktreeMeta).not.toHaveBeenCalled() + }) + + it('lists raw worktree lineage through the runtime server', async () => { + const lineage = { + 'repo::/child': { + worktreeId: 'repo::/child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo::/missing-parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + listWorktreeLineage: vi.fn().mockResolvedValue(lineage) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('worktree.lineageList')) + + expect(runtime.listWorktreeLineage).toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { lineage } }) + }) + it('persists smart sort order on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index ee3de8f36e6..13d0a73c338 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -29,48 +29,71 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) -const WorktreeCreate = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - baseBranch: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - comment: OptionalString, - displayName: OptionalString, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: mobile clients pass a startup command (e.g. 'claude') so the first - // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional() -}) +const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + baseBranch: OptionalString, + branchNameOverride: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + comment: OptionalString, + displayName: OptionalString, + workspaceStatus: OptionalString, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional() + }) + .superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } + }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, @@ -90,6 +113,7 @@ const WorktreeSet = WorktreeSelector.extend({ sparseBaseRef: OptionalString, sparsePresetId: OptionalString, baseRef: OptionalString, + workspaceStatus: OptionalString, pushTarget: z .object({ remoteName: z.string(), @@ -97,7 +121,16 @@ const WorktreeSet = WorktreeSelector.extend({ remoteUrl: OptionalString }) .optional(), - diffComments: z.array(z.unknown()).optional() + diffComments: z.array(z.unknown()).optional(), + parentWorktree: OptionalString, + noParent: OptionalBoolean +}).superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } }) const WorktreeRemove = WorktreeSelector.extend({ @@ -129,6 +162,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [ params: WorktreeListParams, handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit) }), + defineMethod({ + name: 'worktree.lineageList', + params: null, + handler: async (_params, { runtime }) => ({ lineage: await runtime.listWorktreeLineage() }) + }), defineMethod({ name: 'worktree.show', params: WorktreeSelector, @@ -154,18 +192,27 @@ export const WORKTREE_METHODS: RpcMethod[] = [ repoSelector: params.repo, name: params.name ?? '', baseBranch: params.baseBranch, + branchNameOverride: params.branchNameOverride, linkedIssue: params.linkedIssue, linkedPR: params.linkedPR, linkedLinearIssue: params.linkedLinearIssue, comment: params.comment, displayName: params.displayName, + workspaceStatus: params.workspaceStatus, sparseCheckout: params.sparseCheckout, pushTarget: params.pushTarget, runHooks: params.runHooks === true, activate: params.activate === true, setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent, - startup: params.startupCommand ? { command: params.startupCommand } : undefined + startup: params.startupCommand ? { command: params.startupCommand } : undefined, + lineage: { + parentWorktree: params.parentWorktree, + ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } }) }), defineMethod({ @@ -188,8 +235,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [ sparseBaseRef: params.sparseBaseRef, sparsePresetId: params.sparsePresetId, baseRef: params.baseRef, + workspaceStatus: params.workspaceStatus, pushTarget: params.pushTarget, - diffComments: params.diffComments + diffComments: params.diffComments, + lineage: + params.parentWorktree || params.noParent === true + ? { + parentWorktree: params.parentWorktree, + noParent: params.noParent === true + } + : undefined } as Parameters[1]) }) }), diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index a67388bf142..8d66b351c5b 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -694,6 +694,40 @@ describe('OrcaRuntimeRpcServer', () => { expect(pushRuntimeGit).not.toHaveBeenCalled() }) + it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = { + getRuntimeId: () => 'test-runtime', + getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }) + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const channelDevice = server['deviceRegistry']!.addDevice('phone', 'mobile') + const requestDevice = server['deviceRegistry']!.addDevice('cli', 'runtime') + const replies: Record[] = [] + + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_mismatch', + method: 'status.get', + deviceToken: requestDevice.token + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {}, + undefined, + undefined, + channelDevice.token + ) + + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_mismatch', + ok: false, + error: expect.objectContaining({ code: 'unauthorized' }) + }) + ) + }) + it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) @@ -1151,6 +1185,152 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('emits keepalive frames while terminal.wait blocks and returns its structured timeout', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 30 + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + runtime.onPtyData('pty-1', 'Starting MCP servers...\n', 123) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_list', + authToken: metadata!.authToken, + method: 'terminal.list' + }) + const handle = ( + listResponse.result as { + terminals: { handle: string }[] + } + ).terminals[0]!.handle + + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_terminal_wait', + authToken: metadata!.authToken, + method: 'terminal.wait', + params: { + terminal: handle, + for: 'tui-idle', + timeoutMs: 150 + } + }) + await session.done + + const keepalives = session.frames.filter((f) => f._keepalive === true) + const terminalFrames = session.frames.filter((f) => f.ok !== undefined) + expect(keepalives.length).toBeGreaterThanOrEqual(2) + expect(terminalFrames).toHaveLength(1) + expect(terminalFrames[0]).toMatchObject({ + id: 'req_terminal_wait', + ok: false, + error: { code: 'timeout' } + }) + } finally { + await server.stop() + } + }) + + it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + keepaliveIntervalMs: 1000, + longPollCap: 1 + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, { + id: 'req_list', + authToken: metadata!.authToken, + method: 'terminal.list' + }) + const handle = ( + listResponse.result as { + terminals: { handle: string }[] + } + ).terminals[0]!.handle + const endpoint = metadata!.transports[0]!.endpoint + + const session = openFramedSession(endpoint, { + id: 'req_terminal_wait', + authToken: metadata!.authToken, + method: 'terminal.wait', + params: { terminal: handle, for: 'exit', timeoutMs: 10_000 } + }) + await waitFor(() => server['activeLongPolls'] === 1) + + session.socket.destroy() + await session.done + await waitFor(() => server['activeLongPolls'] === 0) + + const admitted = openFramedSession(endpoint, { + id: 'req_terminal_wait_2', + authToken: metadata!.authToken, + method: 'terminal.wait', + params: { terminal: handle, for: 'tui-idle', timeoutMs: 50 } + }) + await admitted.done + expect(admitted.frames.find((f) => f.ok !== undefined)).toMatchObject({ + id: 'req_terminal_wait_2', + ok: false, + error: { code: 'timeout' } + }) + } finally { + await server.stop() + } + }) + it('releases long-poll slot when client closes mid-wait', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f4687e2a9e4..6f940d5b6eb 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -163,17 +163,19 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'worktree.sleep' ]) -// Why: a long-poll request is one whose handler blocks for an unbounded -// amount of time waiting for an external event (today, only -// `orchestration.check` with `wait === true`). This function is the single -// place that classifies it — the long-poll counter, abort wiring, and -// runtime_busy admission check all share this decision. See §3.1. +// Why: a long-poll request is one whose handler blocks waiting for an external +// event. This function is the single place that classifies it — the long-poll +// counter, abort wiring, keepalives, and runtime_busy admission check all +// share this decision. See §3.1. function isLongPollRequest(request: RpcRequest): boolean { - if (request.method !== 'orchestration.check') { - return false + if (request.method === 'terminal.wait') { + return true } - const params = request.params as { wait?: unknown } | undefined - return params?.wait === true + if (request.method === 'orchestration.check') { + const params = request.params as { wait?: unknown } | undefined + return params?.wait === true + } + return false } export class OrcaRuntimeRpcServer { @@ -457,12 +459,14 @@ export class OrcaRuntimeRpcServer { } }) channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => { + const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null void this.handleWebSocketMessage( plaintext, encryptedReply, encryptedBinaryReply, wsTransport, - ws + ws, + authenticatedDeviceToken ) }) channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) @@ -630,7 +634,8 @@ export class OrcaRuntimeRpcServer { reply: (response: string) => void, sendBinary: (response: Uint8Array) => void, wsTransport?: WebSocketTransport, - ws?: WebSocket + ws?: WebSocket, + authenticatedDeviceToken?: string | null ): Promise { let request: RpcRequest try { @@ -649,10 +654,17 @@ export class OrcaRuntimeRpcServer { return } - const token = + const requestToken = typeof (request as Record).deviceToken === 'string' ? ((request as Record).deviceToken as string) : null + if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch'))) + return + } + // Why: E2EE already authenticated the WebSocket channel. Use that bound + // identity for authorization instead of trusting a repeated request field. + const token = authenticatedDeviceToken ?? requestToken if (!token) { reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) return diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts new file mode 100644 index 00000000000..6f9369c5aad --- /dev/null +++ b/src/main/skills/discovery.test.ts @@ -0,0 +1,58 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildSkillDiscoverySources, discoverSkills } from './discovery' +import type { Repo } from '../../shared/types' + +function makeRepo(path: string, connectionId: string | null = null): Repo { + return { + id: `repo-${path}`, + path, + displayName: 'Repo', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId + } +} + +describe('skill discovery', () => { + it('discovers home and repo SKILL.md packages with provider metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const repo = join(root, 'repo') + const codexSkill = join(home, '.codex', 'skills', 'review') + const repoSkill = join(repo, '.claude', 'skills', 'docs') + await mkdir(codexSkill, { recursive: true }) + await mkdir(repoSkill, { recursive: true }) + await writeFile( + join(codexSkill, 'SKILL.md'), + ['---', 'name: code-review', 'description: Review code changes.', '---', ''].join('\n') + ) + await writeFile(join(repoSkill, 'SKILL.md'), '# Docs\n\nWrite project docs.') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd'), + repos: [makeRepo(repo)] + }) + + expect(result.skills.map((skill) => skill.name).sort()).toEqual(['Docs', 'code-review']) + expect(result.skills.find((skill) => skill.name === 'code-review')?.providers).toEqual([ + 'codex' + ]) + expect(result.skills.find((skill) => skill.name === 'Docs')?.providers).toEqual(['claude']) + }) + + it('does not add SSH-backed repository paths to local scan roots', () => { + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/remote/repo', 'ssh-1')] + }) + + expect(roots.map((root) => root.path)).not.toContain('/remote/repo/.claude/skills') + expect(roots.map((root) => root.path)).toContain('/workspace/current/.claude/skills') + }) +}) diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts new file mode 100644 index 00000000000..91ef680f52e --- /dev/null +++ b/src/main/skills/discovery.ts @@ -0,0 +1,269 @@ +import { createHash } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { open, readdir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, join, relative, sep } from 'node:path' +import type { Repo } from '../../shared/types' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import type { + DiscoveredSkill, + SkillDiscoveryResult, + SkillDiscoverySource, + SkillProvider, + SkillSourceKind +} from '../../shared/skills' + +type SkillScanRoot = Omit + +const SKILL_FILE_NAME = 'SKILL.md' +const MAX_MARKDOWN_BYTES = 256 * 1024 +const MAX_SKILL_FILES = 200 + +async function pathExists(pathValue: string): Promise { + try { + await stat(pathValue) + return true + } catch { + return false + } +} + +function stableId(pathValue: string): string { + return createHash('sha1').update(pathValue).digest('hex').slice(0, 16) +} + +function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + a.sourceLabel.localeCompare(b.sourceLabel, undefined, { sensitivity: 'base' }) || + a.skillFilePath.localeCompare(b.skillFilePath) + ) +} + +function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): boolean { + const rel = relative(rootPath, childPath) + if (!rel || rel.startsWith('..')) { + return true + } + return rel.split(sep).length <= maxDepth +} + +function sourceKindForSkill(root: SkillScanRoot, skillFilePath: string): SkillSourceKind { + if ( + root.sourceKind === 'home' && + relative(root.path, skillFilePath).split(sep)[0] === '.system' + ) { + return 'bundled' + } + return root.sourceKind +} + +function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string { + if (sourceKind === 'bundled') { + return `${root.label} bundled` + } + return root.label +} + +async function findSkillFiles(rootPath: string, maxDepth: number): Promise { + const out: string[] = [] + async function visit(dirPath: string): Promise { + if (!isWithinDepth(rootPath, dirPath, maxDepth)) { + return + } + let entries: Dirent[] + try { + entries = await readdir(dirPath, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const entryPath = join(dirPath, entry.name) + if (entry.isFile() && entry.name === SKILL_FILE_NAME) { + out.push(entryPath) + continue + } + if (entry.isDirectory()) { + await visit(entryPath) + } + } + } + await visit(rootPath) + return out +} + +async function countFiles(dirPath: string): Promise { + let count = 0 + async function visit(currentPath: string): Promise { + if (count >= MAX_SKILL_FILES) { + return + } + let entries: Dirent[] + try { + entries = await readdir(currentPath, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (count >= MAX_SKILL_FILES) { + return + } + const entryPath = join(currentPath, entry.name) + if (entry.isFile()) { + count += 1 + } else if (entry.isDirectory()) { + await visit(entryPath) + } + } + } + await visit(dirPath) + return count +} + +async function readSkillSummary(skillFilePath: string): Promise<{ + name: string | null + description: string | null + updatedAt: number | null +}> { + try { + const fileStat = await stat(skillFilePath) + const file = await open(skillFilePath, 'r') + let content = '' + try { + const buffer = Buffer.alloc(Math.min(fileStat.size, MAX_MARKDOWN_BYTES)) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + content = buffer.toString('utf8', 0, bytesRead) + } finally { + await file.close() + } + return { + ...summarizeSkillMarkdown(content), + updatedAt: fileStat.mtimeMs + } + } catch { + return { name: null, description: null, updatedAt: null } + } +} + +async function scanRoot(root: SkillScanRoot): Promise { + const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 + const skillFiles = await findSkillFiles(root.path, maxDepth) + const skills = await Promise.all( + skillFiles.map(async (skillFilePath) => { + const directoryPath = dirname(skillFilePath) + const summary = await readSkillSummary(skillFilePath) + const sourceKind = sourceKindForSkill(root, skillFilePath) + return { + id: stableId(skillFilePath), + name: summary.name ?? basename(directoryPath), + description: summary.description, + providers: root.providers, + sourceKind, + sourceLabel: sourceLabelForSkill(root, sourceKind), + rootPath: root.path, + directoryPath, + skillFilePath, + installed: true, + fileCount: await countFiles(directoryPath), + updatedAt: summary.updatedAt + } satisfies DiscoveredSkill + }) + ) + return skills +} + +function source( + id: string, + label: string, + path: string, + sourceKind: SkillSourceKind, + providers: SkillProvider[] +): SkillScanRoot { + return { id, label, path, sourceKind, providers } +} + +export function buildSkillDiscoverySources( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + } = {} +): SkillScanRoot[] { + const home = args.homeDir ?? homedir() + const cwd = args.cwd ?? process.cwd() + const roots: SkillScanRoot[] = [ + source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), + source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']), + source( + 'codex-plugin-cache', + 'Codex plugin cache', + join(home, '.codex', 'plugins', 'cache'), + 'plugin', + ['codex', 'agent-skills'] + ) + ] + + const repoPaths = new Set() + for (const repo of args.repos ?? []) { + if (repo.connectionId) { + continue + } + repoPaths.add(repo.path) + } + repoPaths.add(cwd) + + for (const repoPath of repoPaths) { + const label = `Repo ${basename(repoPath)}` + roots.push( + source( + `repo-agents-${stableId(repoPath)}`, + `${label} .agents`, + join(repoPath, '.agents', 'skills'), + 'repo', + ['agent-skills'] + ), + source( + `repo-claude-${stableId(repoPath)}`, + `${label} .claude`, + join(repoPath, '.claude', 'skills'), + 'repo', + ['claude'] + ) + ) + } + + return roots +} + +export async function discoverSkills(args: { + repos?: Repo[] + homeDir?: string + cwd?: string +}): Promise { + const roots = buildSkillDiscoverySources(args) + const sources: SkillDiscoverySource[] = [] + const skillGroups = await Promise.all( + roots.map(async (root) => { + const exists = await pathExists(root.path) + sources.push({ ...root, exists, skippedReason: exists ? undefined : 'missing' }) + if (!exists) { + return [] + } + return scanRoot(root) + }) + ) + const seen = new Map() + for (const skill of skillGroups.flat()) { + seen.set(skill.skillFilePath, skill) + } + return { + skills: Array.from(seen.values()).sort(compareSkills), + sources: sources.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ), + scannedAt: Date.now() + } +} diff --git a/src/main/source-control/hosted-review-azure-devops.integration.test.ts b/src/main/source-control/hosted-review-azure-devops.integration.test.ts new file mode 100644 index 00000000000..80f2a06cb42 --- /dev/null +++ b/src/main/source-control/hosted-review-azure-devops.integration.test.ts @@ -0,0 +1,128 @@ +import { execFile } from 'child_process' +import { mkdtemp, rm } from 'fs/promises' +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { tmpdir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { _resetAzureDevOpsRepoRefCache } from '../azure-devops/repository-ref' +import { getHostedReviewForBranch } from './hosted-review' + +const execFileAsync = promisify(execFile) +const OLD_ENV = process.env + +type SeenRequest = { + pathname: string + search: string + authorization: string | undefined +} + +function sendJson(res: ServerResponse, body: unknown): void { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +describe('Azure DevOps hosted review integration', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'local-pat' } + delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL + _resetAzureDevOpsRepoRefCache() + }) + + afterEach(() => { + process.env = OLD_ENV + _resetAzureDevOpsRepoRefCache() + }) + + it('resolves an Azure Repos PR through real git remote parsing and HTTP API calls', async () => { + const seen: SeenRequest[] = [] + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + seen.push({ + pathname: url.pathname, + search: url.search, + authorization: req.headers.authorization + }) + + if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { + sendJson(res, { + id: 'repo-guid', + webUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }) + return + } + + if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') { + expect(url.searchParams.get('searchCriteria.sourceRefName')).toBe( + 'refs/heads/feature/azure' + ) + sendJson(res, { + value: [ + { + pullRequestId: 31, + title: 'Azure branch', + status: 'active', + creationDate: '2026-05-16T00:00:00Z', + mergeStatus: 'succeeded', + lastMergeSourceCommit: { commitId: 'abc123' } + } + ] + }) + return + } + + if ( + url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/31/statuses' + ) { + sendJson(res, { value: [{ state: 'succeeded' }] }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const repoPath = await mkdtemp(join(tmpdir(), 'orca-azure-review-')) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP server address') + } + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = `http://127.0.0.1:${address.port}/acme/Project` + + await execFileAsync('git', ['init'], { cwd: repoPath }) + await execFileAsync( + 'git', + ['remote', 'add', 'origin', 'https://dev.azure.com/acme/Project/_git/repo'], + { cwd: repoPath } + ) + + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/azure' }) + ).resolves.toEqual({ + provider: 'azure-devops', + number: 31, + title: 'Azure branch', + state: 'open', + url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/31', + status: 'success', + updatedAt: '2026-05-16T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + + expect(seen.map((request) => request.pathname)).toEqual([ + '/acme/Project/_apis/git/repositories/repo', + '/acme/Project/_apis/git/repositories/repo-guid/pullRequests', + '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/31/statuses' + ]) + expect(seen.every((request) => request.authorization === 'Basic OmxvY2FsLXBhdA==')).toBe(true) + } finally { + await rm(repoPath, { recursive: true, force: true }) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) +}) diff --git a/src/main/source-control/hosted-review-bitbucket.integration.test.ts b/src/main/source-control/hosted-review-bitbucket.integration.test.ts new file mode 100644 index 00000000000..8c66adf0a6b --- /dev/null +++ b/src/main/source-control/hosted-review-bitbucket.integration.test.ts @@ -0,0 +1,121 @@ +import { execFile } from 'child_process' +import { mkdtemp, rm } from 'fs/promises' +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { tmpdir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { _resetBitbucketRepoRefCache } from '../bitbucket/repository-ref' +import { getHostedReviewForBranch } from './hosted-review' + +const execFileAsync = promisify(execFile) +const OLD_ENV = process.env + +type SeenRequest = { + pathname: string + search: string + authorization: string | undefined +} + +function sendJson(res: ServerResponse, body: unknown): void { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +describe('Bitbucket hosted review integration', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'local-token' } + delete process.env.ORCA_BITBUCKET_EMAIL + delete process.env.ORCA_BITBUCKET_API_TOKEN + _resetBitbucketRepoRefCache() + }) + + afterEach(() => { + process.env = OLD_ENV + _resetBitbucketRepoRefCache() + }) + + it('resolves a Bitbucket PR through real git remote parsing and HTTP API calls', async () => { + const seen: SeenRequest[] = [] + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + seen.push({ + pathname: url.pathname, + search: url.search, + authorization: req.headers.authorization + }) + + if (url.pathname === '/2.0/repositories/team/repo/pullrequests') { + sendJson(res, { + values: [ + { + id: 12, + title: 'Local Bitbucket branch', + state: 'OPEN', + updated_on: '2026-05-15T00:00:00Z', + links: { + html: { href: 'https://bitbucket.org/team/repo/pull-requests/12' } + }, + source: { + branch: { name: 'feature/bitbucket' }, + commit: { hash: 'abc123' } + } + } + ] + }) + return + } + + if (url.pathname === '/2.0/repositories/team/repo/commit/abc123/statuses/build') { + sendJson(res, { values: [{ state: 'SUCCESSFUL' }] }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const repoPath = await mkdtemp(join(tmpdir(), 'orca-bitbucket-review-')) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP server address') + } + + process.env.ORCA_BITBUCKET_API_BASE_URL = `http://127.0.0.1:${address.port}/2.0` + await execFileAsync('git', ['init'], { cwd: repoPath }) + await execFileAsync('git', ['remote', 'add', 'origin', 'git@bitbucket.org:team/repo.git'], { + cwd: repoPath + }) + + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/bitbucket' }) + ).resolves.toEqual({ + provider: 'bitbucket', + number: 12, + title: 'Local Bitbucket branch', + state: 'open', + url: 'https://bitbucket.org/team/repo/pull-requests/12', + status: 'success', + updatedAt: '2026-05-15T00:00:00Z', + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + + expect(seen.map((request) => request.pathname)).toEqual([ + '/2.0/repositories/team/repo/pullrequests', + '/2.0/repositories/team/repo/commit/abc123/statuses/build' + ]) + expect(seen.every((request) => request.authorization === 'Bearer local-token')).toBe(true) + const query = new URLSearchParams(seen[0].search) + expect(query.get('q')).toContain('source.branch.name = "feature/bitbucket"') + expect(query.getAll('state')).toEqual(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']) + } finally { + await rm(repoPath, { recursive: true, force: true }) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index ddaede683f5..2cdf3e46f6a 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -5,6 +5,7 @@ const { getRepoSlugMock, getProjectSlugMock, getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, getGiteaRepoSlugMock, getHostedReviewForBranchMock, ghExecFileAsyncMock, @@ -15,6 +16,7 @@ const { getRepoSlugMock: vi.fn(), getProjectSlugMock: vi.fn(), getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), getGiteaRepoSlugMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), @@ -35,6 +37,10 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock })) +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock +})) + vi.mock('../gitea/client', () => ({ getGiteaRepoSlug: getGiteaRepoSlugMock })) @@ -56,22 +62,36 @@ vi.mock('./hosted-review', () => ({ import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +function resetMocks(): void { + for (const mock of [ + createGitHubPullRequestMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock + ]) { + mock.mockReset() + } +} + +function mockGitHubProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + describe('createHostedReview', () => { beforeEach(() => { - createGitHubPullRequestMock.mockReset() - getRepoSlugMock.mockReset() - getProjectSlugMock.mockReset() - getBitbucketRepoSlugMock.mockReset() - getGiteaRepoSlugMock.mockReset() - getHostedReviewForBranchMock.mockReset() - ghExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockReset() - getUpstreamStatusMock.mockReset() + resetMocks() - getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) - getBitbucketRepoSlugMock.mockResolvedValue(null) - getGiteaRepoSlugMock.mockResolvedValue(null) + mockGitHubProvider() getHostedReviewForBranchMock.mockResolvedValue(null) ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) getUpstreamStatusMock.mockResolvedValue({ @@ -198,20 +218,9 @@ describe('createHostedReview', () => { describe('getHostedReviewCreationEligibility', () => { beforeEach(() => { - createGitHubPullRequestMock.mockReset() - getRepoSlugMock.mockReset() - getProjectSlugMock.mockReset() - getBitbucketRepoSlugMock.mockReset() - getGiteaRepoSlugMock.mockReset() - getHostedReviewForBranchMock.mockReset() - ghExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockReset() - getUpstreamStatusMock.mockReset() + resetMocks() - getProjectSlugMock.mockResolvedValue(null) - getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) - getBitbucketRepoSlugMock.mockResolvedValue(null) - getGiteaRepoSlugMock.mockResolvedValue(null) + mockGitHubProvider() getHostedReviewForBranchMock.mockResolvedValue(null) ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) @@ -313,8 +322,7 @@ describe('getHostedReviewCreationEligibility', () => { ).resolves.toMatchObject({ provider: 'gitlab', canCreate: false, - blockedReason: 'unsupported_provider', - nextAction: null + blockedReason: 'unsupported_provider' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 7b8b59bbcfe..34b37065754 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -12,6 +12,7 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' +import { getAzureDevOpsRepoSlug } from '../azure-devops/client' import { getBitbucketRepoSlug } from '../bitbucket/client' import { getGiteaRepoSlug } from '../gitea/client' import { createGitHubPullRequest, getRepoSlug } from '../github/client' @@ -44,6 +45,9 @@ async function detectHostedReviewProvider(repoPath: string): Promise ({ @@ -16,6 +18,8 @@ const { getPRForBranchMock: vi.fn(), getBitbucketRepoSlugMock: vi.fn(), getBitbucketPullRequestForBranchMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getAzureDevOpsPullRequestForBranchMock: vi.fn(), getGiteaRepoSlugMock: vi.fn(), getGiteaPullRequestForBranchMock: vi.fn() })) @@ -37,6 +41,12 @@ vi.mock('../bitbucket/client', () => ({ getBitbucketPullRequest: vi.fn() })) +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: getAzureDevOpsPullRequestForBranchMock, + getAzureDevOpsPullRequest: vi.fn() +})) + vi.mock('../gitea/client', () => ({ getGiteaRepoSlug: getGiteaRepoSlugMock, getGiteaPullRequestForBranch: getGiteaPullRequestForBranchMock, @@ -53,6 +63,8 @@ describe('getHostedReviewForBranch', () => { getPRForBranchMock.mockReset() getBitbucketRepoSlugMock.mockReset() getBitbucketPullRequestForBranchMock.mockReset() + getAzureDevOpsRepoSlugMock.mockReset() + getAzureDevOpsPullRequestForBranchMock.mockReset() getGiteaRepoSlugMock.mockReset() getGiteaPullRequestForBranchMock.mockReset() }) @@ -154,6 +166,7 @@ describe('getHostedReviewForBranch', () => { getProjectSlugMock.mockResolvedValue(null) getRepoSlugMock.mockResolvedValue(null) getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue({ host: 'git.example.com', owner: 'team', @@ -189,4 +202,50 @@ describe('getHostedReviewForBranch', () => { }) expect(getGiteaPullRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature/gitea', 14) }) + + it('falls through to Azure DevOps before Gitea when origin is an Azure Repos remote', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue({ + host: 'dev.azure.com', + organization: 'team', + project: 'Project', + repository: 'orca' + }) + getAzureDevOpsPullRequestForBranchMock.mockResolvedValue({ + number: 21, + title: 'Azure branch', + state: 'open', + url: 'https://dev.azure.com/team/Project/_git/orca/pullrequest/21', + status: 'success', + updatedAt: '2026-05-16T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + + await expect( + getHostedReviewForBranch({ + repoPath: '/repo', + branch: 'feature/azure', + linkedAzureDevOpsPR: 21 + }) + ).resolves.toEqual({ + provider: 'azure-devops', + number: 21, + title: 'Azure branch', + state: 'open', + url: 'https://dev.azure.com/team/Project/_git/orca/pullrequest/21', + status: 'success', + updatedAt: '2026-05-16T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'abc123' + }) + expect(getAzureDevOpsPullRequestForBranchMock).toHaveBeenCalledWith( + '/repo', + 'feature/azure', + 21 + ) + expect(getGiteaRepoSlugMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index 401f6b973a0..c76da7c4869 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -1,5 +1,11 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' import type { MRInfo, PRInfo } from '../../shared/types' +import { + getAzureDevOpsPullRequest, + getAzureDevOpsPullRequestForBranch, + getAzureDevOpsRepoSlug +} from '../azure-devops/client' +import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' import { getBitbucketPullRequest, getBitbucketPullRequestForBranch, @@ -66,6 +72,20 @@ function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { } } +function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { + return { + provider: 'azure-devops', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { return { provider: 'gitea', @@ -87,6 +107,7 @@ export async function getHostedReviewForBranch(input: { linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null }): Promise { const branchName = input.branch.replace(/^refs\/heads\//, '') @@ -95,6 +116,7 @@ export async function getHostedReviewForBranch(input: { input.linkedGitHubPR == null && input.linkedGitLabMR == null && input.linkedBitbucketPR == null && + input.linkedAzureDevOpsPR == null && input.linkedGiteaPR == null ) { return null @@ -132,6 +154,16 @@ export async function getHostedReviewForBranch(input: { return pr ? mapBitbucketReview(pr) : null } + const azureDevOpsRepo = await getAzureDevOpsRepoSlug(input.repoPath) + if (azureDevOpsRepo) { + const pr = await getAzureDevOpsPullRequestForBranch( + input.repoPath, + branchName, + input.linkedAzureDevOpsPR ?? null + ) + return pr ? mapAzureDevOpsReview(pr) : null + } + const giteaRepo = await getGiteaRepoSlug(input.repoPath) if (giteaRepo) { const pr = await getGiteaPullRequestForBranch( @@ -147,7 +179,7 @@ export async function getHostedReviewForBranch(input: { export async function getHostedReviewByNumber(input: { repoPath: string - provider: 'github' | 'gitlab' | 'bitbucket' | 'gitea' + provider: 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea' number: number }): Promise { if (input.provider === 'gitlab') { @@ -158,6 +190,10 @@ export async function getHostedReviewByNumber(input: { const pr = await getBitbucketPullRequest(input.repoPath, input.number) return pr ? mapBitbucketReview(pr) : null } + if (input.provider === 'azure-devops') { + const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number) + return pr ? mapAzureDevOpsReview(pr) : null + } if (input.provider === 'gitea') { const pr = await getGiteaPullRequest(input.repoPath, input.number) return pr ? mapGiteaReview(pr) : null diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 7e829052109..8a595128a7d 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -11,6 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 180_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2', + archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf', archiveFormat: 'tar.bz2', files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'], sampleRate: 16000, @@ -28,6 +29,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 170_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2', + archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad', archiveFormat: 'tar.bz2', files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'], sampleRate: 16000, @@ -43,6 +45,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 130_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2', + archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8', archiveFormat: 'tar.bz2', files: [ 'encoder-epoch-99-avg-1.onnx', @@ -64,6 +67,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 115_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2', + archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f', archiveFormat: 'tar.bz2', files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'tokens.txt'], sampleRate: 16000, @@ -78,6 +82,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 128_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2', + archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9', archiveFormat: 'tar.bz2', files: [ 'encoder-epoch-99-avg-1.onnx', @@ -98,6 +103,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 74_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2', + archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b', archiveFormat: 'tar.bz2', files: [ 'encoder-epoch-99-avg-1.onnx', @@ -118,6 +124,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sizeBytes: 116_000_000, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2', + archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1', archiveFormat: 'tar.bz2', files: ['tiny-encoder.onnx', 'tiny-decoder.onnx', 'tiny-tokens.txt'], sampleRate: 16000, diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts new file mode 100644 index 00000000000..6c25e9a5e50 --- /dev/null +++ b/src/main/speech/model-manager.test.ts @@ -0,0 +1,68 @@ +import { createHash } from 'crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, vi } from 'vitest' +import { SPEECH_MODEL_CATALOG } from './model-catalog' +import { ModelManager } from './model-manager' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-speech-models-test' + } +})) + +type ModelManagerInternals = { + verifyArchiveSha256: (archivePath: string, expectedSha256: string) => Promise + downloadFile: ( + url: string, + dest: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean + ) => Promise +} + +describe('ModelManager', () => { + it('requires pinned SHA-256 hashes for every catalog archive', () => { + for (const manifest of SPEECH_MODEL_CATALOG) { + expect(manifest.archiveSha256).toMatch(/^[a-f0-9]{64}$/) + } + }) + + it('verifies downloaded archive hashes before extraction', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const archivePath = join(dir, 'model.tar.bz2') + writeFileSync(archivePath, 'known archive bytes') + const expected = createHash('sha256').update('known archive bytes').digest('hex') + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + await expect(manager.verifyArchiveSha256(archivePath, expected)).resolves.toBeUndefined() + await expect(manager.verifyArchiveSha256(archivePath, '0'.repeat(64))).rejects.toThrow( + /integrity verification/ + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects non-HTTPS model downloads', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + await expect( + manager.downloadFile( + 'http://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + 1, + 'm', + () => false + ) + ).rejects.toThrow(/HTTPS/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 88979d42569..9c25c728bca 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -1,9 +1,11 @@ +/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */ import { app } from 'electron' import { join, resolve, relative } from 'path' -import { existsSync, mkdirSync, createWriteStream, rmSync } from 'fs' +import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs' import { readdir, rm } from 'fs/promises' +import { createHash } from 'crypto' import { get as httpsGet } from 'https' -import { get as httpGet, type IncomingMessage } from 'http' +import type { IncomingMessage } from 'http' import { pipeline } from 'stream/promises' import { spawn } from 'child_process' import type { @@ -12,6 +14,7 @@ import type { SpeechModelStatus } from '../../shared/speech-types' import { SPEECH_MODEL_CATALOG, getCatalogModel } from './model-catalog' +import { resolveTarExecutable } from './tar-executable' type DownloadHandle = { abort: () => void @@ -132,6 +135,13 @@ export class ModelManager { return } + await this.verifyArchiveSha256(archivePath, manifest.archiveSha256) + + if (aborted) { + this.cleanup(modelId, archivePath) + return + } + this.updateState(modelId, 'extracting') await this.extractArchive(archivePath, this.modelsDir, modelId, () => aborted) @@ -212,25 +222,71 @@ export class ModelManager { dest: string, expectedSize: number, modelId: string, - isAborted: () => boolean + isAborted: () => boolean, + redirectCount = 0 ): Promise { return new Promise((resolve, reject) => { - const getter = url.startsWith('https') ? httpsGet : httpGet + let parsedUrl: URL + try { + parsedUrl = new URL(url) + } catch { + reject(new Error('Invalid download URL')) + return + } - const request = getter(url, (response: IncomingMessage) => { - if (response.statusCode === 301 || response.statusCode === 302) { + if (parsedUrl.protocol !== 'https:') { + reject(new Error('Model downloads must use HTTPS')) + return + } + + const request = httpsGet(parsedUrl, (response: IncomingMessage) => { + if ( + response.statusCode === 301 || + response.statusCode === 302 || + response.statusCode === 303 || + response.statusCode === 307 || + response.statusCode === 308 + ) { const redirectUrl = response.headers.location if (!redirectUrl) { + response.resume() reject(new Error('Redirect without location')) return } - this.downloadFile(redirectUrl, dest, expectedSize, modelId, isAborted) + if (redirectCount >= 5) { + response.resume() + reject(new Error('Too many redirects')) + return + } + let resolvedRedirect: URL + try { + resolvedRedirect = new URL(redirectUrl, parsedUrl) + } catch { + response.resume() + reject(new Error('Invalid redirect URL')) + return + } + if (resolvedRedirect.protocol !== 'https:') { + response.resume() + reject(new Error('Model download redirect must use HTTPS')) + return + } + response.resume() + this.downloadFile( + resolvedRedirect.toString(), + dest, + expectedSize, + modelId, + isAborted, + redirectCount + 1 + ) .then(resolve) .catch(reject) return } if (response.statusCode !== 200) { + response.resume() reject(new Error(`HTTP ${response.statusCode}`)) return } @@ -266,6 +322,26 @@ export class ModelManager { }) } + private verifyArchiveSha256(archivePath: string, expectedSha256: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256') + const stream = createReadStream(archivePath) + + stream.on('data', (chunk) => hash.update(chunk)) + stream.on('error', reject) + stream.on('end', () => { + const actualSha256 = hash.digest('hex') + if (actualSha256 !== expectedSha256.toLowerCase()) { + // Why: these archives feed native model parsers; filename checks do + // not protect against compromised or redirected release assets. + reject(new Error('Downloaded model archive failed integrity verification')) + return + } + resolve() + }) + }) + } + private extractArchive( archivePath: string, destDir: string, @@ -280,9 +356,15 @@ export class ModelManager { // (1MB default maxBuffer). bzip2 decompression is slow (~1-5 min for // 170MB archives) and exec can silently kill the process if stderr // exceeds the buffer. spawn streams output without buffering. - const child = spawn('tar', ['-xjf', archivePath, '-C', modelDir, '--strip-components=1'], { - stdio: ['ignore', 'ignore', 'pipe'] - }) + const tarExecutable = resolveTarExecutable() + const child = spawn( + tarExecutable, + ['-xjf', archivePath, '-C', modelDir, '--strip-components=1'], + { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true + } + ) let stderr = '' child.stderr?.on('data', (chunk: Buffer) => { diff --git a/src/main/speech/tar-executable.ts b/src/main/speech/tar-executable.ts new file mode 100644 index 00000000000..c237f43b373 --- /dev/null +++ b/src/main/speech/tar-executable.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'fs' +import { win32 as pathWin32 } from 'path' + +export function resolveTarExecutable( + options: { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + exists?: (path: string) => boolean + } = {} +): string { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return 'tar' + } + + const env = options.env ?? process.env + const systemRoot = env.SystemRoot ?? env.WINDIR ?? 'C:\\Windows' + const candidate = pathWin32.join(systemRoot, 'System32', 'tar.exe') + const exists = options.exists ?? existsSync + if (exists(candidate)) { + return candidate + } + + // Why: packaged Windows apps can have a stripped PATH. Use the OS tar + // location explicitly, and fail with a repairable error if it is absent. + throw new Error(`Windows tar.exe not found at ${candidate}`) +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 52660c51955..e6d236bd26f 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -8,8 +8,9 @@ import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' -const { muxRequestMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn() +const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ + muxRequestMock: vi.fn(), + installRemoteManagedAgentHooksMock: vi.fn() })) vi.mock('./ssh-relay-deploy', () => ({ @@ -29,6 +30,10 @@ vi.mock('./ssh-channel-multiplexer', () => { } }) +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: installRemoteManagedAgentHooksMock +})) + vi.mock('../providers/ssh-pty-provider', () => ({ isSshPtyNotFoundError: (err: unknown) => (err instanceof Error ? err.message : String(err)).includes('not found'), @@ -127,6 +132,8 @@ describe('SshRelaySession', () => { delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS muxRequestMock.mockReset() muxRequestMock.mockResolvedValue([]) + installRemoteManagedAgentHooksMock.mockReset() + installRemoteManagedAgentHooksMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) }) @@ -151,9 +158,14 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('syncs relay-owned plugin assets before registering the SSH PTY provider', async () => { + it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - muxRequestMock.mockResolvedValue({ ok: true }) + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + return { ok: true } + }) const sftp = { end: vi.fn() } const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { @@ -167,14 +179,15 @@ describe('SshRelaySession', () => { ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD ) expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0) + expect(mockConn.sftp).toHaveBeenCalledTimes(1) + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(sftp.end).toHaveBeenCalledTimes(1) + expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan( + muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex] + ) expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan( vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0] ) - // Why: connecting to SSH may upload relay-owned plugin source, but must - // not mutate user-owned agent config files. Remote managed-hook install - // belongs behind an explicit per-host user action. - expect(mockConn.sftp).not.toHaveBeenCalled() - expect(sftp.end).not.toHaveBeenCalled() }) it('does not register providers if dispose wins during initial plugin sync', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 7a9f5d4fb2a..1c0b0b1c7e3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -17,6 +17,7 @@ import { SshPtyProvider, isSshPtyNotFoundError } from '../providers/ssh-pty-prov import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' +import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, @@ -432,6 +433,11 @@ export class SshRelaySession { return false } + await this.installManagedHooksOnRemote(mux) + if (shouldContinue && !shouldContinue()) { + return false + } + await this.installPluginsOnRelay(mux) if (shouldContinue && !shouldContinue()) { return false @@ -454,6 +460,52 @@ export class SshRelaySession { return true } + // Why: the relay can inject ORCA_AGENT_HOOK_* env into SSH PTYs, but + // hook-script agents (Claude/Codex/Gemini/etc.) still need their config + // files on the remote host to call Orca's managed script. Install those + // configs before registering the PTY provider so newly spawned agent panes + // report status from their first prompt. + private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { + if (!isRemoteAgentHooksEnabled()) { + return + } + + let remoteHome: string + try { + const result = (await mux.request('session.resolveHome', { path: '~' })) as { + resolvedPath?: unknown + } + if (typeof result.resolvedPath !== 'string' || result.resolvedPath.length === 0) { + console.warn( + `[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: could not resolve remote home` + ) + return + } + remoteHome = result.resolvedPath + } catch (error) { + console.warn( + `[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + return + } + + let sftp: Awaited> | null = null + try { + sftp = await this.requireReadyConnection().sftp() + await installRemoteManagedAgentHooks(sftp, remoteHome) + } catch (error) { + console.warn( + `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } finally { + ;(sftp as { end?: () => void } | null)?.end?.() + } + } + // Why: ship the OpenCode plugin / Pi extension source bodies to the relay // so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent diff --git a/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs b/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs index 90528b17916..6f4aca7bae5 100644 --- a/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs +++ b/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const grandchildPath = path.join(__dirname, 'electron-vite-dev-grandchild.mjs') const pidFile = process.env.ORCA_DEV_WRAPPER_TEST_PID_FILE +const envFile = process.env.ORCA_DEV_WRAPPER_TEST_ENV_FILE const grandchild = spawn(process.execPath, [grandchildPath], { stdio: 'ignore' @@ -16,4 +17,25 @@ if (!pidFile) { } writeFileSync(pidFile, `${grandchild.pid ?? ''}\n`, 'utf8') +if (envFile) { + writeFileSync( + envFile, + JSON.stringify( + { + args: process.argv.slice(2), + label: process.env.ORCA_DEV_INSTANCE_LABEL ?? null, + branch: process.env.ORCA_DEV_BRANCH ?? null, + worktreeName: process.env.ORCA_DEV_WORKTREE_NAME ?? null, + repoRoot: process.env.ORCA_DEV_REPO_ROOT ?? null, + badgeLabel: process.env.ORCA_DEV_DOCK_BADGE_LABEL ?? null, + dockTitle: process.env.ORCA_DEV_DOCK_TITLE ?? null, + stableName: process.env.ORCA_DEV_STABLE_NAME ?? null, + electronExecPath: process.env.ELECTRON_EXEC_PATH ?? null + }, + null, + 2 + ), + 'utf8' + ) +} setInterval(() => {}, 1000) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index a58e85dab7a..2552323e9eb 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,3 +1,4 @@ +import { homedir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -12,6 +13,7 @@ vi.mock('electron', () => { quit: vi.fn(), exit: vi.fn(), isPackaged: false, + disableHardwareAcceleration: vi.fn(), commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn(() => '') @@ -69,9 +71,9 @@ describe('patchPackagedProcessPath', () => { // fallback install locations for the opencode and Pi CLI install scripts. // Without them on PATH, GUI-launched Orca reports both as "Not installed" // even when `which` resolves them in the user's shell. - expect(segments).toContain('/Users/tester/.opencode/bin') - expect(segments).toContain('/Users/tester/.vite-plus/bin') - expect(segments).toContain('/Users/tester/bin') + expect(segments).toContain(join('/Users/tester', '.opencode/bin')) + expect(segments).toContain(join('/Users/tester', '.vite-plus/bin')) + expect(segments).toContain(join('/Users/tester', 'bin')) }) it('leaves PATH untouched when the app is not packaged', async () => { @@ -87,6 +89,23 @@ describe('patchPackagedProcessPath', () => { expect(process.env.PATH).toBe('/usr/bin:/bin') }) + + it('prepends Windows user-local CLI dirs for packaged Start Menu launches', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('win32') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + const pathDelimiter = process.platform === 'win32' ? ';' : ':' + process.env.PATH = `C:\\Windows\\System32${pathDelimiter}C:\\Windows` + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(pathDelimiter) + const userLocalBin = join(homedir(), '.local', 'bin') + expect(segments).toContain(userLocalBin) + expect(segments.indexOf(userLocalBin)).toBeLessThan(segments.indexOf('C:\\Windows\\System32')) + }) }) describe('configureDevUserDataPath', () => { @@ -247,10 +266,32 @@ describe('installDevParentWatchdog', () => { }) describe('enableMainProcessGpuFeatures', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR + + function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) + } + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + if (originalE2EUserDataDir === undefined) { + delete process.env.ORCA_E2E_USER_DATA_DIR + } else { + process.env.ORCA_E2E_USER_DATA_DIR = originalE2EUserDataDir + } + }) + it('appends VS Code-style GPU channel flags without unsafe WebGPU/Vulkan opt-ins', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') + delete process.env.ORCA_E2E_USER_DATA_DIR vi.mocked(app.commandLine.appendSwitch).mockClear() enableMainProcessGpuFeatures() @@ -261,10 +302,30 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) + it('disables the GPU process for Linux E2E runs', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + setPlatform('linux') + process.env.ORCA_E2E_USER_DATA_DIR = '/tmp/orca-e2e' + vi.mocked(app.disableHardwareAcceleration).mockClear() + vi.mocked(app.commandLine.appendSwitch).mockClear() + + enableMainProcessGpuFeatures() + + expect(app.disableHardwareAcceleration).toHaveBeenCalledTimes(1) + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith( + 'enable-features', + expect.any(String) + ) + }) + it('preserves existing enable-features switches', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') + delete process.env.ORCA_E2E_USER_DATA_DIR vi.mocked(app.commandLine.appendSwitch).mockClear() vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('ExistingFeature') enableMainProcessGpuFeatures() diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index bf64bc4a3cd..7a23599b3b6 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -5,6 +5,10 @@ import { getMainE2EConfig } from '../e2e-config' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 +function getProcessPathDelimiter(): string { + return process.platform === 'win32' ? ';' : ':' +} + function requestDevParentShutdown(): void { app.quit() @@ -36,35 +40,39 @@ export function installUncaughtPipeErrorGuard(): void { } export function patchPackagedProcessPath(): void { - if (!app.isPackaged || process.platform === 'win32') { + if (!app.isPackaged) { return } const home = process.env.HOME ?? '' - const extraPaths = [ - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/bin', - '/usr/local/sbin', - '/snap/bin', - '/home/linuxbrew/.linuxbrew/bin', - '/nix/var/nix/profiles/default/bin' - ] + const extraPaths: string[] = [] - if (home) { + if (process.platform !== 'win32') { extraPaths.push( - join(home, 'bin'), - join(home, '.local/bin'), - join(home, '.nix-profile/bin'), - // Why: several agent CLIs ship install scripts that drop binaries into - // tool-specific ~/./bin directories (opencode's documented fallback, - // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH - // without shell rc files, so these stay invisible to `which` probes — and - // the Agents settings page reports them as "Not installed" even when the - // user can run them from Terminal. See stablyai/orca#829. - join(home, '.opencode/bin'), - join(home, '.vite-plus/bin') + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + '/snap/bin', + '/home/linuxbrew/.linuxbrew/bin', + '/nix/var/nix/profiles/default/bin' ) + + if (home) { + extraPaths.push( + join(home, 'bin'), + join(home, '.local/bin'), + join(home, '.nix-profile/bin'), + // Why: several agent CLIs ship install scripts that drop binaries into + // tool-specific ~/./bin directories (opencode's documented fallback, + // Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH + // without shell rc files, so these stay invisible to `which` probes — and + // the Agents settings page reports them as "Not installed" even when the + // user can run them from Terminal. See stablyai/orca#829. + join(home, '.opencode/bin'), + join(home, '.vite-plus/bin') + ) + } } // Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm, @@ -72,14 +80,20 @@ export function patchPackagedProcessPath(): void { // resolveCodexCommand() can locate the codex binary in these directories, but // spawning it still fails if node itself isn't in PATH. Adding version manager // bin paths here fixes all spawn sites (login, rate limits, usage tracking). + // On Windows this also seeds user-local installer dirs, since shell hydration + // is POSIX-only and Start Menu launches can miss user-level PATH updates. extraPaths.push(...getVersionManagerBinPaths()) - const currentPath = process.env.PATH ?? '' - const existing = new Set(currentPath.split(':')) + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const currentPath = process.env[pathKey] ?? '' + const pathDelimiter = getProcessPathDelimiter() + const existing = new Set(currentPath.split(pathDelimiter)) const missing = extraPaths.filter((path) => !existing.has(path)) if (missing.length > 0) { - process.env.PATH = [...missing, ...currentPath.split(':').filter(Boolean)].join(':') + process.env[pathKey] = [...missing, ...currentPath.split(pathDelimiter).filter(Boolean)].join( + pathDelimiter + ) } } @@ -170,6 +184,16 @@ export function installDevParentWatchdog(isDev: boolean): void { } export function enableMainProcessGpuFeatures(): void { + if (process.platform === 'linux' && getMainE2EConfig().userDataDir) { + // Why: Ubuntu/Xvfb runners can fail Electron startup with + // "GPU process isn't usable" before Playwright sees the first window. + // E2E coverage does not depend on GPU compositing, so keep CI on the + // software path instead of retrying around a crashed app process. + app.disableHardwareAcceleration() + app.commandLine.appendSwitch('disable-gpu') + return + } + const existingFeatures = app.commandLine.getSwitchValue('enable-features') const features = [ // Why: mirror VS Code's conservative Electron GPU-channel startup flags diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts new file mode 100644 index 00000000000..2021dfed224 --- /dev/null +++ b/src/main/startup/dev-instance-identity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { getDevInstanceIdentity } from './dev-instance-identity' + +describe('dev-instance-identity', () => { + it('keeps packaged identity stable', () => { + expect(getDevInstanceIdentity(false, {})).toMatchObject({ + name: 'Orca', + isDev: false, + devLabel: null, + dockBadgeLabel: null, + appUserModelId: 'com.stablyai.orca' + }) + }) + + it('derives a readable dev label from worktree and branch env', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_REPO_ROOT: '/repo/worktrees/dev-indicator', + ORCA_DEV_WORKTREE_NAME: 'dev-indicator', + ORCA_DEV_BRANCH: 'nwparker/dev-indicator' + }) + + expect(identity).toMatchObject({ + isDev: true, + devLabel: 'dev-indicator', + devBranch: 'nwparker/dev-indicator', + devWorktreeName: 'dev-indicator', + devRepoRoot: '/repo/worktrees/dev-indicator' + }) + expect(identity.name).toBe('Orca: nwparker/dev-indicator') + expect(identity.dockBadgeLabel).toBeNull() + expect(identity.appUserModelId).toMatch(/^com\.stablyai\.orca\.dev\.[a-f0-9]{10}$/) + }) + + it('includes the branch when it differs from the worktree basename', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_REPO_ROOT: '/repo/worktrees/payment-ui', + ORCA_DEV_WORKTREE_NAME: 'payment-ui', + ORCA_DEV_BRANCH: 'feature/billing-shell' + }) + + expect(identity.devLabel).toBe('payment-ui @ feature/billing-shell') + expect(identity.name).toBe('Orca: feature/billing-shell') + expect(identity.dockBadgeLabel).toBeNull() + }) + + it('allows an explicit label override', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_INSTANCE_LABEL: 'manual label', + ORCA_DEV_WORKTREE_NAME: 'dev-indicator', + ORCA_DEV_BRANCH: 'feature/other' + }) + + expect(identity.devLabel).toBe('manual label') + expect(identity.name).toBe('Orca: feature/other') + expect(identity.dockBadgeLabel).toBeNull() + }) +}) diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts new file mode 100644 index 00000000000..17ce1ca648c --- /dev/null +++ b/src/main/startup/dev-instance-identity.ts @@ -0,0 +1,82 @@ +import { createHash } from 'crypto' +import path from 'path' +import type { AppIdentity } from '../../shared/app-identity' + +const BASE_APP_NAME = 'Orca' +const BASE_APP_USER_MODEL_ID = 'com.stablyai.orca' +const MAX_LABEL_LENGTH = 80 + +export type DevInstanceIdentity = AppIdentity & { + appUserModelId: string +} + +function cleanEnvValue(value: string | undefined): string | null { + const trimmed = value?.replace(/\s+/g, ' ').trim() + if (!trimmed) { + return null + } + return trimmed.length > MAX_LABEL_LENGTH + ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 3)}...` + : trimmed +} + +function lastPathSegment(value: string): string { + const normalized = value.replace(/\\/g, '/') + return normalized.split('/').filter(Boolean).at(-1) ?? value +} + +function formatLabel(branch: string | null, worktreeName: string | null): string | null { + if (branch && worktreeName) { + if (branch === worktreeName || lastPathSegment(branch) === worktreeName) { + return worktreeName + } + return `${worktreeName} @ ${branch}` + } + return branch ?? worktreeName +} + +function createDevAppUserModelId(identityKey: string | null): string { + if (!identityKey) { + return BASE_APP_USER_MODEL_ID + } + const hash = createHash('sha1').update(identityKey).digest('hex').slice(0, 10) + return `${BASE_APP_USER_MODEL_ID}.dev.${hash}` +} + +export function getDevInstanceIdentity( + isDev: boolean, + env: NodeJS.ProcessEnv = process.env +): DevInstanceIdentity { + if (!isDev) { + return { + name: BASE_APP_NAME, + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null, + appUserModelId: BASE_APP_USER_MODEL_ID + } + } + + const repoRoot = cleanEnvValue(env.ORCA_DEV_REPO_ROOT) + const branch = cleanEnvValue(env.ORCA_DEV_BRANCH) + const worktreeName = + cleanEnvValue(env.ORCA_DEV_WORKTREE_NAME) ?? + cleanEnvValue(path.basename(repoRoot ?? process.cwd())) + const devLabel = cleanEnvValue(env.ORCA_DEV_INSTANCE_LABEL) ?? formatLabel(branch, worktreeName) + const dockTitle = + cleanEnvValue(env.ORCA_DEV_DOCK_TITLE) ?? `${BASE_APP_NAME}: ${branch ?? devLabel ?? 'dev'}` + + return { + name: dockTitle, + isDev: true, + devLabel, + devBranch: branch, + devWorktreeName: worktreeName, + devRepoRoot: repoRoot, + dockBadgeLabel: null, + appUserModelId: createDevAppUserModelId(repoRoot ?? devLabel) + } +} diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index ab93eb30423..6faf7d8d967 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { startFirstWindowStartupServices } from './first-window-startup-services' +import { + FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS, + startFirstWindowStartupServices +} from './first-window-startup-services' describe('startFirstWindowStartupServices', () => { it('starts daemon and hook services concurrently before awaiting either', async () => { @@ -55,4 +58,27 @@ describe('startFirstWindowStartupServices', () => { expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error)) }) + + it('fails open when a pre-window startup service hangs', async () => { + vi.useFakeTimers() + const onDaemonError = vi.fn() + const onAgentHookServerError = vi.fn() + + try { + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => new Promise(() => {}), + startAgentHookServer: () => Promise.resolve(), + onDaemonError, + onAgentHookServerError + }) + + await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + await expect(started).resolves.toBeUndefined() + + expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) + expect(onAgentHookServerError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 9a6de0783d9..0f7f1ed236f 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -5,6 +5,33 @@ type FirstWindowStartupServices = { onAgentHookServerError: (error: unknown) => void } +export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000 + +async function startServiceWithTimeout( + label: string, + start: () => Promise, + onError: (error: unknown) => void +): Promise { + let timeout: ReturnType | null = null + try { + const startPromise = start() + await Promise.race([ + startPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} startup timed out`)) + }, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + }) + ]) + } catch (error) { + onError(error) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + /** * Starts the services that must be ready before restored terminal panes mount. */ @@ -16,8 +43,10 @@ export async function startFirstWindowStartupServices({ }: FirstWindowStartupServices): Promise { // Why: daemon startup and hook-server binding are independent, but both gate // restored terminals; run them together so cold-start latency is max(), not sum(). + // They are also fail-open services: a wedged daemon/hook startup must not + // prevent the first BrowserWindow from existing. await Promise.all([ - startDaemonPtyProvider().catch(onDaemonError), - startAgentHookServer().catch(onAgentHookServerError) + startServiceWithTimeout('daemon PTY provider', startDaemonPtyProvider, onDaemonError), + startServiceWithTimeout('agent hook server', startAgentHookServer, onAgentHookServerError) ]) } diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index e56e8c79c18..0ad80107960 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -64,6 +64,7 @@ describe('run-electron-vite-dev', () => { ...process.env, ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile }, stdio: 'ignore' @@ -100,4 +101,122 @@ describe('run-electron-vite-dev', () => { processesToCleanUp.delete(wrapper.pid!) } ) + + it('forwards dev instance identity to electron-vite', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9444'], { + cwd: resolve('.'), + env: { + ...process.env, + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, + ORCA_DEV_BRANCH: 'feature/billing-shell', + ORCA_DEV_WORKTREE_NAME: 'payment-ui', + ORCA_DEV_DOCK_BADGE_LABEL: undefined, + ORCA_DEV_DOCK_TITLE: undefined + }, + stdio: 'ignore' + }) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const grandchildPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10) + if (Number.isFinite(grandchildPid)) { + processesToCleanUp.add(grandchildPid) + } + + const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as { + args: string[] + label: string + branch: string + worktreeName: string + repoRoot: string + badgeLabel: string | null + dockTitle: string + stableName: string | null + electronExecPath: string | null + } + expect(envSnapshot.args).toContain('--remote-debugging-port=9444') + expect(envSnapshot.label).toBe('payment-ui @ feature/billing-shell') + expect(envSnapshot.branch).toBe('feature/billing-shell') + expect(envSnapshot.worktreeName).toBe('payment-ui') + expect(envSnapshot.repoRoot).toBe(resolve('.')) + expect(envSnapshot.badgeLabel).toBeNull() + expect(envSnapshot.dockTitle).toBe('Orca: feature/billing-shell') + expect(envSnapshot.stableName).toBeNull() + expect(envSnapshot.electronExecPath).toBeNull() + + wrapper.kill('SIGINT') + }) + + it('consumes the stable-name flag before forwarding args to electron-vite', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn( + process.execPath, + [wrapperPath, '--stable-name', '--remote-debugging-port=9445'], + { + cwd: resolve('.'), + env: { + ...process.env, + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, + ORCA_DEV_BRANCH: 'feature/stable-name', + ORCA_DEV_WORKTREE_NAME: 'stable-ui' + }, + stdio: 'ignore' + } + ) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const grandchildPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10) + if (Number.isFinite(grandchildPid)) { + processesToCleanUp.add(grandchildPid) + } + + const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as { + args: string[] + stableName: string | null + electronExecPath: string | null + } + expect(envSnapshot.args).not.toContain('--stable-name') + expect(envSnapshot.args).toContain('--remote-debugging-port=9445') + expect(envSnapshot.stableName).toBe('1') + expect(envSnapshot.electronExecPath).toBeNull() + + wrapper.kill('SIGINT') + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts new file mode 100644 index 00000000000..f4ba624b72f --- /dev/null +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -0,0 +1,52 @@ +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' + +export type CommitMessageAgentEnvironmentResolvers = { + prepareForCodexLaunch?: () => string | null + prepareForClaudeLaunch?: () => Promise +} + +function cloneProcessEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + return env +} + +export async function prepareLocalCommitMessageAgentEnv( + agentId: string, + resolvers: CommitMessageAgentEnvironmentResolvers | undefined +): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { + if (!resolvers) { + return { ok: true } + } + + try { + if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { + const codexHomePath = resolvers.prepareForCodexLaunch() + return { + ok: true, + env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined + } + } + + if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { + const preparation = await resolvers.prepareForClaudeLaunch() + const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { + stripAuthEnv: preparation.stripAuthEnv + }) + return { ok: true, env } + } + } catch (error) { + console.error('[commit-message] Failed to prepare agent environment:', error) + return { + ok: false, + error: 'Failed to prepare the selected agent account for commit message generation.' + } + } + + return { ok: true } +} diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 77665b8991b..c18f91bad87 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -6,9 +6,12 @@ import type * as ChildProcess from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { - applyOrcaAttribution, + cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, - resolveCommitMessageSettings + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + trimGeneratedCommitMessage } from './commit-message-text-generation' vi.mock('child_process', async (importOriginal) => { @@ -54,10 +57,25 @@ describe('resolveCommitMessageSettings', () => { ok: true, params: { agentId: 'codex', - model: 'gpt-5.4-mini', + model: 'gpt-5.5', thinkingLevel: 'low', - customPrompt: 'Use Conventional Commits.', - attributionEnabled: true + customPrompt: 'Use Conventional Commits.' + } + }) + }) + + it("uses the user's default agent when the AI setting has no explicit agent", () => { + const settings = getDefaultSettings('/tmp') + settings.defaultTuiAgent = 'codex' + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'codex', + model: 'gpt-5.5', + thinkingLevel: 'low' } }) }) @@ -67,8 +85,8 @@ describe('resolveCommitMessageSettings', () => { settings.commitMessageAi = { enabled: true, agentId: 'codex', - selectedModelByAgent: { codex: 'gpt-5.5' }, - selectedThinkingByModel: { 'gpt-5.5': 'turbo' }, + selectedModelByAgent: { codex: 'gpt-5.4-mini' }, + selectedThinkingByModel: { 'gpt-5.4-mini': 'turbo' }, customPrompt: '', customAgentCommand: '' } @@ -79,7 +97,7 @@ describe('resolveCommitMessageSettings', () => { ok: true, params: { agentId: 'codex', - model: 'gpt-5.5', + model: 'gpt-5.4-mini', thinkingLevel: 'low' } }) @@ -239,8 +257,7 @@ describe('generateCommitMessageFromContext', () => { { agentId: 'custom', model: '', - customAgentCommand: 'agent', - attributionEnabled: true + customAgentCommand: 'agent' }, { kind: 'remote', @@ -257,8 +274,7 @@ describe('generateCommitMessageFromContext', () => { expect(result).toEqual({ success: true, - message: - 'Update README\n\n- Explain the generated commit-message flow\n\nCo-authored-by: Orca ', + message: 'Update README\n\n- Explain the generated commit-message flow', agentLabel: 'agent' }) }) @@ -377,6 +393,94 @@ describe('generateCommitMessageFromContext', () => { ) }) + it('keeps local commit-message and pull-request cancellation lanes separate', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + + const commit = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const pullRequest = generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + cancelGenerateCommitMessageLocal('/repo') + + expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL') + expect(children[1]?.kill).not.toHaveBeenCalled() + + children[0]?.listeners.get('close')?.(null) + const pullRequestStdout = children[1]?.listeners.get('stdout:data') + pullRequestStdout?.( + Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') + ) + children[1]?.listeners.get('close')?.(0) + + await expect(commit).resolves.toEqual({ + success: false, + error: 'Generation canceled.', + canceled: true + }) + await expect(pullRequest).resolves.toMatchObject({ + success: true, + fields: { + base: 'main', + title: 'Update README', + body: 'Details', + draft: false + } + }) + + cancelGeneratePullRequestFieldsLocal('/repo') + expect(children[1]?.kill).not.toHaveBeenCalled() + }) + it('routes Windows batch-script agent commands through cmd.exe', async () => { const originalComSpec = process.env.ComSpec process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' @@ -419,7 +523,7 @@ describe('generateCommitMessageFromContext', () => { }) expect(spawnMock).toHaveBeenCalledWith( 'C:\\Windows\\System32\\cmd.exe', - ['/d', '/s', '/c', '"C:/tools/agent.cmd"'], + ['/d', '/c', 'C:/tools/agent.cmd'], expect.objectContaining({ cwd: 'C:\\repo', windowsHide: true @@ -464,10 +568,10 @@ describe('generateCommitMessageFromContext', () => { }) }) -describe('applyOrcaAttribution', () => { - it('does not duplicate the Orca trailer', () => { - const message = applyOrcaAttribution('Update docs', true) +describe('trimGeneratedCommitMessage', () => { + it('removes trailing whitespace from generated messages', () => { + const message = trimGeneratedCommitMessage('Update docs\n\n') - expect(applyOrcaAttribution(message, true)).toBe(message) + expect(message).toBe('Update docs') }) }) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index e056270f8ca..09949e28181 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -9,22 +9,27 @@ import { type CommitMessageDraftContext, type GeneratedCommitMessage } from '../../shared/commit-message-generation' +import { + buildPullRequestFieldsPrompt, + parseGeneratedPullRequestFields, + type GeneratedPullRequestFields, + type PullRequestDraftContext +} from '../../shared/pull-request-generation' import { cleanGeneratedCommitMessage, extractAgentErrorMessage } from '../../shared/commit-message-prompt' import { CUSTOM_AGENT_ID, - DEFAULT_COMMIT_MESSAGE_AGENT_ID, getCommitMessageAgentSpec, getCommitMessageModel, - isCustomAgentId + isCustomAgentId, + resolveCommitMessageAgentChoice } from '../../shared/commit-message-agent-spec' import { planCommitMessageGeneration, type CommitMessagePlan } from '../../shared/commit-message-plan' -import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' import { resolveCliCommand } from '../codex-cli/command' import { getSpawnArgsForWindows, @@ -42,14 +47,16 @@ export type GenerateCommitMessageParams = { customPrompt?: string customAgentCommand?: string agentCommandOverride?: string - /** When true, append `Co-authored-by: Orca ...` after the cleaned message. */ - attributionEnabled?: boolean } export type GenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type GeneratePullRequestFieldsResult = + | { success: true; fields: GeneratedPullRequestFields; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + export type RemoteCommitMessageExecResult = { stdout: string stderr: string @@ -76,37 +83,33 @@ type ResolveCommitMessageSettingsResult = | { ok: true; params: GenerateCommitMessageParams } | { ok: false; error: string } -type InternalCommitMessageGenerationResult = - | { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string } +type InternalTextGenerationResult = + | { success: true; rawOutput: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } -/** Appends the Orca trailer if the message does not already include it. */ -export function applyOrcaAttribution(message: string, enabled: boolean): string { - if (!enabled) { - // Why: trim trailing whitespace even on the no-attribution path so a - // stray "\n" from the agent's output never reaches the textarea as a - // visible blank line. - return message.replace(/\s+$/, '') - } - const stripped = message.replace(/\s+$/, '') - if (stripped.includes(ORCA_GIT_COMMIT_TRAILER)) { - return stripped - } - // Why: a blank line separates the trailer block from the body so `git - // interpret-trailers` and most parsers treat it as a real trailer instead - // of a paragraph continuation. - return `${stripped}\n\n${ORCA_GIT_COMMIT_TRAILER}` +export function trimGeneratedCommitMessage(message: string): string { + return message.replace(/\s+$/, '') } export function resolveCommitMessageSettings( settings: GlobalSettings ): ResolveCommitMessageSettingsResult { const config = settings.commitMessageAi - if (!config?.enabled || !config.agentId) { - return { ok: false, error: 'Enable AI commit messages and choose an agent in Settings -> Git.' } + if (!config?.enabled) { + return { ok: false, error: 'Enable AI commit messages in Settings -> Git.' } } - if (isCustomAgentId(config.agentId)) { + const agentChoice = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) + if (!agentChoice) { + return { + ok: false, + error: + `Default agent "${settings.defaultTuiAgent}" does not support AI commit messages. ` + + 'Choose Claude or Codex in Settings -> Git -> AI Commit Messages.' + } + } + + if (isCustomAgentId(agentChoice)) { const customAgentCommand = config.customAgentCommand.trim() if (!customAgentCommand) { return { @@ -120,13 +123,12 @@ export function resolveCommitMessageSettings( agentId: CUSTOM_AGENT_ID, model: '', customPrompt: config.customPrompt, - customAgentCommand, - attributionEnabled: settings.enableGitHubAttribution === true + customAgentCommand } } } - const agentId = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID + const agentId = agentChoice const spec = getCommitMessageAgentSpec(agentId) if (!spec) { return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` } @@ -153,8 +155,7 @@ export function resolveCommitMessageSettings( model: model.id, thinkingLevel, customPrompt: config.customPrompt, - ...(agentCommandOverride ? { agentCommandOverride } : {}), - attributionEnabled: settings.enableGitHubAttribution === true + ...(agentCommandOverride ? { agentCommandOverride } : {}) } } } @@ -199,23 +200,27 @@ function killProcessTree(child: ChildProcess): void { } } -// Keying by `local:${cwd}` keeps local cancellation independent from any SSH -// worktree with the same remote path. +type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' + +// Keying by operation plus `local:${cwd}` keeps local cancellation independent +// from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -function localLaneKey(cwd: string): string { - return `local:${cwd}` +function localLaneKey(operation: LocalGenerationOperation, cwd: string): string { + return `${operation}:local:${cwd}` } export function cancelGenerateCommitMessageLocal(cwd: string): void { - cancelTokensByLane.get(localLaneKey(cwd))?.() + cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() } async function runLocalPlan( plan: CommitMessagePlan, cwd: string, - env: NodeJS.ProcessEnv | undefined -): Promise { + env: NodeJS.ProcessEnv | undefined, + emptyResultName = 'message', + operation: LocalGenerationOperation = 'commit-message' +): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { let child: ChildProcess @@ -255,20 +260,24 @@ async function runLocalPlan( let outputLimitExceeded = false let settled = false let canceledByUser = false - const laneKey = localLaneKey(cwd) - const finalize = (result: InternalCommitMessageGenerationResult): void => { + const laneKey = localLaneKey(operation, cwd) + let cancelToken: (() => void) | null = null + const finalize = (result: InternalTextGenerationResult): void => { if (settled) { return } settled = true - cancelTokensByLane.delete(laneKey) + if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { + cancelTokensByLane.delete(laneKey) + } resolve(result) } - cancelTokensByLane.set(laneKey, () => { + cancelToken = () => { canceledByUser = true killProcessTree(child) - }) + } + cancelTokensByLane.set(laneKey, cancelToken) const timer = setTimeout(() => { killProcessTree(child) @@ -322,7 +331,7 @@ async function runLocalPlan( finalize({ success: false, error: userFacingAgentFailure(label) }) return } - finalizeFromAgentOutput({ code, stdout, stderr, label, finalize }) + finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize }) }) child.stdin?.end(stdinPayload ?? undefined) @@ -334,9 +343,10 @@ function finalizeFromAgentOutput(args: { stdout: string stderr: string label: string - finalize: (result: InternalCommitMessageGenerationResult) => void + emptyResultName: string + finalize: (result: InternalTextGenerationResult) => void }): void { - const { code, stdout, stderr, label, finalize } = args + const { code, stdout, stderr, label, emptyResultName, finalize } = args if (code !== 0) { const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) console.error('[commit-message] Generator failed:', { @@ -351,21 +361,21 @@ function finalizeFromAgentOutput(args: { } const cleaned = cleanGeneratedCommitMessage(stdout) if (!cleaned) { - finalize({ success: false, error: `${label} returned an empty message.` }) + finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` }) return } - const commitMessage = splitGeneratedCommitMessage(cleaned) finalize({ success: true, - commitMessage, + rawOutput: cleaned, agentLabel: label }) } async function runRemotePlan( plan: CommitMessagePlan, - target: Extract -): Promise { + target: Extract, + emptyResultName = 'message' +): Promise { const { binary, label } = plan let result: RemoteCommitMessageExecResult try { @@ -412,21 +422,27 @@ async function runRemotePlan( stdout: result.stdout, stderr: result.stderr, label, + emptyResultName, finalize: resolve }) }) } function formatCommitMessageGenerationResult( - result: InternalCommitMessageGenerationResult, - attributionEnabled: boolean + result: InternalTextGenerationResult ): GenerateCommitMessageResult { if (!result.success) { return result } + let commitMessage: GeneratedCommitMessage + try { + commitMessage = splitGeneratedCommitMessage(result.rawOutput) + } catch { + return { success: false, error: 'Generated commit message could not be parsed.' } + } return { success: true, - message: applyOrcaAttribution(result.commitMessage.message, attributionEnabled), + message: trimGeneratedCommitMessage(commitMessage.message), agentLabel: result.agentLabel } } @@ -444,7 +460,47 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' + ? await runRemotePlan(planned.plan, target, 'details') + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details') + return formatCommitMessageGenerationResult(internalResult) +} + +export function cancelGeneratePullRequestFieldsLocal(cwd: string): void { + cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.() +} + +function formatPullRequestFieldsGenerationResult( + result: InternalTextGenerationResult, + context: PullRequestDraftContext +): GeneratePullRequestFieldsResult { + if (!result.success) { + return result + } + try { + return { + success: true, + fields: parseGeneratedPullRequestFields(result.rawOutput, context), + agentLabel: result.agentLabel + } + } catch { + return { success: false, error: 'Generated pull request details could not be parsed.' } + } +} + +export async function generatePullRequestFieldsFromContext( + context: PullRequestDraftContext, + params: GenerateCommitMessageParams, + target: CommitMessageGenerationTarget +): Promise { + const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') + const planned = planCommitMessageGeneration(params, prompt) + if (!planned.ok) { + return { success: false, error: planned.error } + } + + const internalResult = + target.kind === 'remote' ? await runRemotePlan(planned.plan, target) - : await runLocalPlan(planned.plan, target.cwd, target.env) - return formatCommitMessageGenerationResult(internalResult, params.attributionEnabled === true) + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') + return formatPullRequestFieldsGenerationResult(internalResult, context) } diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts new file mode 100644 index 00000000000..12aa93846bb --- /dev/null +++ b/src/main/text-generation/pull-request-context.ts @@ -0,0 +1,64 @@ +import type { PullRequestDraftContext } from '../../shared/pull-request-generation' + +const MAX_PULL_REQUEST_CONTEXT_BYTES = 10 * 1024 * 1024 + +type GitExec = ( + args: string[], + options?: { maxBuffer?: number } +) => Promise<{ stdout: string; stderr?: string }> + +export type PullRequestContextInput = { + base: string + currentTitle: string + currentBody: string + currentDraft: boolean +} + +async function safeExec(execGit: GitExec, args: string[]): Promise { + try { + const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES }) + return stdout.trim() + } catch { + return '' + } +} + +export async function getPullRequestDraftContext( + execGit: GitExec, + input: PullRequestContextInput +): Promise { + const base = input.base.trim() + if (!base || base.startsWith('-')) { + return null + } + + const [branch, mergeBase] = await Promise.all([ + safeExec(execGit, ['branch', '--show-current']), + safeExec(execGit, ['merge-base', base, 'HEAD']) + ]) + if (!mergeBase) { + return null + } + + const range = `${mergeBase}..HEAD` + const [commitSummary, changeSummary, patch] = await Promise.all([ + safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]), + safeExec(execGit, ['diff', '--name-status', range]), + safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range]) + ]) + + if (!commitSummary && !changeSummary && !patch) { + return null + } + + return { + branch: branch || null, + base, + currentTitle: input.currentTitle, + currentBody: input.currentBody, + currentDraft: input.currentDraft, + commitSummary, + changeSummary, + patch + } +} diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index e30ad095edf..4b52fdbb7e2 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -1,5 +1,4 @@ import { app, autoUpdater as nativeUpdater } from 'electron' -import { autoUpdater } from 'electron-updater' import type { UpdateStatus } from '../shared/types' import { consumeMacInstallGuardBypass, @@ -11,11 +10,13 @@ import { } from './updater-mac-install' import { compareVersions } from './updater-fallback' import { fetchChangelog } from './updater-changelog' +import type { ElectronAutoUpdater } from './electron-updater-loader' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 type UpdaterHandlerContext = { + autoUpdater: ElectronAutoUpdater clearBackgroundCheckLaunchPending: () => void clearAvailableUpdateContext: () => void consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null @@ -45,6 +46,7 @@ type UpdaterHandlerContext = { } export function registerAutoUpdaterHandlers({ + autoUpdater, clearBackgroundCheckLaunchPending, clearAvailableUpdateContext, consumeMissingManifestPrereleaseFallbackResult, diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 53871a2e730..2ecf0c855be 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -79,6 +79,10 @@ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./electron-updater-loader', () => ({ + loadElectronAutoUpdater: () => autoUpdaterMock +})) + vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index 249332a03d3..c4baf8cecdb 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -96,6 +96,10 @@ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./electron-updater-loader', () => ({ + loadElectronAutoUpdater: () => autoUpdaterMock +})) + vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 729d04e88c9..4d91f760097 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -48,6 +48,7 @@ const { autoUpdaterMock.downloadUpdate.mockReset() autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockClear() + autoUpdaterMock.updateConfigPath = undefined autoUpdaterMock.allowPrerelease = false delete (autoUpdaterMock as Record).verifyUpdateCodeSignature } @@ -61,6 +62,7 @@ const { downloadUpdate: vi.fn(), quitAndInstall: vi.fn(), setFeedURL: vi.fn(), + updateConfigPath: undefined as string | undefined, emit, reset } @@ -98,6 +100,10 @@ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./electron-updater-loader', () => ({ + loadElectronAutoUpdater: () => autoUpdaterMock +})) + vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) @@ -151,6 +157,22 @@ describe('updater', () => { vi.useRealTimers() }) + it('does not load or configure electron-updater during dev setup', async () => { + isMock.dev = true + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + + // Why: E2E launches use dev mode and Electron's direct script runner, whose + // default app version makes electron-updater throw during module load. + expect(autoUpdaterMock.updateConfigPath).toBeUndefined() + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(powerMonitorOnMock).not.toHaveBeenCalled() + }) + it('deduplicates identical check errors from the event and rejected promise', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { autoUpdaterMock.emit('checking-for-update') diff --git a/src/main/updater.ts b/src/main/updater.ts index 3f00cb3ffbb..d7f4f1e3818 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,10 @@ /* eslint-disable max-lines */ -import path from 'node:path' import { app, BrowserWindow, powerMonitor } from 'electron' -import { autoUpdater } from 'electron-updater' import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' import type { UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' +import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -81,6 +80,14 @@ let downloadInFlight = false /** Guards against the macOS `activate` handler re-opening the old version * while Squirrel's ShipIt is replacing the .app bundle. */ let quittingForUpdate = false +let autoUpdater: ElectronAutoUpdater | null = null + +function getAutoUpdater(): ElectronAutoUpdater { + if (!autoUpdater) { + autoUpdater = loadElectronAutoUpdater() + } + return autoUpdater +} function clearAvailableUpdateContext(): void { availableVersion = null @@ -226,7 +233,7 @@ function performQuitAndInstall(): void { win.removeAllListeners('close') } - autoUpdater.quitAndInstall(false, true) + getAutoUpdater().quitAndInstall(false, true) } async function sendCheckFailureStatus( @@ -419,6 +426,7 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v } async function pinDefaultReleaseFeed(): Promise { + const autoUpdater = getAutoUpdater() // Why: the /releases/latest/download/ redirect can move between the update // check and the later manual download click. Pinning to the concrete tag // keeps the manifest and ZIP asset on the same release. @@ -497,6 +505,7 @@ function retryPrereleaseFallbackAfterMissingManifest( console.info( `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` ) + const autoUpdater = getAutoUpdater() autoUpdater.setFeedURL({ provider: 'generic', url }) userInitiatedCheck = Boolean(userInitiated) backgroundCheckLaunchPending = !userInitiated @@ -537,6 +546,7 @@ function runBackgroundUpdateCheck( backgroundCheckLaunchPending = true // Don't send 'checking' here — the 'checking-for-update' event handler does it, // and sending it from both places causes duplicate notifications (issue #35). + const autoUpdater = getAutoUpdater() const launch = (): Promise => autoUpdater.checkForUpdates() const run = pinDefaultReleaseFeed().then(launch) void Promise.resolve(run).catch((err) => { @@ -557,7 +567,7 @@ function enableIncludePrerelease(): void { // accept a prerelease manifest for users who intentionally Shift-clicked. // We keep using the manifest-probed generic feed instead of the native // GitHub provider because cancelled RC releases can appear without assets. - autoUpdater.allowPrerelease = true + getAutoUpdater().allowPrerelease = true includePrereleaseActive = true } @@ -581,6 +591,7 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean // Don't send 'checking' here — the 'checking-for-update' event handler does it, // and sending it from both places causes duplicate notifications (issue #35). + const autoUpdater = getAutoUpdater() const launch = (): Promise => autoUpdater.checkForUpdates() const run = pinDefaultReleaseFeed().then(launch) void Promise.resolve(run).catch((err) => { @@ -708,14 +719,10 @@ export function setupAutoUpdater( return } if (is.dev) { - // Why: dev-app-update.yml lives at config/dev-app-update.yml (not repo root) - // so the root directory stays short. electron-updater only reads it when - // the dev-mode early-return below is temporarily disabled to exercise the - // update flow locally — point it at the new path up-front so that works. - autoUpdater.updateConfigPath = path.join(app.getAppPath(), 'config', 'dev-app-update.yml') return } + const autoUpdater = getAutoUpdater() autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true @@ -769,6 +776,7 @@ export function setupAutoUpdater( autoUpdaterInitialized = true registerAutoUpdaterHandlers({ + autoUpdater, clearAvailableUpdateContext, consumeMissingManifestPrereleaseFallbackResult, getMissingManifestPrereleaseFallbackUserInitiated, @@ -849,8 +857,10 @@ export function downloadUpdate(): void { } downloadInFlight = true beginMacUpdateDownload() - autoUpdater.downloadUpdate().catch((err) => { - downloadInFlight = false - sendErrorStatus(String(err?.message ?? err)) - }) + getAutoUpdater() + .downloadUpdate() + .catch((err) => { + downloadInFlight = false + sendErrorStatus(String(err?.message ?? err)) + }) } diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index cf4efb4cd52..f80fbe687eb 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -44,9 +44,9 @@ describe('getSpawnArgsForWindows', () => { '--foo' ]) expect(spawnCmd).toBe('C:\\Windows\\System32\\cmd.exe') - // Why: /d disables AutoRun; /s preserves quoted command-line parsing; - // /c runs the quoted batch command and exits. - expect(spawnArgs).toEqual(['/d', '/s', '/c', '"C:\\tools\\codex.cmd" "login" "--foo"']) + // Why: /d disables AutoRun; /c runs the batch command and exits. + // Separate argv entries avoid cmd.exe seeing Node-escaped quotes. + expect(spawnArgs).toEqual(['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--foo']) }) } finally { if (originalComSpec === undefined) { @@ -80,6 +80,14 @@ describe('getSpawnArgsForWindows', () => { ) }) }) + + it('rejects unsafe command paths for .cmd scripts on win32', () => { + withPlatform('win32', () => { + expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + }) + }) }) describe('isPermissionError', () => { diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index edddf2eefd5..7f577e55f2f 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -35,13 +35,6 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean { return /[&|<>^"%!\r\n]/.test(value) } -function quoteWindowsBatchToken(value: string): string { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new UnsafeWindowsBatchArgumentsError() - } - return `"${value}"` -} - /** Check whether an error is a Windows permission error (EACCES or EPERM). */ export function isPermissionError(error: unknown): boolean { return ( @@ -132,8 +125,16 @@ export function getSpawnArgsForWindows( args: string[] ): { spawnCmd: string; spawnArgs: string[] } { if (isWindowsBatchScript(command)) { - const commandLine = [command, ...args].map(quoteWindowsBatchToken).join(' ') - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } + for (const value of [command, ...args]) { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new UnsafeWindowsBatchArgumentsError() + } + } + + // Why: when Node passes a pre-quoted command line as one argv entry, + // cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd + // shims. Separate argv entries let Node quote spaces without breaking cmd. + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } } return { spawnCmd: command, spawnArgs: args } } diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1a11ddd2f69..24c9afc1839 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -61,6 +61,7 @@ vi.mock('../ipc/worktrees', () => ({ })) vi.mock('../ipc/pty', () => ({ + getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock })) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 6bc943dec84..db2c5bd0ffc 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,16 +1,15 @@ /* eslint-disable max-lines -- Why: this file is the central main-window IPC wiring point; splitting it during the mobile release compatibility rebase would increase release risk. */ -import fs from 'node:fs/promises' -import path from 'node:path' import { randomUUID } from 'node:crypto' -import { app, clipboard, ipcMain, nativeImage, session } from 'electron' +import { app, ipcMain, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' -import { registerPtyHandlers } from '../ipc/pty' +import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -44,6 +43,7 @@ export function attachMainWindowServices( ): void { registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime) + registerWorkspaceCleanupHandlers(store, { runtime, getLocalPtyProvider }) registerPtyHandlers( mainWindow, runtime, @@ -343,47 +343,6 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void { ) } -export function registerClipboardHandlers(): void { - ipcMain.removeHandler('clipboard:readText') - ipcMain.removeHandler('clipboard:writeText') - ipcMain.removeHandler('clipboard:writeImage') - ipcMain.removeHandler('clipboard:saveImageAsTempFile') - - ipcMain.handle('clipboard:readText', () => clipboard.readText()) - // Why: terminals need to detect clipboard images to support tools like Claude - // Code that accept image input via paste. Writes the clipboard image to a - // temp file and returns the path, or null if the clipboard has no image. - ipcMain.handle('clipboard:saveImageAsTempFile', async () => { - const image = clipboard.readImage() - if (image.isEmpty()) { - return null - } - const tempPath = path.join(app.getPath('temp'), `orca-paste-${Date.now()}.png`) - await fs.writeFile(tempPath, image.toPNG()) - return tempPath - }) - ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) - ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { - // Why: only accept validated PNG data URIs to prevent writing arbitrary - // data to the clipboard. The renderer already validates the prefix, but - // defense-in-depth applies here too. - const prefix = 'data:image/png;base64,' - if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) { - return - } - // Why: use createFromBuffer instead of createFromDataURL — the latter - // silently returns an empty image on some macOS + Electron combinations - // when the data URL is large (>500KB). Decoding the base64 manually and - // using createFromBuffer is more reliable. - const buffer = Buffer.from(dataUrl.slice(prefix.length), 'base64') - const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) { - return - } - clipboard.writeImage(image) - }) -} - export function registerUpdaterHandlers(_store: Store): void { ipcMain.removeHandler('updater:getStatus') ipcMain.removeHandler('updater:getVersion') diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts new file mode 100644 index 00000000000..d0f0be0a312 --- /dev/null +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + removeHandlerMock, + handleMock, + clipboardReadTextMock, + clipboardWriteTextMock, + clipboardReadImageMock, + clipboardWriteImageMock, + nativeImageCreateFromBufferMock +} = vi.hoisted(() => ({ + removeHandlerMock: vi.fn(), + handleMock: vi.fn(), + clipboardReadTextMock: vi.fn(), + clipboardWriteTextMock: vi.fn(), + clipboardReadImageMock: vi.fn(), + clipboardWriteImageMock: vi.fn(), + nativeImageCreateFromBufferMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/tmp') + }, + clipboard: { + readText: clipboardReadTextMock, + writeText: clipboardWriteTextMock, + readImage: clipboardReadImageMock, + writeImage: clipboardWriteImageMock + }, + ipcMain: { + removeHandler: removeHandlerMock, + handle: handleMock + }, + nativeImage: { + createFromBuffer: nativeImageCreateFromBufferMock + } +})) + +import { registerClipboardHandlers } from './clipboard-ipc-handlers' + +function getRegisteredHandlers(): Map unknown> { + const handlers = new Map unknown>() + for (const [channel, handler] of handleMock.mock.calls as [ + string, + (...args: unknown[]) => unknown + ][]) { + handlers.set(channel, handler) + } + return handlers +} + +describe('registerClipboardHandlers', () => { + beforeEach(() => { + removeHandlerMock.mockReset() + handleMock.mockReset() + clipboardReadTextMock.mockReset() + clipboardWriteTextMock.mockReset() + clipboardReadImageMock.mockReset() + clipboardWriteImageMock.mockReset() + nativeImageCreateFromBufferMock.mockReset() + }) + + it('registers normal and selection text clipboard IPC handlers', () => { + clipboardReadTextMock.mockImplementation((clipboardType?: string) => + clipboardType === 'selection' ? 'selection text' : 'standard text' + ) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + expect(handlers.get('clipboard:readText')?.()).toBe('standard text') + expect(handlers.get('clipboard:readSelectionText')?.()).toBe('selection text') + handlers.get('clipboard:writeText')?.({}, 'normal text') + handlers.get('clipboard:writeSelectionText')?.({}, 'primary text') + + expect(clipboardReadTextMock).toHaveBeenCalledWith() + expect(clipboardReadTextMock).toHaveBeenCalledWith('selection') + expect(clipboardWriteTextMock).toHaveBeenCalledWith('normal text') + expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') + }) + + it('removes stale clipboard IPC handlers before registering replacements', () => { + registerClipboardHandlers() + + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readSelectionText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeSelectionText') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') + }) +}) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts new file mode 100644 index 00000000000..a8cf69ddf76 --- /dev/null +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { app, clipboard, ipcMain, nativeImage } from 'electron' + +export function registerClipboardHandlers(): void { + ipcMain.removeHandler('clipboard:readText') + ipcMain.removeHandler('clipboard:readSelectionText') + ipcMain.removeHandler('clipboard:writeText') + ipcMain.removeHandler('clipboard:writeSelectionText') + ipcMain.removeHandler('clipboard:writeImage') + ipcMain.removeHandler('clipboard:saveImageAsTempFile') + + ipcMain.handle('clipboard:readText', () => clipboard.readText()) + ipcMain.handle('clipboard:readSelectionText', () => clipboard.readText('selection')) + // Why: terminals need to detect clipboard images to support tools like Claude + // Code that accept image input via paste. Writes the clipboard image to a + // temp file and returns the path, or null if the clipboard has no image. + ipcMain.handle('clipboard:saveImageAsTempFile', async () => { + const image = clipboard.readImage() + if (image.isEmpty()) { + return null + } + const tempPath = path.join(app.getPath('temp'), `orca-paste-${Date.now()}.png`) + await fs.writeFile(tempPath, image.toPNG()) + return tempPath + }) + ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) + ipcMain.handle('clipboard:writeSelectionText', (_event, text: string) => + clipboard.writeText(text, 'selection') + ) + ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { + // Why: only accept validated PNG data URIs to prevent writing arbitrary + // data to the clipboard. The renderer already validates the prefix, but + // defense-in-depth applies here too. + const prefix = 'data:image/png;base64,' + if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) { + return + } + // Why: use createFromBuffer instead of createFromDataURL — the latter + // silently returns an empty image on some macOS + Electron combinations + // when the data URL is large (>500KB). Decoding the base64 manually and + // using createFromBuffer is more reliable. + const buffer = Buffer.from(dataUrl.slice(prefix.length), 'base64') + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return + } + clipboard.writeImage(image) + }) +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index ffbb85a029f..a0bf129cb00 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -51,7 +51,7 @@ vi.mock('../browser/browser-manager', () => ({ } })) -import { createMainWindow } from './createMainWindow' +import { createMainWindow, loadMainWindow } from './createMainWindow' import { ipcMain } from 'electron' describe('createMainWindow', () => { @@ -69,6 +69,46 @@ describe('createMainWindow', () => { vi.useRealTimers() }) + it('can defer renderer loading until startup IPC handlers are registered', () => { + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + const win = createMainWindow(null, { deferLoad: true }) + + expect(browserWindowInstance.loadFile).not.toHaveBeenCalled() + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + loadMainWindow(win) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + }) + it('enables renderer sandboxing and opens external links safely', () => { const windowHandlers: Record void> = {} const webContents = { @@ -320,6 +360,89 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('forwards Ctrl+Tab keydown and Ctrl release to the renderer switcher', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const beforeInputEvent = windowHandlers['before-input-event'] + const firstPreventDefault = vi.fn() + beforeInputEvent( + { preventDefault: firstPreventDefault } as never, + { + type: 'keyDown', + code: 'Tab', + key: 'Tab', + control: true, + meta: false, + alt: false, + shift: false + } as never + ) + const secondPreventDefault = vi.fn() + beforeInputEvent( + { preventDefault: secondPreventDefault } as never, + { + type: 'keyDown', + code: 'Tab', + key: 'Tab', + control: true, + meta: false, + alt: false, + shift: true + } as never + ) + const releasePreventDefault = vi.fn() + beforeInputEvent( + { preventDefault: releasePreventDefault } as never, + { + type: 'keyUp', + code: 'ControlLeft', + key: 'Control', + control: false, + meta: false, + alt: false, + shift: false + } as never + ) + + expect(firstPreventDefault).toHaveBeenCalledTimes(1) + expect(secondPreventDefault).toHaveBeenCalledTimes(1) + expect(releasePreventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', { shiftKey: false }) + expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyDown', { shiftKey: true }) + expect(webContents.send).toHaveBeenNthCalledWith(3, 'ui:ctrlTabKeyUp') + }) + it('only intercepts the dictation chord when enabled toggle mode can handle it', () => { const windowHandlers: Record void> = {} const webContents = { @@ -567,6 +690,217 @@ describe('createMainWindow', () => { expect(onQuitAborted).toHaveBeenCalledTimes(1) }) + it('allows close after the renderer process is gone', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null, { getIsQuitting: () => true }) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as never + ) + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).not.toHaveBeenCalled() + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', { + isQuitting: true + }) + + consoleError.mockRestore() + }) + + it('does not persist pending bounds after bypassing close for a gone renderer', () => { + vi.useFakeTimers() + + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + getBounds: vi.fn(() => ({ x: 10, y: 20, width: 1000, height: 700 })), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + const updateUI = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow({ + getUI: () => ({}), + getSettings: () => ({ windowBackgroundBlur: false }), + updateUI + } as never) + + windowHandlers.resize() + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as never + ) + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + vi.advanceTimersByTime(500) + + expect(preventDefault).not.toHaveBeenCalled() + expect(updateUI).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('resumes close confirmation after a renderer process reloads', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => false) + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null, { getIsQuitting: () => true }) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as never + ) + windowHandlers['did-finish-load']?.() + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { + isQuitting: true + }) + + consoleError.mockRestore() + }) + + it('allows close when Electron reports a crashed webContents', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isCrashed: vi.fn(() => true) + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null, { getIsQuitting: () => true }) + + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).not.toHaveBeenCalled() + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', { + isQuitting: true + }) + }) + it('ignores traffic light sync IPC on non-macOS', () => { const windowHandlers: Record void> = {} const webContents = { @@ -1053,6 +1387,44 @@ describe('createMainWindow', () => { assertInterceptsAfterReset() }) + it('notifies the caller when the renderer process is gone', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + const onRendererProcessGone = vi.fn() + + createMainWindow(null, { onRendererProcessGone }) + + const details = { reason: 'crashed', exitCode: 5 } as Electron.RenderProcessGoneDetails + windowHandlers['render-process-gone']?.({} as never, details) + + expect(onRendererProcessGone).toHaveBeenCalledWith(details) + }) + it('ignores duplicate ready-to-show events after startup maximize has already run', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 149de5b9e9e..11a515b173e 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -32,6 +32,14 @@ function forceRepaint(window: BrowserWindow): void { }, 32) } +function isCtrlTabSwitchKey(input: Electron.Input): boolean { + return input.code === 'Tab' && input.control && !input.meta && !input.alt +} + +function isControlKeyRelease(input: Electron.Input): boolean { + return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight') +} + // Why: the titlebar is 36px (border-box, 1px border-bottom). The visual // center of the CSS-centered content sits at ~18 CSS px from the top. // At zoom factor z that becomes 18·z window px. Traffic lights are @@ -60,6 +68,19 @@ type CreateMainWindowOptions = { * latch must be cleared or later window closes will be misclassified as * quit attempts. */ onQuitAborted?: () => void + onRendererProcessGone?: (details: Electron.RenderProcessGoneDetails) => void + /** Why: main-process startup must register IPC handlers before the renderer + * begins booting, or eager renderer calls can race into missing channels. */ + deferLoad?: boolean + title?: string +} + +export function loadMainWindow(mainWindow: BrowserWindow): void { + if (is.dev && process.env.ELECTRON_RENDERER_URL) { + void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + void mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } } export function createMainWindow( @@ -153,6 +174,7 @@ export function createMainWindow( ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, + title: opts?.title ?? 'Orca', show: false, // Why: on macOS the menu lives in the system menu bar, so the in-window // menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar @@ -460,14 +482,24 @@ export function createMainWindow( const resetMarkdownEditorFocus = (): void => { markdownEditorFocused = false } - mainWindow.webContents.on('render-process-gone', resetMarkdownEditorFocus) + let rendererProcessGone = false + mainWindow.webContents.on('render-process-gone', (_event, details) => { + rendererProcessGone = true + resetMarkdownEditorFocus() + opts?.onRendererProcessGone?.(details) + console.error('[window] Renderer process gone; close confirmation will be bypassed', details) + }) mainWindow.webContents.on('destroyed', resetMarkdownEditorFocus) mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => { if (isMainFrame) { resetMarkdownEditorFocus() } }) + mainWindow.webContents.on('did-finish-load', () => { + rendererProcessGone = false + }) + let ctrlTabSwitching = false mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type === 'keyDown' && is.dev && input.code === 'F12') { event.preventDefault() @@ -479,6 +511,25 @@ export function createMainWindow( return } + if (isCtrlTabSwitchKey(input)) { + // Why: Ctrl+Tab is a held-key interaction. Route both press and release + // through IPC so renderer keyup suppression from preventDefault cannot + // leave the switcher overlay stranded. + event.preventDefault() + if (input.type === 'keyDown') { + ctrlTabSwitching = true + mainWindow.webContents.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) + } + return + } + + if (ctrlTabSwitching && isControlKeyRelease(input)) { + event.preventDefault() + ctrlTabSwitching = false + mainWindow.webContents.send('ui:ctrlTabKeyUp') + return + } + // Why: TipTap owns bare Cmd/Ctrl+B for bold while the markdown editor is // focused — skip interception so its keymap runs. Scoped to the bare chord // (no Shift/Alt): any extra modifier signals different intent and must @@ -623,6 +674,18 @@ export function createMainWindow( } return } + const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false + if (rendererProcessGone || isRendererCrashed) { + // Why: after a native renderer crash the renderer cannot answer + // window:close-requested. Let Cmd+Q / OS close complete instead of + // trapping the user in a blank, unquittable window. + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } + return + } e.preventDefault() mainWindow.webContents.send('window:close-requested', { isQuitting: opts?.getIsQuitting?.() ?? false @@ -725,10 +788,8 @@ export function createMainWindow( app.removeListener('before-quit', freezeBoundsOnQuit) }) - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + if (!opts?.deferLoad) { + loadMainWindow(mainWindow) } return mainWindow diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ab03644b477..667dfc0dbd1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -7,8 +7,10 @@ import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { AppIdentity } from '../shared/app-identity' import type { BaseRefDefaultResult, + BaseRefSearchResult, BrowserCookieImportResult, BrowserLoadError, BrowserSessionProfile, @@ -25,6 +27,7 @@ import type { GhosttyImportPreview, GlobalSettings, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -87,12 +90,14 @@ import type { UpdateStatus, Worktree, WorktreeBaseStatusEvent, + WorktreeLineage, WorktreeMeta, WorktreeRemoteBranchConflictEvent, WorktreeSetupLaunch, WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { @@ -144,6 +149,7 @@ import type { BrowserPopupEvent } from '../shared/browser-guest-events' import type { ElectronAPI } from '@electron-toolkit/preload' +import type { BrowserSetAnnotationViewportBridgeArgs } from '../shared/browser-annotation-viewport-bridge' import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' @@ -157,6 +163,12 @@ import type { RuntimeTerminalDriverState } from '../shared/runtime-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' +import type { SkillDiscoveryResult } from '../shared/skills' +import type { + CrashReportRecord, + CrashReportSubmitArgs, + CrashReportSubmitResult +} from '../shared/crash-reporting' export type { ShellOpenLocalPathResult } from '../shared/shell-open-types' @@ -211,6 +223,16 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { @@ -224,11 +246,22 @@ import type { AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, + ExternalAutomationCreateInput, ExternalAutomationActionInput, ExternalAutomationManager, + ExternalAutomationRunsInput, + ExternalAutomationRunsPage, + ExternalAutomationUpdateInput, AutomationRun, AutomationUpdateInput } from '../shared/automations-types' +import type { + WorkspaceCleanupDismissArgs, + WorkspaceCleanupLocalProcessArgs, + WorkspaceCleanupLocalProcessResult, + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' export type BrowserApi = { registerGuest: (args: { @@ -244,6 +277,7 @@ export type BrowserApi = { browserPageId: string override: BrowserViewportOverride | null }) => Promise + setAnnotationViewportBridge: (args: BrowserSetAnnotationViewportBridgeArgs) => Promise onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void @@ -320,6 +354,13 @@ export type PreflightStatus = { * include it. Consumers gate on `glab?.installed` / `authenticated`. */ glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } gitea?: { configured: boolean authenticated: boolean @@ -442,7 +483,33 @@ export type CodexUsageApi = { }) => Promise } +export type OpenCodeUsageApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSummary: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getDaily: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getBreakdown: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + }) => Promise + getRecentSessions: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + limit?: number + }) => Promise +} + export type AppApi = { + /** Returns the app identity currently exposed to native chrome and the titlebar. */ + getIdentity: () => Promise /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; * in packaged builds this is file:// resources. Renderer appends filenames. */ getFeatureWallAssetBaseUrl: () => Promise @@ -511,6 +578,11 @@ export type PreloadApi = { getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + }) => Promise onChanged: (callback: () => void) => () => void } sparsePresets: { @@ -545,6 +617,12 @@ export type PreloadApi = { }) => Promise<{ baseBranch: string } | { error: string }> remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + listLineage: () => Promise> + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void @@ -552,6 +630,14 @@ export type PreloadApi = { callback: (data: WorktreeRemoteBranchConflictEvent) => void ) => () => void } + workspaceCleanup: { + scan: (args?: WorkspaceCleanupScanArgs) => Promise + dismiss: (args: WorkspaceCleanupDismissArgs) => Promise + clearDismissals: () => Promise + hasKillableLocalProcesses: ( + args: WorkspaceCleanupLocalProcessArgs + ) => Promise + } workspaceSpace: { analyze: () => Promise cancel: () => Promise @@ -630,6 +716,15 @@ export type PreloadApi = { githubEmail: string | null }) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }> } + crashReports: { + getLatestPending: () => Promise + dismiss: (args: { reportId: string }) => Promise + copyLatestDiagnostics: (args?: { + reportId?: string + notes?: string + }) => Promise<{ ok: true } | { ok: false; error: string }> + submit: (args: CrashReportSubmitArgs) => Promise + } export: ExportApi gh: { viewer: () => Promise @@ -716,6 +811,14 @@ export type PreloadApi = { threadId: string resolve: boolean }) => Promise + setPRFileViewed: (args: { + repoPath: string + repoId?: string + prNumber: number + pullRequestId: string + path: string + viewed: boolean + }) => Promise updatePRTitle: (args: { repoPath: string repoId?: string @@ -1021,9 +1124,15 @@ export type PreloadApi = { geminiStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise + grokStatus: () => Promise + copilotStatus: () => Promise + hermesStatus: () => Promise } agentTrust: { - markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + }) => Promise } preflight: PreflightApi notifications: { @@ -1058,7 +1167,7 @@ export type PreloadApi = { shell: { openPath: (path: string) => Promise openInFileManager: (path: string) => Promise - openInExternalEditor: (path: string) => Promise + openInExternalEditor: (path: string, command?: string) => Promise openUrl: (url: string) => Promise openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise @@ -1069,6 +1178,9 @@ export type PreloadApi = { pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise } + skills: { + discover: () => Promise + } pet: { import: () => Promise importPetBundle: () => Promise @@ -1146,6 +1258,7 @@ export type PreloadApi = { memory: MemoryApi claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi + openCodeUsage: OpenCodeUsageApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { @@ -1248,7 +1361,19 @@ export type PreloadApi = { onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { - status: (args: { worktreePath: string; connectionId?: string }) => Promise + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + }) => Promise + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }) => Promise + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ) => Promise conflictOperation: (args: { worktreePath: string connectionId?: string @@ -1265,6 +1390,11 @@ export type PreloadApi = { baseRef: string connectionId?: string }) => Promise + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }) => Promise upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -1289,6 +1419,14 @@ export type PreloadApi = { oldPath?: string connectionId?: string }) => Promise + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }) => Promise commit: (args: { worktreePath: string message: string @@ -1305,6 +1443,25 @@ export type PreloadApi = { worktreePath: string connectionId?: string }) => Promise + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }) => Promise< + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + } + | { success: false; error: string; canceled?: boolean } + > + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }) => Promise stage: (args: { worktreePath: string filePath: string @@ -1347,6 +1504,7 @@ export type PreloadApi = { set: (args: Partial) => Promise onOpenSettings: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void + onOpenCrashReport: (callback: () => void) => () => void onShowFeatureTourNudge: (callback: () => void) => () => void onToggleLeftSidebar: (callback: () => void) => () => void onToggleRightSidebar: (callback: () => void) => () => void @@ -1383,6 +1541,8 @@ export type PreloadApi = { onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void) => () => void + onCtrlTabKeyUp: (callback: () => void) => () => void onToggleStatusBar: (callback: () => void) => () => void onDictationKeyDown: (callback: () => void) => () => void onExportPdfRequested: (callback: () => void) => () => void @@ -1413,6 +1573,7 @@ export type PreloadApi = { afterTabId?: string command?: string title?: string + activate?: boolean }) => void ) => () => void replyTerminalCreate: (reply: { @@ -1454,8 +1615,10 @@ export type PreloadApi = { onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise + readSelectionClipboardText: () => Promise saveClipboardImageAsTempFile: () => Promise writeClipboardText: (text: string) => Promise + writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise onFileDrop: ( callback: ( @@ -1613,12 +1776,16 @@ export type PreloadApi = { list: () => Promise listRuns: (args?: { automationId?: string }) => Promise listExternalManagers: () => Promise + listExternalRuns: (input: ExternalAutomationRunsInput) => Promise + createExternal: (input: ExternalAutomationCreateInput) => Promise + updateExternal: (input: ExternalAutomationUpdateInput) => Promise runExternalAction: (input: ExternalAutomationActionInput) => Promise create: (input: AutomationCreateInput) => Promise update: (args: { id: string; updates: AutomationUpdateInput }) => Promise delete: (args: { id: string }) => Promise runNow: (args: { id: string }) => Promise markDispatchResult: (result: AutomationDispatchResult) => Promise + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise rendererReady: () => Promise onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 314d84a134c..0bad7d8aa8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,9 +5,11 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' +import type { AppIdentity } from '../shared/app-identity' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { + BaseRefSearchResult, BaseRefDefaultResult, BrowserViewportOverride, CreateWorktreeArgs, @@ -32,7 +34,9 @@ import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' +import type { SkillDiscoveryResult } from '../shared/skills' import type { RuntimeStatus, RuntimeSyncWindowGraph, @@ -100,8 +104,12 @@ import type { AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, + ExternalAutomationCreateInput, ExternalAutomationActionInput, ExternalAutomationManager, + ExternalAutomationRunsInput, + ExternalAutomationRunsPage, + ExternalAutomationUpdateInput, AutomationRun, AutomationUpdateInput } from '../shared/automations-types' @@ -323,6 +331,7 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { + getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), @@ -403,6 +412,12 @@ const api = { searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + onChanged: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('repos:changed', listener) @@ -465,6 +480,15 @@ const api = { updates: Record }): Promise => ipcRenderer.invoke('worktrees:updateMeta', args), + listLineage: (): Promise> => + ipcRenderer.invoke('worktrees:listLineage'), + + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }): Promise => ipcRenderer.invoke('worktrees:updateLineage', args), + persistSortOrder: (args: { orderedIds: string[] }): Promise => ipcRenderer.invoke('worktrees:persistSortOrder', args), @@ -494,6 +518,19 @@ const api = { } }, + workspaceCleanup: { + scan: (args?: { worktreeId?: string; skipGitWorktreeIds?: string[] }): Promise => + ipcRenderer.invoke('workspaceCleanup:scan', args), + dismiss: (args: { dismissals: unknown[] }): Promise => + ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: (): Promise => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args: { + worktreeId: string + connectionId?: string | null + worktreePath?: string + }): Promise => ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args) + }, + workspaceSpace: { analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze'), @@ -673,6 +710,20 @@ const api = { ipcRenderer.invoke('feedback:submit', args) }, + crashReports: { + getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), + dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + submit: (args: { + reportId?: string + notes?: string + submitAnonymously?: boolean + githubLogin: string | null + githubEmail: string | null + }) => ipcRenderer.invoke('crashReports:submit', args) + }, + export: { htmlToPdf: (args: { html: string @@ -780,6 +831,15 @@ const api = { resolve: boolean }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + setPRFileViewed: (args: { + repoPath: string + repoId?: string + prNumber: number + pullRequestId: string + path: string + viewed: boolean + }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), + updatePRTitle: (args: { repoPath: string repoId?: string @@ -1092,12 +1152,20 @@ const api = { ipcRenderer.invoke('agentHooks:geminiStatus'), cursorStatus: (): Promise => ipcRenderer.invoke('agentHooks:cursorStatus'), - droidStatus: (): Promise => ipcRenderer.invoke('agentHooks:droidStatus') + droidStatus: (): Promise => + ipcRenderer.invoke('agentHooks:droidStatus'), + grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus'), + copilotStatus: (): Promise => + ipcRenderer.invoke('agentHooks:copilotStatus'), + hermesStatus: (): Promise => + ipcRenderer.invoke('agentHooks:hermesStatus') }, agentTrust: { - markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise => - ipcRenderer.invoke('agentTrust:markTrusted', args) + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) }, preflight: { @@ -1108,6 +1176,13 @@ const api = { gh: { installed: boolean; authenticated: boolean } glab?: { installed: boolean; authenticated: boolean } bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } gitea?: { configured: boolean authenticated: boolean @@ -1222,8 +1297,8 @@ const api = { openInFileManager: (path: string): Promise => ipcRenderer.invoke('shell:openInFileManager', path), - openInExternalEditor: (path: string): Promise => - ipcRenderer.invoke('shell:openInExternalEditor', path), + openInExternalEditor: (path: string, command?: string): Promise => + ipcRenderer.invoke('shell:openInExternalEditor', path, command), openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), @@ -1246,6 +1321,10 @@ const api = { ipcRenderer.invoke('shell:copyFile', args) }, + skills: { + discover: (): Promise => ipcRenderer.invoke('skills:discover') + }, + pet: { import: (): Promise => ipcRenderer.invoke('pet:import'), importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), @@ -1275,6 +1354,9 @@ const api = { override: BrowserViewportOverride | null }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + setAnnotationViewportBridge: (args): Promise => + ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), + onGuestLoadFailed: ( callback: (args: { browserPageId: string @@ -1830,8 +1912,19 @@ const api = { }, git: { - status: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:status', args), + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + }): Promise => ipcRenderer.invoke('git:status', args), + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:checkIgnored', args), + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => ipcRenderer.invoke('git:history', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), diff: (args: { @@ -1846,6 +1939,11 @@ const api = { baseRef: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -1867,6 +1965,14 @@ const api = { oldPath?: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string @@ -1880,6 +1986,18 @@ const api = { worktreePath: string connectionId?: string }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), stage: (args: { worktreePath: string filePath: string @@ -1931,6 +2049,11 @@ const api = { ipcRenderer.on('ui:openFeatureTour', listener) return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) }, + onOpenCrashReport: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openCrashReport', listener) + return () => ipcRenderer.removeListener('ui:openCrashReport', listener) + }, onShowFeatureTourNudge: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:showFeatureTourNudge', listener) @@ -2077,6 +2200,17 @@ const api = { ipcRenderer.on('ui:switchTerminalTab', listener) return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) }, + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => + callback(data) + ipcRenderer.on('ui:ctrlTabKeyDown', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) + }, + onCtrlTabKeyUp: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:ctrlTabKeyUp', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) + }, onToggleStatusBar: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:toggleStatusBar', listener) @@ -2146,6 +2280,7 @@ const api = { afterTabId?: string command?: string title?: string + activate?: boolean }) => void ): (() => void) => { const listener = ( @@ -2156,6 +2291,7 @@ const api = { afterTabId?: string command?: string title?: string + activate?: boolean } ) => callback(data) ipcRenderer.on('terminal:requestTabCreate', listener) @@ -2273,10 +2409,14 @@ const api = { return () => ipcRenderer.removeListener('terminal:zoom', listener) }, readClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readText'), + readSelectionClipboardText: (): Promise => + ipcRenderer.invoke('clipboard:readSelectionText'), saveClipboardImageAsTempFile: (): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile'), writeClipboardText: (text: string): Promise => ipcRenderer.invoke('clipboard:writeText', text), + writeSelectionClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeSelectionText', text), writeClipboardImage: (dataUrl: string): Promise => ipcRenderer.invoke('clipboard:writeImage', dataUrl), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => @@ -2403,6 +2543,22 @@ const api = { ipcRenderer.invoke('codexUsage:getRecentSessions', args) }, + openCodeUsage: { + getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), + setEnabled: (args: { enabled: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:setEnabled', args), + refresh: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:refresh', args), + getSummary: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getSummary', args), + getDaily: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getDaily', args), + getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getBreakdown', args), + getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => + ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), @@ -2648,6 +2804,12 @@ const api = { ipcRenderer.invoke('automations:listRuns', args), listExternalManagers: (): Promise => ipcRenderer.invoke('automations:listExternalManagers'), + listExternalRuns: (input: ExternalAutomationRunsInput): Promise => + ipcRenderer.invoke('automations:listExternalRuns', input), + createExternal: (input: ExternalAutomationCreateInput): Promise => + ipcRenderer.invoke('automations:createExternal', input), + updateExternal: (input: ExternalAutomationUpdateInput): Promise => + ipcRenderer.invoke('automations:updateExternal', input), runExternalAction: (input: ExternalAutomationActionInput): Promise => ipcRenderer.invoke('automations:runExternalAction', input), create: (input: AutomationCreateInput): Promise => @@ -2659,6 +2821,8 @@ const api = { ipcRenderer.invoke('automations:runNow', args), markDispatchResult: (result: AutomationDispatchResult): Promise => ipcRenderer.invoke('automations:markDispatchResult', result), + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => + ipcRenderer.invoke('automations:snapshotWorkspaceName', args), rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e24182441be..a03ea8cc564 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' @@ -197,4 +197,117 @@ describe('RelayAgentHookServer', () => { server.stop() } }) + + it('keeps Copilot transcript retry alive across a following SessionEnd event', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + const transcriptPath = join(dir, 'events.jsonl') + writeFileSync(transcriptPath, '') + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'Stop', transcriptPath } + }) + }) + await fetch(`http://127.0.0.1:${port}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'SessionEnd', reason: 'complete' } + }) + }) + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: 'assistant.message', + data: { content: 'Relay transcript completed.' } + })}\n` + ) + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( + 'Relay transcript completed.' + ) + } finally { + server.stop() + } + }) + + it('retries Grok chat history on the relay without blocking the hook POST', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528' + const cwd = join(dir, 'workspace') + const sessionDir = join(dir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'chat_history.jsonl'), '') + vi.stubEnv('HOME', dir) + vi.stubEnv('USERPROFILE', dir) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hookEventName: 'user_prompt_submit', prompt: 'hihi' } + }) + }) + const response = await fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hookEventName: 'Stop', sessionId, cwd } + }) + }) + + expect(response.status).toBe(204) + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'Relay Grok reply.' })}\n` + ) + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe('Relay Grok reply.') + } finally { + server.stop() + vi.unstubAllEnvs() + } + }) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index f2fc8e5d267..a537a62d037 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: relay hook parsing, replay cache, endpoint + writing, and assistant-message retry state are one lifecycle unit; splitting + them would obscure cleanup ordering across remote PTY reconnects. */ // Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts // a loopback HTTP server (same shape as Orca's main-process server: bind // 127.0.0.1:0, bearer-token auth, /hook/ routing) and forwards every @@ -19,6 +22,7 @@ import { clearPaneCacheState, createHookListenerState, getEndpointFileName, + hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, readRequestBody, @@ -41,6 +45,8 @@ export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void // server is the only consumer. const RELAY_HOOKS_DIR_NAME = '.orca-relay' const RELAY_HOOKS_SUBDIR = 'agent-hooks' +const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 +const ASSISTANT_MESSAGE_RETRY_MS = 50 // Why: cap env/version metadata at 64 chars so a misbehaving agent CLI // cannot grow lastEnvelopeMetaByPaneKey unboundedly per pane via the cache @@ -87,6 +93,7 @@ export class RelayAgentHookServer { string, { source: AgentHookSource; env?: string; version?: string } > = new Map() + private assistantMessageRetryTimers = new Map>() private forward: RelayHookForward constructor(options: RelayHookServerOptions) { @@ -144,6 +151,10 @@ export class RelayAgentHookServer { this.port = 0 this.token = '' this.endpointFileWritten = false + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } @@ -175,6 +186,7 @@ export class RelayAgentHookServer { * resurfaces as a ghost event on a later reconnect. Symmetric with the * local server's clearPaneState on PTY teardown. */ clearPaneState(paneKey: string): void { + this.clearAssistantMessageRetry(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } @@ -229,13 +241,12 @@ export class RelayAgentHookServer { } const event = normalizeHookPayload(this.state, source, body, this.env) if (event) { - this.state.lastStatusByPaneKey.set(event.paneKey, event) // TODO: once normalizeHookPayload returns validated env/version, drop // bodyEnv/bodyVersion and source those from the listener result instead. const env = this.bodyEnv(body) const version = this.bodyVersion(body) - this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) - this.forwardEvent(event, source, env, version) + this.applyEvent(event, source, env, version) + this.scheduleAssistantMessageRetry(source, body, event, env, version) } res.writeHead(204) res.end() @@ -271,6 +282,77 @@ export class RelayAgentHookServer { this.forward(envelope) } + private applyEvent( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ): void { + if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { + this.clearAssistantMessageRetry(event.paneKey) + } + this.state.lastStatusByPaneKey.set(event.paneKey, event) + this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) + this.forwardEvent(event, source, env, version) + } + + private clearAssistantMessageRetry(paneKey: string): void { + const timer = this.assistantMessageRetryTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.assistantMessageRetryTimers.delete(paneKey) + } + + private scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: AgentHookEventPayload, + env?: string, + version?: string, + attempt = 1 + ): void { + if ( + original.payload.lastAssistantMessage || + !hasPendingAgentResultText(source, body) || + attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS + ) { + return + } + this.clearAssistantMessageRetry(original.paneKey) + const timer = setTimeout(() => { + try { + this.assistantMessageRetryTimers.delete(original.paneKey) + const current = this.state.lastStatusByPaneKey.get(original.paneKey) + if ( + !current || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (!event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, env, version, attempt + 1) + return + } + // Why: the relay runs on SSH targets too; retry from a timer so a delayed + // transcript/chat-history write does not block the remote hook server. + this.applyEvent(event, source, env, version) + } catch (err) { + process.stderr.write( + `[relay-hook-server] assistant message retry failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + }, ASSISTANT_MESSAGE_RETRY_MS) + this.assistantMessageRetryTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + private bodyEnv(body: unknown): string | undefined { if (typeof body !== 'object' || body === null) { return undefined diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts new file mode 100644 index 00000000000..2228ff57c4d --- /dev/null +++ b/src/relay/external-automations-handler.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ExternalAutomationsHandler } from './external-automations-handler' +import type { RelayDispatcher } from './dispatcher' + +const execFileMock = vi.hoisted(() => + vi.fn((...args: unknown[]) => { + const callback = args.at(-1) + if (typeof callback === 'function') { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(null, '', '') + } + }) +) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +type CapturedHandler = (params?: Record) => Promise + +function createHandlerHarness(): { + handler: ExternalAutomationsHandler + requestHandlers: Map +} { + const requestHandlers = new Map() + const dispatcher = { + onRequest(method: string, handler: CapturedHandler): void { + requestHandlers.set(method, handler) + } + } + const handler = new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) + return { handler, requestHandlers } +} + +beforeEach(() => { + execFileMock.mockClear() +}) + +describe('ExternalAutomationsHandler', () => { + it('runs external lifecycle actions without shell wrapping', async () => { + const { requestHandlers } = createHandlerHarness() + + await requestHandlers.get('externalAutomations.act')?.({ + provider: 'hermes', + action: 'run', + jobId: 'job-1' + }) + + expect(execFileMock).toHaveBeenCalledWith( + 'hermes', + ['cron', 'run', 'job-1'], + { encoding: 'utf-8', timeout: 30_000 }, + expect.any(Function) + ) + }) + + it('paginates remote Hermes run history after ref lookup', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const handlerInternals = handler as unknown as { + readHermesRunRefs: (jobId: string) => Promise<{ id: string; run_at: string }[]> + hydrateHermesRunRef: ( + jobId: string, + ref: { id: string; run_at: string } + ) => Promise<{ id: string; run_at: string }> + } + handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue([ + { + id: 'cron_job-1_20260516_090000', + run_at: '2026-05-16T09:00:00' + }, + { + id: 'job-1:2026-05-15_09-00-00.md', + run_at: '2026-05-15T09:00:00' + }, + { + id: 'job-1:2026-05-14_09-00-00.md', + run_at: '2026-05-14T09:00:00' + } + ]) + handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => ref) + + const result = (await requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 2 + })) as { total: number; runs: { id: string }[] } + + expect(result.total).toBe(3) + expect(result.runs.map((run) => run.id)).toEqual([ + 'cron_job-1_20260516_090000', + 'job-1:2026-05-15_09-00-00.md' + ]) + }) + + it('uses a count-only path for remote Hermes manager listing run counts', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const handlerInternals = handler as unknown as { + readHermesRunCount: (jobId: string) => Promise + } + handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue(42) + + const result = (await requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + })) as { total: number; runs: unknown[] } + + expect(result).toEqual({ total: 42, runs: [] }) + }) + + it('deduplicates concurrent remote Hermes count reads', async () => { + const { handler, requestHandlers } = createHandlerHarness() + let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} + const readHermesRunRefs = vi.fn( + () => + new Promise<{ id: string; run_at: string }[]>((resolve) => { + resolveRefs = resolve + }) + ) + const handlerInternals = handler as unknown as { + readHermesRunRefs: typeof readHermesRunRefs + } + handlerInternals.readHermesRunRefs = readHermesRunRefs + + const first = requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + const second = requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + + expect(readHermesRunRefs).toHaveBeenCalledTimes(1) + resolveRefs([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { total: 2, runs: [] }, + { total: 2, runs: [] } + ]) + }) + + it('clears the remote Hermes count cache after lifecycle actions', async () => { + const { handler, requestHandlers } = createHandlerHarness() + const readHermesRunRefs = vi + .fn() + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } + ]) + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) + const handlerInternals = handler as unknown as { + readHermesRunRefs: typeof readHermesRunRefs + } + handlerInternals.readHermesRunRefs = readHermesRunRefs + + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 1, runs: [] }) + + await requestHandlers.get('externalAutomations.act')?.({ + provider: 'hermes', + action: 'run', + jobId: 'job-1' + }) + await expect( + requestHandlers.get('externalAutomations.runs')?.({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 0 + }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readHermesRunRefs).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 82d2c6d2799..bc9956d1ff7 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -1,28 +1,82 @@ +/* eslint-disable max-lines -- Why: relay external automation listing, paginated + * run history, and actions must stay co-located behind one relay request handler. */ import { execFile } from 'child_process' import { existsSync } from 'fs' -import { readFile } from 'fs/promises' +import { open, readdir, readFile, realpath, stat } from 'fs/promises' +import { createRequire } from 'module' import { homedir } from 'os' -import { join } from 'path' +import { isAbsolute, join, relative, resolve } from 'path' import { promisify } from 'util' +import type Database from 'better-sqlite3' import type { RelayDispatcher } from './dispatcher' const execFileAsync = promisify(execFile) -const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json') +const requireOptional = createRequire(__filename) +const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') +const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') +const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') +const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') +const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ +const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ +const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ +const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 +const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 +const FULL_SESSION_LOG_HEADING = '## Full session log' +const REFERENCED_LOG_HEADING = '## Latest log file' +const LATEST_LOG_PATH_PATTERN = + /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i +type DatabaseConstructor = typeof Database +let databaseConstructor: DatabaseConstructor | null | undefined type ExternalProvider = 'hermes' | 'openclaw' type HermesAction = 'pause' | 'resume' | 'run' | 'delete' +type HermesOutputRunRef = { + kind: 'output' + id: string + job_id: string + run_at: string | null + run_key: string | null + output_path: string +} +type HermesSessionRunRef = { + kind: 'session' + id: string + job_id: string + run_at: string | null + run_key: string | null +} +type HermesMergedRunRef = { + id: string + job_id: string + run_at: string | null + run_key: string | null + output: HermesOutputRunRef | null + session: HermesSessionRunRef | null +} +type HermesRunCountCacheEntry = { + promise: Promise + expiresAt: number +} + +const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 export class ExternalAutomationsHandler { + private readonly hermesRunCountCache = new Map() + constructor(private readonly dispatcher: RelayDispatcher) { this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params)) + this.dispatcher.onRequest('externalAutomations.runs', (params) => this.listRuns(params)) + this.dispatcher.onRequest('externalAutomations.create', (params) => this.createJob(params)) + this.dispatcher.onRequest('externalAutomations.update', (params) => this.updateJob(params)) this.dispatcher.onRequest('externalAutomations.act', (params) => this.runAction(params)) } private async isCommandAvailable(command: string): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' try { - await execFileAsync('/bin/sh', ['-lc', `which ${command}`], { + await execFileAsync(finder, [command], { encoding: 'utf-8', timeout: 5000 }) @@ -39,15 +93,656 @@ export class ExternalAutomationsHandler { } const content = await readFile(jobsFile, 'utf-8') const parsed = JSON.parse(content) as unknown - if (Array.isArray(parsed)) { - return parsed + const jobs = Array.isArray(parsed) + ? parsed + : typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + Array.isArray((parsed as { jobs?: unknown }).jobs) + ? (parsed as { jobs: unknown[] }).jobs + : [] + if (provider !== 'hermes') { + return jobs + } + return Promise.all( + jobs.map(async (job) => { + if (!this.isRecord(job) || typeof job.id !== 'string') { + return job + } + const runsPage = await this.listRuns({ + provider: 'hermes', + jobId: job.id, + page: 1, + pageSize: 0 + }) + return { + ...job, + run_count: runsPage.total, + runs: runsPage.runs + } + }) + ) + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + } + + private runAtFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}-${month}-${day}T${hour}:${minute}:${second}` + } + + private runKeyFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}${month}${day}_${hour}${minute}${second}` + } + + private runAtFromUnixSeconds(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() + } + + private sortableTimeFromRunKey(runKey: string | null): number { + if (!runKey) { + return Number.NaN + } + const match = HERMES_RUN_KEY_PATTERN.exec(runKey) + if (!match) { + return Number.NaN + } + const [, year, month, day, hour, minute, second] = match + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) + } + + private escapeSqlLike(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') + } + + private cleanRunPreview(value: string): string | null { + const normalized = value + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[#*_`>()]/g, ' ') + .replaceAll('[', ' ') + .replaceAll(']', ' ') + .replace(/\s+/g, ' ') + .trim() + if (!normalized) { + return null + } + return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized + } + + private parseHermesOutput(content: string): { + status: 'completed' | 'failed' | 'unknown' + outputPreview: string | null + outputContent: string + error: string | null + } { + const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) + const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) + const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) + const error = errorMatch ? this.cleanRunPreview(errorMatch[1]) : null + return { + status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', + outputPreview: this.cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), + outputContent: content, + error + } + } + + private extractLatestLogPath(content: string): string | null { + const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() + if (!rawPath) { + return null + } + return rawPath.replace(/^`|`$/g, '').trim() + } + + private async readReferencedLogFile(content: string): Promise<{ + path: string + content: string + truncated: boolean + } | null> { + const logPath = this.extractLatestLogPath(content) + if (!logPath || !isAbsolute(logPath)) { + return null + } + try { + const homeRealPath = await realpath(HERMES_HOME) + const logRealPath = await realpath(logPath) + const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) + // Why: the output body can contain agent-authored text, so only hydrate + // referenced files that resolve inside Hermes' own data directory. + if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) { + return null + } + const logStat = await stat(logPath) + if (!logStat.isFile()) { + return null + } + if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + return { + path: logPath, + content: await readFile(logPath, 'utf-8'), + truncated: false + } + } + const file = await open(logPath, 'r') + try { + const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) + await file.read( + buffer, + 0, + MAX_REFERENCED_LOG_BYTES, + logStat.size - MAX_REFERENCED_LOG_BYTES + ) + return { + path: logPath, + content: buffer.toString('utf-8'), + truncated: true + } + } finally { + await file.close() + } + } catch { + return null + } + } + + private async appendReferencedLogFile(content: string): Promise { + if (content.includes(REFERENCED_LOG_HEADING)) { + return content + } + const logFile = await this.readReferencedLogFile(content) + if (!logFile) { + return content + } + const note = logFile.truncated + ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` + : null + return [ + content, + '---', + REFERENCED_LOG_HEADING, + '', + `Path: ${logFile.path}`, + note, + '```text', + logFile.content.trimEnd(), + '```' + ] + .filter((part) => part !== null) + .join('\n\n') + } + + private formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') + } + + private getRunKey(run: unknown): string | null { + return this.isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() + ? run.run_key + : null + } + + private getRunOutputContent(run: unknown): string | null { + return this.isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() + ? run.output_content + : null + } + + private getRunOutputPreview(run: unknown): string | null { + return this.isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() + ? run.output_preview + : null + } + + private mergeOutputAndSessionContent( + outputContent: string | null, + sessionContent: string | null + ): string | null { + if (!sessionContent) { + return outputContent + } + if (!outputContent) { + return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` + } + if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { + return outputContent + } + return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` + } + + private findMatchingSessionRunIndex( + outputRun: unknown, + sessionRuns: unknown[], + usedSessionRunIndexes: Set + ): number | null { + const outputRunKey = this.getRunKey(outputRun) + const exactMatchIndex = sessionRuns.findIndex( + (sessionRun, index) => + !usedSessionRunIndexes.has(index) && this.getRunKey(sessionRun) === outputRunKey + ) + if (exactMatchIndex >= 0) { + return exactMatchIndex + } + + const outputTime = this.sortableTimeFromRunKey(outputRunKey) + if (!Number.isFinite(outputTime)) { + return null + } + + let bestIndex: number | null = null + let bestGap = Number.POSITIVE_INFINITY + for (let index = 0; index < sessionRuns.length; index += 1) { + if (usedSessionRunIndexes.has(index)) { + continue + } + const sessionTime = this.sortableTimeFromRunKey(this.getRunKey(sessionRuns[index])) + if (!Number.isFinite(sessionTime)) { + continue + } + const gap = outputTime - sessionTime + if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { + continue + } + bestIndex = index + bestGap = gap + } + return bestIndex + } + + private mergeHermesOutputAndSessionRuns( + outputRuns: unknown[], + sessionRuns: unknown[] + ): unknown[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRuns = outputRuns.map((outputRun) => { + if (!this.isRecord(outputRun)) { + return outputRun + } + const sessionRunIndex = this.findMatchingSessionRunIndex( + outputRun, + sessionRuns, + usedSessionRunIndexes + ) + if (sessionRunIndex === null) { + return outputRun + } + const sessionRun = sessionRuns[sessionRunIndex] + if (!this.isRecord(sessionRun)) { + return outputRun + } + usedSessionRunIndexes.add(sessionRunIndex) + // Hermes writes the markdown output at completion, while state.db keeps + // the actual turn-by-turn transcript under the cron session start time. + return { + ...outputRun, + output_preview: this.getRunOutputPreview(outputRun) ?? this.getRunOutputPreview(sessionRun), + output_content: this.mergeOutputAndSessionContent( + this.getRunOutputContent(outputRun), + this.getRunOutputContent(sessionRun) + ) + } + }) + return [ + ...mergedOutputRuns, + ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) + ] + } + + private mergeHermesOutputAndSessionRunRefs( + outputRefs: HermesOutputRunRef[], + sessionRefs: HermesSessionRunRef[] + ): HermesMergedRunRef[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRefs = outputRefs.map((outputRef) => { + const sessionRunIndex = this.findMatchingSessionRunIndex( + outputRef, + sessionRefs, + usedSessionRunIndexes + ) + const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] + if (sessionRunIndex !== null) { + usedSessionRunIndexes.add(sessionRunIndex) + } + return { + id: outputRef.id, + job_id: outputRef.job_id, + run_at: outputRef.run_at, + run_key: outputRef.run_key, + output: outputRef, + session: sessionRef + } + }) + return [ + ...mergedOutputRefs, + ...sessionRefs + .filter((_, index) => !usedSessionRunIndexes.has(index)) + .map((sessionRef) => ({ + id: sessionRef.id, + job_id: sessionRef.job_id, + run_at: sessionRef.run_at, + run_key: sessionRef.run_key, + output: null, + session: sessionRef + })) + ] + } + + private getDatabaseConstructor(): DatabaseConstructor | null { + if (databaseConstructor !== undefined) { + return databaseConstructor + } + try { + const loaded = requireOptional('better-sqlite3') as + | DatabaseConstructor + | { default?: DatabaseConstructor } + databaseConstructor = typeof loaded === 'function' ? loaded : (loaded.default ?? null) + } catch { + databaseConstructor = null + } + return databaseConstructor + } + + private async readHermesRunRefs(jobId: string): Promise { + const outputRuns = await this.readHermesOutputFileRunRefs(jobId) + return this.mergeHermesOutputAndSessionRunRefs( + outputRuns, + this.readHermesSessionDbRunRefs(jobId) + ).sort((a, b) => { + const aTime = this.getRawRunTime(a) + const bTime = this.getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return this.getRawRunId(b).localeCompare(this.getRawRunId(a)) + }) + } + + private async hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { + const outputRun = ref.output ? await this.readHermesOutputFileRun(ref.output) : null + const sessionRun = ref.session ? this.readHermesSessionDbRunById(jobId, ref.session.id) : null + return ( + this.mergeHermesOutputAndSessionRuns( + outputRun ? [outputRun] : [], + sessionRun ? [sessionRun] : [] + )[0] ?? + outputRun ?? + sessionRun ?? + ref + ) + } + + private async readHermesRunCount(jobId: string): Promise { + if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { + return 0 + } + const now = Date.now() + const cached = this.hermesRunCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + const entry: HermesRunCountCacheEntry = { + promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), + expiresAt: Number.POSITIVE_INFINITY + } + this.hermesRunCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (this.hermesRunCountCache.get(jobId) === entry) { + this.hermesRunCountCache.delete(jobId) + } + throw error + } + } + + private clearHermesRunCountCache(jobId?: string): void { + if (jobId) { + this.hermesRunCountCache.delete(jobId) + return + } + this.hermesRunCountCache.clear() + } + + private async listRuns(params: Record = {}): Promise<{ + total: number + runs: unknown[] + }> { + const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' + const jobId = params.jobId + const page = + typeof params.page === 'number' && Number.isFinite(params.page) + ? Math.max(1, Math.floor(params.page)) + : 1 + const pageSize = + typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) + ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) + : 25 + if (provider !== 'hermes') { + return { total: 0, runs: [] } + } + if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + if (pageSize === 0) { + // Why: manager listing only needs a badge count; hydrating markdown logs + // and full session transcripts can make opening Automations very slow. + return { total: await this.readHermesRunCount(jobId), runs: [] } + } + const runRefs = await this.readHermesRunRefs(jobId) + const start = (page - 1) * pageSize + return { + total: runRefs.length, + runs: await Promise.all( + runRefs.slice(start, start + pageSize).map((ref) => this.hydrateHermesRunRef(jobId, ref)) + ) + } + } + + private getRawRunId(run: unknown): string { + if (this.isRecord(run) && 'id' in run) { + return String(run.id) + } + return '' + } + + private getRawRunTime(run: unknown): number { + if (!this.isRecord(run) || !('run_at' in run)) { + return Number.NaN + } + return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN + } + + private async readHermesOutputFileRunRefs(jobId: string): Promise { + const outputDir = join(HERMES_OUTPUT_DIR, jobId) + if (!existsSync(outputDir)) { + return [] + } + const entries = await readdir(outputDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) + .map((entry) => ({ + kind: 'output' as const, + id: `${jobId}:${entry.name}`, + job_id: jobId, + run_at: this.runAtFromHermesOutputFile(entry.name), + run_key: this.runKeyFromHermesOutputFile(entry.name), + output_path: join(outputDir, entry.name) + })) + } + + private async readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { + try { + const content = await readFile(ref.output_path, 'utf-8') + const parsed = this.parseHermesOutput(content) + const outputContent = await this.appendReferencedLogFile(parsed.outputContent) + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: parsed.status, + output_preview: parsed.outputPreview, + output_content: outputContent, + error: parsed.error, + output_path: ref.output_path + } + } catch (error) { + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: 'unknown', + output_preview: null, + output_content: null, + error: error instanceof Error ? error.message : String(error), + output_path: ref.output_path + } + } + } + + private readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { + if (!existsSync(HERMES_STATE_DB)) { + return [] + } + const Database = this.getDatabaseConstructor() + if (!Database) { + return [] + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const pattern = `cron\\_${this.escapeSqlLike(jobId)}\\_%` + const rows = db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) as Record[] + return rows.map((row) => { + const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` + return { + kind: 'session', + id: runId, + job_id: jobId, + run_at: this.runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null + } + }) + } finally { + db.close() + } + } catch { + return [] + } + } + + private readHermesSessionDbRunById(jobId: string, runId: string): unknown | null { + if (!existsSync(HERMES_STATE_DB)) { + return null + } + const Database = this.getDatabaseConstructor() + if (!Database) { + return null + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const row = db + .prepare( + `SELECT id, title, started_at, ended_at, end_reason, model, message_count, + input_tokens, output_tokens, estimated_cost_usd + FROM sessions + WHERE id = ?` + ) + .get(runId) as Record | undefined + if (!row) { + return null + } + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(runId) as Record[] + const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null + const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null + const messageCount = typeof row.message_count === 'number' ? row.message_count : null + const tokenCount = + (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + + (typeof row.output_tokens === 'number' ? row.output_tokens : 0) + const summaryParts = [ + title, + model ? `Model: ${model}` : null, + messageCount !== null ? `${messageCount} messages` : null, + tokenCount > 0 ? `${tokenCount} tokens` : null + ].filter(Boolean) + return { + id: runId, + job_id: jobId, + run_at: this.runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null, + status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', + output_preview: summaryParts.join(' · ') || null, + output_content: this.formatSessionMessages(messages), + error: null, + output_path: HERMES_STATE_DB + } + } finally { + db.close() + } + } catch { + return null } - return typeof parsed === 'object' && - parsed !== null && - !Array.isArray(parsed) && - Array.isArray((parsed as { jobs?: unknown }).jobs) - ? (parsed as { jobs: unknown[] }).jobs - : [] } private async listJobs(params?: Record): Promise<{ @@ -97,6 +792,86 @@ export class ExternalAutomationsHandler { } } + private normalizeHermesJobMutation(params: Record): { + name: string + prompt: string + schedule: string + workdir: string + } { + const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' + if (provider !== 'hermes') { + throw new Error('Only Hermes cron creation and editing are supported.') + } + const name = typeof params.name === 'string' ? params.name.trim() : '' + const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' + const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' + const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' + if (!prompt) { + throw new Error('Hermes cron requires a prompt.') + } + if (!schedule) { + throw new Error('Hermes cron requires a schedule.') + } + return { + name: name || prompt.slice(0, 50).trim(), + prompt, + schedule, + workdir + } + } + + private async runHermesCronCommand(args: string[]): Promise { + await execFileAsync('hermes', args, { + encoding: 'utf-8', + timeout: 30_000 + }) + } + + private async createJob(params: Record = {}): Promise<{ ok: true }> { + const input = this.normalizeHermesJobMutation(params) + const args = [ + 'cron', + 'create', + input.schedule, + input.prompt, + '--name', + input.name, + '--deliver', + 'local' + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCountCache() + return { ok: true } + } + + private async updateJob(params: Record = {}): Promise<{ ok: true }> { + const input = this.normalizeHermesJobMutation(params) + const jobId = params.jobId + if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const args = [ + 'cron', + 'edit', + jobId, + '--schedule', + input.schedule, + '--prompt', + input.prompt, + '--name', + input.name + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCountCache(jobId) + return { ok: true } + } + private async runAction(params: Record = {}): Promise<{ ok: true }> { const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' const action = params.action @@ -109,10 +884,13 @@ export class ExternalAutomationsHandler { } const command = provider === 'hermes' ? this.hermesCommand(action) : this.openClawCommand(action) - await execFileAsync('/bin/sh', ['-lc', `${provider} cron ${command} ${jobId}`], { + await execFileAsync(provider, ['cron', command, jobId], { encoding: 'utf-8', timeout: 30_000 }) + if (provider === 'hermes') { + this.clearHermesRunCountCache(jobId) + } return { ok: true } } } diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts new file mode 100644 index 00000000000..96ff993240e --- /dev/null +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -0,0 +1,157 @@ +import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' +import { buildDiffResult, parseBranchDiff, parseBranchDiffNumstat } from './git-handler-utils' + +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + +function assertFullGitObjectId(value: string, label: string): void { + if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) + } +} + +export async function commitCompare(git: GitExec, worktreePath: string, commitId: string) { + assertFullGitObjectId(commitId, 'commitId') + let commitOid = '' + try { + const { stdout } = await git( + ['rev-parse', '--verify', '--end-of-options', `${commitId}^{commit}`], + worktreePath + ) + commitOid = stdout.trim() + } catch { + return { + summary: { + commitOid: '', + parentOid: null, + compareRef: commitId, + baseRef: 'parent', + changedFiles: 0, + status: 'invalid-commit', + errorMessage: `Commit ${commitId} could not be resolved in this repository.` + }, + entries: [] + } + } + + const summary = { + commitOid, + parentOid: null as string | null, + compareRef: commitOid.slice(0, 7), + baseRef: 'empty tree', + changedFiles: 0, + status: 'ready' as const + } + + try { + const { stdout: parentsOut } = await git( + ['rev-list', '--parents', '-n', '1', commitOid], + worktreePath + ) + const [, firstParent] = parentsOut.trim().split(/\s+/) + summary.parentOid = firstParent ?? null + summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + + // Why: root commits have no parent tree; diff-tree --root asks git to + // compare against the repository's empty tree without hardcoding hash format. + const diffArgs = summary.parentOid + ? [ + '-c', + 'core.quotePath=false', + 'diff', + '--name-status', + '-M', + '-C', + summary.parentOid, + commitOid + ] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-M', + '-C', + commitOid + ] + const numstatArgs = summary.parentOid + ? [ + '-c', + 'core.quotePath=false', + 'diff', + '--numstat', + '-M', + '-C', + summary.parentOid, + commitOid + ] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--numstat', + '-r', + '-M', + '-C', + commitOid + ] + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + git(diffArgs, worktreePath), + git(numstatArgs, worktreePath) + ]) + const entries = parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) + summary.changedFiles = entries.length + return { summary, entries } + } catch (error) { + return { + summary: { + ...summary, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' + }, + entries: [] + } + } +} + +export async function commitDiffEntry( + gitBuffer: GitBufferExec, + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + } +) { + assertFullGitObjectId(args.commitOid, 'commitOid') + if (args.parentOid) { + assertFullGitObjectId(args.parentOid, 'parentOid') + } + try { + const oldPath = args.oldPath ?? args.filePath + const left = args.parentOid + ? await readBlobAtOid(gitBuffer, worktreePath, args.parentOid, oldPath) + : { content: '', isBinary: false } + const right = await readBlobAtOid(gitBuffer, worktreePath, args.commitOid, args.filePath) + return buildDiffResult( + left.content, + right.content, + left.isBinary, + right.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index baa3b115deb..f9a89973992 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -60,8 +60,10 @@ export async function getStatusOp( ahead: number behind: number } + ignoredPaths?: string[] }> { const worktreePath = params.worktreePath as string + const includeIgnored = params.includeIgnored === true const conflictOperation = await detectConflictOperation(worktreePath) const entries: Record[] = [] let head: string | undefined @@ -74,28 +76,31 @@ export async function getStatusOp( behind: number } | undefined + let ignoredPaths: string[] = [] try { // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in // git's stdout instead of C-style octal escapes; without it the parsed // entry.path renders as gibberish in the source-control sidebar and // downstream blob lookups miss. - const { stdout } = await git( - [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ], - worktreePath - ) + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + const { stdout } = await git(statusArgs, worktreePath) const parsed = parseStatusOutput(stdout) entries.push(...parsed.entries) head = parsed.head branch = parsed.branch upstreamStatus = parsed.upstreamStatus + ignoredPaths = parsed.ignoredPaths for (const uLine of parsed.unmergedLines) { const entry = parseUnmergedEntry(worktreePath, uLine) @@ -107,5 +112,43 @@ export async function getStatusOp( // not a git repo or git not available } - return { entries, conflictOperation, head, branch, upstreamStatus } + return { + entries, + conflictOperation, + head, + branch, + upstreamStatus, + ...(includeIgnored ? { ignoredPaths } : {}) + } +} + +function parseCheckIgnoreOutput(stdout: string): string[] { + return stdout.split(/\r?\n/).filter(Boolean) +} + +export async function checkIgnoredPathsOp( + git: GitExec, + params: Record +): Promise { + const worktreePath = params.worktreePath as string + const paths = Array.isArray(params.paths) + ? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0) + : [] + if (paths.length === 0) { + return [] + } + + try { + const { stdout } = await git( + ['-c', 'core.quotePath=false', 'check-ignore', '--', ...paths], + worktreePath + ) + return parseCheckIgnoreOutput(stdout) + } catch (error) { + const gitError = error as Error & { code?: number | string; stdout?: string } + if (gitError.code === 1) { + return parseCheckIgnoreOutput(gitError.stdout ?? '') + } + throw error + } } diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 2ab5da3b5e7..ad2b7964960 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -28,4 +28,13 @@ describe('parseStatusOutput', () => { expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) }) + + it('parses ignored porcelain records separately from actionable entries', () => { + const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) + + expect(result.ignoredPaths).toEqual(['dist/', '.env']) + expect(result.entries).toEqual([ + { path: 'scratch.txt', status: 'untracked', area: 'untracked' } + ]) + }) }) diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index a0c72179007..e84e04a170e 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -100,7 +100,52 @@ export function parseUnmergedEntry( /** * Parse `git diff --name-status` output into structured change entries. */ -export function parseBranchDiff(stdout: string): Record[] { +export type BranchDiffLineStats = { + added?: number + removed?: number +} + +function parseNumstatCount(value: string): number | undefined { + if (value === '-') { + return undefined + } + const count = Number.parseInt(value, 10) + return Number.isFinite(count) ? count : undefined +} + +function normalizeNumstatPath(path: string): string { + const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) + if (bracedRename) { + return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` + } + const renameMarker = ' => ' + const markerIndex = path.lastIndexOf(renameMarker) + return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) +} + +export function parseBranchDiffNumstat(stdout: string): Map { + const stats = new Map() + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const parts = line.split('\t') + const rawPath = parts.slice(2).join('\t') + if (!rawPath) { + continue + } + stats.set(normalizeNumstatPath(rawPath), { + added: parseNumstatCount(parts[0] ?? ''), + removed: parseNumstatCount(parts[1] ?? '') + }) + } + return stats +} + +export function parseBranchDiff( + stdout: string, + statsByPath: Map = new Map() +): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { if (!line) { @@ -114,12 +159,12 @@ export function parseBranchDiff(stdout: string): Record[] { const oldPath = parts[1] const filePath = parts[2] if (filePath) { - entries.push({ path: filePath, oldPath, status }) + entries.push({ path: filePath, oldPath, status, ...statsByPath.get(filePath) }) } } else { const filePath = parts[1] if (filePath) { - entries.push({ path: filePath, status }) + entries.push({ path: filePath, status, ...statsByPath.get(filePath) }) } } } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index a4104c449ff..2c1b063362b 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -37,6 +37,8 @@ describe('GitHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') + expect(methods).toContain('git.checkIgnored') + expect(methods).toContain('git.history') expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') expect(methods).toContain('git.stage') @@ -59,6 +61,33 @@ describe('GitHandler', () => { expect(methods).toContain('git.isGitRepo') }) + describe('history', () => { + it('returns bounded git history for a repo', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + gitCommit(tmpDir, 'second') + + const result = (await dispatcher.callRequest('git.history', { + worktreePath: tmpDir, + limit: 10 + })) as { + items: { subject: string; displayId?: string }[] + currentRef?: { category?: string; revision?: string } + hasMore: boolean + limit: number + } + + expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial']) + expect(result.currentRef?.category).toBe('branches') + expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/) + expect(result.items[0]?.displayId).toHaveLength(7) + expect(result.hasMore).toBe(false) + expect(result.limit).toBe(10) + }) + }) + describe('status', () => { it('returns empty entries for clean repo', async () => { gitInit(tmpDir) @@ -92,6 +121,47 @@ describe('GitHandler', () => { expect(untracked!.area).toBe('untracked') }) + it('returns ignored paths only when requested', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') + gitCommit(tmpDir, 'initial') + mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) + writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') + writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') + + const defaultResult = (await dispatcher.callRequest('git.status', { + worktreePath: tmpDir + })) as { + ignoredPaths?: string[] + } + const ignoredResult = (await dispatcher.callRequest('git.status', { + worktreePath: tmpDir, + includeIgnored: true + })) as { + ignoredPaths?: string[] + } + + expect('ignoredPaths' in defaultResult).toBe(false) + expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env'])) + }) + + it('checks ignored status for selected paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') + gitCommit(tmpDir, 'initial') + mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) + writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') + writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') + + const result = (await dispatcher.callRequest('git.checkIgnored', { + worktreePath: tmpDir, + paths: ['dist/bundle.js', 'src/index.ts', '.env'] + })) as string[] + + expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env'])) + expect(result).not.toContain('src/index.ts') + }) + it('detects modified files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index ecc4bd0e9ae..06bc0016509 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -7,17 +7,19 @@ import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' +import { parseBranchDiff, parseBranchDiffNumstat, parseWorktreeList } from './git-handler-utils' import { computeDiff, branchCompare as branchCompareOp, branchDiffEntries, validateGitExecArgs } from './git-handler-ops' +import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' -import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' +import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' +import { loadGitHistoryFromExecutor } from '../shared/git-history' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -35,6 +37,8 @@ export class GitHandler { private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) + this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) @@ -45,11 +49,13 @@ export class GitHandler { this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p)) this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) this.dispatcher.onRequest('git.branchCompare', (p) => this.branchCompare(p)) + this.dispatcher.onRequest('git.commitCompare', (p) => this.commitCompare(p)) this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p)) this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) + this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) @@ -82,6 +88,18 @@ export class GitHandler { return getStatusOp(this.git.bind(this), params) } + private async checkIgnored(params: Record) { + return checkIgnoredPathsOp(this.git.bind(this), params) + } + + private async history(params: Record) { + const worktreePath = params.worktreePath as string + return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, { + limit: typeof params.limit === 'number' ? params.limit : undefined, + baseRef: typeof params.baseRef === 'string' ? params.baseRef : null + }) + } + private async getDiff(params: Record) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string @@ -245,10 +263,20 @@ export class GitHandler { ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) - return parseBranchDiff(stdout) + const { stdout: numstat } = await gitBound( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + worktreePath + ) + return parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) }) } + private async commitCompare(params: Record) { + const worktreePath = params.worktreePath as string + const commitId = params.commitId as string + return commitCompareOp(this.git.bind(this), worktreePath, commitId) + } + private async upstreamStatus(params: Record) { const worktreePath = params.worktreePath as string @@ -362,6 +390,16 @@ export class GitHandler { ) } + private async commitDiff(params: Record) { + const worktreePath = params.worktreePath as string + return commitDiffEntry(this.gitBuffer.bind(this), worktreePath, { + commitOid: params.commitOid as string, + parentOid: params.parentOid as string | null | undefined, + filePath: params.filePath as string, + oldPath: params.oldPath as string | undefined + }) + } + private async exec(params: Record) { const args = params.args as string[] const cwd = params.cwd as string diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts index 1299943401a..78f66e2129a 100644 --- a/src/relay/git-status-output-parser.ts +++ b/src/relay/git-status-output-parser.ts @@ -22,6 +22,7 @@ export function parseStatusChar(char: string): string { export function parseStatusOutput(stdout: string): { entries: Record[] unmergedLines: string[] + ignoredPaths: string[] head?: string branch?: string upstreamStatus: { @@ -33,6 +34,7 @@ export function parseStatusOutput(stdout: string): { } { const entries: Record[] = [] const unmergedLines: string[] = [] + const ignoredPaths: string[] = [] let head: string | undefined let branch: string | undefined let upstreamName: string | undefined @@ -108,6 +110,8 @@ export function parseStatusOutput(stdout: string): { } } else if (line.startsWith('? ')) { entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) + } else if (line.startsWith('! ')) { + ignoredPaths.push(line.slice(2)) } else if (line.startsWith('u ')) { unmergedLines.push(line) } @@ -116,6 +120,7 @@ export function parseStatusOutput(stdout: string): { return { entries, unmergedLines, + ignoredPaths, head, branch, upstreamStatus: upstreamName diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 83fa72b75bd..422dd9538c7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -47,6 +47,7 @@ import { StarNagCard } from './components/StarNagCard' import { FeatureTourNudge } from './components/feature-wall/FeatureTourNudge' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' +import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog' @@ -56,10 +57,16 @@ import { } from './components/floating-terminal/FloatingTerminalPanel' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { DictationController } from './components/dictation/DictationController' +import { CrashReportDialog } from './components/crash-report/CrashReportDialog' +import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' +import { + resolvePrimarySelectionMiddleClickPaste, + usePrimarySelectionPaste +} from './hooks/usePrimarySelectionPaste' import { getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual, @@ -80,10 +87,12 @@ import { } from './lib/startup-ui-hydration' import { applyDocumentTheme } from './lib/document-theme' import { isEditableTarget } from './lib/editable-target' +import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { canGoBackWorktreeHistory, canGoForwardWorktreeHistory } from '@/store/slices/worktree-nav-history' +import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor' import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' import type { OnboardingState } from '../../shared/types' @@ -161,10 +170,14 @@ const TaskPage = lazy(() => import('./components/TaskPage')) const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('./components/settings/Settings')) +const SkillsPage = lazy(() => import('./components/skills/SkillsPage')) const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) +const WorkspaceCleanupDialog = lazy( + () => import('./components/workspace-cleanup/WorkspaceCleanupDialog') +) const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal')) // Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless // the user opts into the experimental flag. @@ -210,13 +223,14 @@ function App(): React.JSX.Element { // Why: Zustand actions are referentially stable, but each individual // useAppStore(s => s.someAction) still registers a subscription that React - // must check on every store mutation. Consolidating 19 action refs into one - // useShallow subscription means one equality check instead of 19. + // must check on every store mutation. Consolidating action refs into one + // useShallow subscription means one equality check instead of many. const actions = useAppStore( useShallow((s) => ({ toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, initGitHubCache: s.initGitHubCache, refreshAllGitHub: s.refreshAllGitHub, @@ -235,6 +249,7 @@ function App(): React.JSX.Element { toggleRightSidebar: s.toggleRightSidebar, setRightSidebarOpen: s.setRightSidebarOpen, setRightSidebarTab: s.setRightSidebarTab, + seedFileSearchQuery: s.seedFileSearchQuery, setActiveView: s.setActiveView, updateSettings: s.updateSettings, pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, @@ -245,6 +260,11 @@ function App(): React.JSX.Element { const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + // Why: App swaps the sidebar between workspace and landing layouts when the + // active workspace is slept/deleted. Keep virtualized scroll memory above + // that remount so the left workspace list doesn't restart at scrollTop 0. + const worktreeSidebarScrollOffsetRef = useRef(0) + const worktreeSidebarScrollAnchorRef = useRef(null) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const activeTabId = useAppStore((s) => s.activeTabId) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) @@ -254,6 +274,10 @@ function App(): React.JSX.Element { const floatingTerminalTriggerLocation = useAppStore( (s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button' ) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) + const showFloatingTerminalButton = + floatingTerminalEnabled && + (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) // Why: the floating terminal is a transient overlay; hotkey minimize should // return keyboard focus to the surface the user was working in before it. const floatingTerminalReturnFocusRef = useRef(null) @@ -327,6 +351,10 @@ function App(): React.JSX.Element { const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) + const primarySelectionMiddleClickPaste = resolvePrimarySelectionMiddleClickPaste( + settings?.primarySelectionMiddleClickPaste + ) + usePrimarySelectionPaste(primarySelectionMiddleClickPaste) const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const petVisible = useAppStore((s) => s.petVisible) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) @@ -362,6 +390,10 @@ function App(): React.JSX.Element { useGlobalFileDrop() useAutoAckViewedAgent() + useEffect(() => { + return onOnboardingReopened(setOnboarding) + }, []) + // Why: sidebar open/close flips width instantaneously. useLayoutEffect // runs synchronously after React commits the DOM but before paint, so // dispatching SYNC_FIT_PANES_EVENT here lets the terminal reflow in the @@ -405,6 +437,7 @@ function App(): React.JSX.Element { await actions.fetchSettings() await actions.fetchRepos() await actions.fetchAllWorktrees() + await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() uiHydrated = hydratePersistedUIAfterStartupRead({ persistedUI, @@ -862,7 +895,10 @@ function App(): React.JSX.Element { // Why: Activity and Space are full-page navigation surfaces — same // treatment as Settings — so the worktree sidebar is removed for those views. const showSidebar = - activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space' + activeView !== 'settings' && + activeView !== 'activity' && + activeView !== 'space' && + activeView !== 'skills' // Why: only the terminal workspace replaces the full-width titlebar with // split-column chrome. Full-page navigation views keep the draggable app // titlebar so their page-level controls can live in that window strip. @@ -874,7 +910,8 @@ function App(): React.JSX.Element { activeView !== 'tasks' && activeView !== 'activity' && activeView !== 'automations' && - activeView !== 'space' + activeView !== 'space' && + activeView !== 'skills' const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -907,6 +944,31 @@ function App(): React.JSX.Element { // browser guest has focus. The renderer keeps matching handlers for // local-focus cases and to preserve the same guards in one place. + const isSearchShortcut = e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f' + const canRevealRightSidebar = + activeView !== 'tasks' && + activeView !== 'activity' && + activeView !== 'automations' && + activeView !== 'space' && + activeView !== 'skills' + + const openSearchSidebar = (query: string | null): void => { + if (query && activeWorktreeId) { + actions.seedFileSearchQuery(activeWorktreeId, query) + } + actions.setRightSidebarTab('search') + actions.setRightSidebarOpen(true) + } + + if (mod && isSearchShortcut && canRevealRightSidebar) { + const selectedText = getSelectedTextForFileSearch() + if (selectedText) { + e.preventDefault() + openSearchSidebar(selectedText) + return + } + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out @@ -961,12 +1023,7 @@ function App(): React.JSX.Element { // Why: full-page navigation surfaces should not reveal the right sidebar; // they are designed as distraction-free content areas. - if ( - activeView === 'tasks' || - activeView === 'activity' || - activeView === 'automations' || - activeView === 'space' - ) { + if (!canRevealRightSidebar) { return } @@ -986,10 +1043,9 @@ function App(): React.JSX.Element { } // Cmd/Ctrl+Shift+F — toggle right sidebar / search tab - if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') { + if (isSearchShortcut) { e.preventDefault() - actions.setRightSidebarTab('search') - actions.setRightSidebarOpen(true) + openSearchSidebar(null) return } @@ -1045,6 +1101,7 @@ function App(): React.JSX.Element { activeModal !== 'quick-open' && activeModal !== 'worktree-palette' && activeModal !== 'new-workspace-composer' && + activeModal !== 'workspace-cleanup' && activeModal !== 'feature-wall' ) { return @@ -1071,7 +1128,14 @@ function App(): React.JSX.Element { // `titlebar-left`. Measuring only the inner control cluster left the // back/forward arrows hanging over the first tab when the sidebar was // collapsed (Cmd+B), producing a half-occluded, non-scrollable tab strip. -
+ // Why: collapsed workspace mode floats inside a w-0 sidebar wrapper; w-max + // prevents Windows Chromium from shrinking the app name down to one glyph. +
{isMac && !isFullScreen ? (
@@ -1100,13 +1164,13 @@ function App(): React.JSX.Element { ) : (
)} - {showSidebar && ( + {showSidebar && !isWindows && ( <> {settings?.showTitlebarAppName !== false && (
- Orca + Orca
@@ -1145,7 +1209,9 @@ function App(): React.JSX.Element { Activity since that page owns its own back-out via the Close button in ActivityTitlebarControls. */} {(activeView === 'terminal' || activeView === 'tasks') && ( -
+ // Why: when the workspace sidebar is collapsed, this header shrink-wraps + // and ml-auto has no spare width; keep a fixed gutter before Back. +
) : ( - + ) ) : null}
@@ -1370,6 +1449,7 @@ function App(): React.JSX.Element {
{activeView === 'settings' ? : null} + {activeView === 'skills' ? : null} {activeView === 'tasks' ? : null} {activeView === 'automations' ? : null} {activeView === 'activity' ? : null} @@ -1377,6 +1457,15 @@ function App(): React.JSX.Element { {activeView === 'terminal' && !activeWorktreeId ? : null}
+ {showFloatingTerminalButton ? ( + setFloatingTerminalOpenWithFocus((open) => !open)} + /> + ) : null}
@@ -1388,62 +1477,55 @@ function App(): React.JSX.Element { {showRightSidebarControls ? : null}
{floatingTerminalEnabled ? ( - <> - - {floatingTerminalTriggerLocation === 'floating-button' ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null} - + ) : null} - {/* Why: NewWorkspaceComposerCard renders Radix s that crash - when mounted outside a TooltipProvider ancestor. Keep the global - composer modal inside this provider so the card renders safely - whether triggered from Cmd+J or any future entry point. */} + {/* Why: root overlays can render Radix s; keep them inside + the shared provider so lazy surfaces mount safely from any entry point. */} {mountedLazyModalIds.has('new-workspace-composer') ? : null} + {mountedLazyModalIds.has('workspace-cleanup') ? : null} - - - {mountedLazyModalIds.has('quick-open') ? : null} - {mountedLazyModalIds.has('worktree-palette') ? : null} - {mountedLazyModalIds.has('feature-wall') ? : null} - - {/* Why: mount PetOverlay only when the experimental flag is on AND + + {mountedLazyModalIds.has('quick-open') ? : null} + {mountedLazyModalIds.has('worktree-palette') ? : null} + {mountedLazyModalIds.has('feature-wall') ? : null} + + {/* Why: mount PetOverlay only when the experimental flag is on AND the user hasn't hit "Hide pet" in the status-bar menu. Both conditions must be true — see design doc (pet-overlay.md) on why the two toggles are kept independent. */} - {petEnabled && petVisible ? ( - - - - ) : null} - - - - {/* Why: the existing-user opt-in banner mounts at App root so it + {petEnabled && petVisible ? ( + + + + ) : null} + + + + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates internally on the cohort markers populated by the migration, so it only shows for users who installed before the telemetry release and have not yet resolved consent. New users get no first-launch surface — see telemetry-plan.md §First-launch experience. */} - - - - - {onboarding && shouldShowOnboarding(onboarding) ? ( - - - - ) : null} - + + + + + + {onboarding && shouldShowOnboarding(onboarding) ? ( + + + + ) : null} + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 08df8050141..b0492fd750b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -57,6 +57,14 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-git-graph-ref: var(--git-graph-ref); + --color-git-graph-remote-ref: var(--git-graph-remote-ref); + --color-git-graph-base-ref: var(--git-graph-base-ref); + --color-git-graph-lane-1: var(--git-graph-lane-1); + --color-git-graph-lane-2: var(--git-graph-lane-2); + --color-git-graph-lane-3: var(--git-graph-lane-3); + --color-git-graph-lane-4: var(--git-graph-lane-4); + --color-git-graph-lane-5: var(--git-graph-lane-5); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -114,6 +122,15 @@ --git-decoration-renamed: #007acc; --git-decoration-untracked: #007100; --git-decoration-copied: #007acc; + --git-decoration-ignored: #8c8c8c; + --git-graph-ref: #007acc; + --git-graph-remote-ref: #b66dff; + --git-graph-base-ref: #ea5c00; + --git-graph-lane-1: #ffb000; + --git-graph-lane-2: #dc267f; + --git-graph-lane-3: #994f00; + --git-graph-lane-4: #40b0a6; + --git-graph-lane-5: #b66dff; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -158,6 +175,15 @@ --git-decoration-renamed: #73c991; --git-decoration-untracked: #73c991; --git-decoration-copied: #73c991; + --git-decoration-ignored: #6e6e6e; + --git-graph-ref: #3794ff; + --git-graph-remote-ref: #b66dff; + --git-graph-base-ref: #ea5c00; + --git-graph-lane-1: #ffb000; + --git-graph-lane-2: #dc267f; + --git-graph-lane-3: #ce9178; + --git-graph-lane-4: #40b0a6; + --git-graph-lane-5: #b66dff; } /* ── Base Layer ──────────────────────────────────────── */ @@ -241,7 +267,7 @@ /* Keep the sidebar gutter reserved; only the thumb fades in on hover. */ .worktree-sidebar-scrollbar { /* Why: the scrollbar itself owns the gutter; padding is only the card-to-gutter gap. */ - padding-right: 9px; + padding-right: 6px; scrollbar-gutter: stable; scrollbar-color: transparent transparent; } @@ -551,7 +577,9 @@ -webkit-app-region: no-drag; display: inline-flex; align-items: center; + gap: 4px; height: 24px; + max-width: min(280px, 32vw); margin-left: 2px; margin-right: 4px; padding: 0 6px; @@ -561,9 +589,14 @@ font-weight: 600; line-height: 24px; white-space: nowrap; + overflow: hidden; cursor: pointer; } +.titlebar-app-name-main { + flex: 0 0 auto; +} + .titlebar-icon-button { -webkit-app-region: no-drag; background: none; @@ -772,6 +805,88 @@ } } +/* Why: board-origin cards use pointer drag so Shift+scroll can keep firing; + these replace the native drag cursor and ghost affordance. */ +[data-workspace-board-pointer-draggable='true'] { + cursor: grab; +} + +[data-workspace-board-card-pointer-dragging='true'] { + outline: 1px solid var(--sidebar-ring); + outline-offset: 1px; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 36%, transparent); +} + +[data-workspace-board-card-pointer-dragging='true'], +[data-workspace-board-card-pointer-dragging='true'] *, +:root[data-workspace-board-pointer-dragging] * { + cursor: grabbing !important; +} + +[data-workspace-board-card-drag-preview='true'] { + z-index: 2147483647; + overflow: visible; + border-radius: var(--radius); + outline: 1px solid var(--sidebar-ring); + outline-offset: 1px; + box-shadow: 0 10px 24px color-mix(in srgb, var(--foreground) 16%, transparent); + opacity: 0.96; + will-change: transform; +} + +[data-workspace-board-card-drag-preview='true'][data-workspace-board-card-drag-stack='true'] { + isolation: isolate; +} + +[data-workspace-board-card-drag-card='true'] { + width: 100%; + height: 100%; +} + +[data-workspace-board-card-drag-preview='true'][data-workspace-board-card-drag-stack='true']::before, +[data-workspace-board-card-drag-preview='true'][data-workspace-board-card-drag-stack='true']::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: var(--radius); + border: 1px solid color-mix(in srgb, var(--sidebar-ring) 44%, transparent); + background: var(--sidebar); +} + +[data-workspace-board-card-drag-preview='true'][data-workspace-board-card-drag-stack='true']::before { + transform: translate(4px, 4px); + opacity: 0.72; +} + +[data-workspace-board-card-drag-preview='true'][data-workspace-board-card-drag-stack='true']::after { + transform: translate(8px, 8px); + opacity: 0.44; +} + +[data-workspace-board-card-drag-preview='true'] * { + cursor: grabbing !important; +} + +[data-workspace-board-card-drag-count='true'] { + position: absolute; + top: -8px; + right: -8px; + z-index: 2; + display: flex; + min-width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border-radius: 9999px; + border: 1px solid var(--sidebar-border); + background: var(--foreground); + color: var(--background); + font-size: 10px; + font-weight: 600; + line-height: 1; +} + .sidebar-header { padding: 12px 16px 8px; font-size: 11px; @@ -930,6 +1045,51 @@ text-underline-offset: 2px; } +/* ── Git conflict decorations ───────────────────────────────────── */ + +.monaco-editor .orca-conflict-marker-line { + background: color-mix(in srgb, var(--destructive) 12%, transparent); +} + +.monaco-editor .orca-conflict-section-line { + border-left: 3px solid transparent; +} + +.monaco-editor .orca-conflict-current-line { + background: color-mix(in srgb, var(--git-decoration-modified) 12%, transparent); + border-left-color: color-mix(in srgb, var(--git-decoration-modified) 72%, transparent); +} + +.monaco-editor .orca-conflict-base-line { + background: color-mix(in srgb, var(--muted-foreground) 7%, transparent); + border-left-color: color-mix(in srgb, var(--muted-foreground) 36%, transparent); +} + +.monaco-editor .orca-conflict-incoming-line { + background: color-mix(in srgb, var(--git-decoration-renamed) 11%, transparent); + border-left-color: color-mix(in srgb, var(--git-decoration-renamed) 68%, transparent); +} + +.monaco-editor .orca-conflict-line-decoration { + width: 4px !important; + margin-left: 3px; + background: var(--destructive); + border-radius: 999px; +} + +.monaco-editor .orca-conflict-margin { + background: color-mix(in srgb, var(--destructive) 8%, transparent); +} + +.monaco-editor .orca-conflict-marker-label { + color: var(--destructive); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 11px; + font-weight: 600; + font-style: normal; + opacity: 0.92; +} + /* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.) render inside a `.context-view` wrapper → `.workbench-hover-container` → `.workbench-hover.compact`, positioned above the target button. The wrapper's @@ -1011,23 +1171,26 @@ align-items: center; justify-content: center; padding: 0; - border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border: 1px solid color-mix(in srgb, var(--foreground) 22%, var(--border)); border-radius: 4px; - background: var(--background); - color: var(--muted-foreground); + background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface)); + color: color-mix(in srgb, var(--foreground) 78%, var(--muted-foreground)); cursor: pointer; z-index: 5; - opacity: 0.7; + opacity: 1; + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent); transition: - opacity 100ms ease, color 100ms ease, - background-color 100ms ease; + background-color 100ms ease, + border-color 100ms ease, + box-shadow 100ms ease; } .orca-diff-comment-add-btn:hover { - opacity: 1; color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border)); background: color-mix(in srgb, var(--primary) 12%, var(--background)); + box-shadow: 0 1px 4px color-mix(in srgb, var(--foreground) 16%, transparent); } .orca-diff-comment-add-btn:focus-visible { @@ -1045,10 +1208,9 @@ .orca-diff-comment-inline { width: 100%; - /* Why: match the popover's horizontal inset so the saved card lines up with - the new-comment popover that preceded it. Both anchor at `left: 56px` and - cap at 420px wide. */ - padding: 4px 24px 6px 56px; + /* Why: Monaco's view-zone node already starts after the diff gutter. Avoid + adding a second left inset so saved notes sit flush with the code column. */ + padding: 4px 24px 6px 0; box-sizing: border-box; } diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 338f1293ab4..b4fabf0aa11 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -593,19 +593,6 @@ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); } -.rich-markdown-slash-query { - display: flex; - min-height: 32px; - align-items: center; - gap: 1px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 64%, transparent); - padding: 5px 12px; - color: var(--popover-foreground); - font-size: 13px; - font-weight: 500; - line-height: 20px; -} - .rich-markdown-slash-section { padding: 9px 12px 4px; color: var(--muted-foreground); diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index d0b32989339..05931b02b59 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -128,6 +128,37 @@ opacity: 0.4 !important; } +.pane.pane-focus-rim-flash::before { + content: ''; + position: absolute; + inset: 1px; + z-index: 30; + pointer-events: none; + border: 1px solid color-mix(in srgb, var(--ring) 82%, transparent); + border-radius: 3px; + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--ring) 44%, transparent), + 0 0 0 2px color-mix(in srgb, var(--ring) 28%, transparent), + 0 0 18px color-mix(in srgb, var(--ring) 28%, transparent); + animation: pane-focus-rim-flash 1.5s ease-out forwards; +} + +@keyframes pane-focus-rim-flash { + 0%, + 72% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .pane.pane-focus-rim-flash::before { + animation: none; + } +} + /* Suppress pointer events on terminals during pane drag so overlay works */ .is-pane-dragging .pane { pointer-events: none; diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 4db3dd8393c..9f1b3f6d172 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -80,6 +80,7 @@ import type { GitHubOwnerRepo, GitHubPRFile, GitHubPRFileContents, + GitHubPRFileViewedState, GitHubWorkItem, GitHubWorkItemDetails, GitHubAssignableUser, @@ -355,6 +356,16 @@ function fileStatusLabel(status: GitHubPRFile['status']): string { } } +function isPRFileViewed(file: GitHubPRFile): boolean { + return file.viewerViewedState === 'VIEWED' +} + +// Why: GitHub's viewed toggle auto-collapses the file; keying rows by the +// viewed state resets the row's local expanded/content state without an Effect. +function getPRFileRowKey(file: GitHubPRFile): string { + return `${file.path}:${file.viewerViewedState ?? 'UNVIEWED'}` +} + function findNearestBraceBlock( lines: string[], targetLine: number @@ -400,6 +411,9 @@ type FileRowProps = { prNumber: number headSha: string | undefined baseSha: string | undefined + viewed: boolean + viewedPending: boolean + onViewedChange: (path: string, viewed: boolean) => Promise } type DiffViewMode = 'flat' | 'tree' @@ -414,7 +428,9 @@ type DiffTreeNodeProps = { prNumber: number headSha: string | undefined baseSha: string | undefined + pendingViewedPaths: ReadonlySet onCommentAdded: (comment: PRComment) => void + onViewedChange: (path: string, viewed: boolean) => Promise } function PRDiffTreeNode({ @@ -425,7 +441,9 @@ function PRDiffTreeNode({ prNumber, headSha, baseSha, - onCommentAdded + pendingViewedPaths, + onCommentAdded, + onViewedChange }: DiffTreeNodeProps): React.JSX.Element { const [open, setOpen] = useState(true) @@ -438,7 +456,10 @@ function PRDiffTreeNode({ prNumber={prNumber} headSha={headSha} baseSha={baseSha} + viewed={isPRFileViewed(node.file)} + viewedPending={pendingViewedPaths.has(node.file.path)} onCommentAdded={onCommentAdded} + onViewedChange={onViewedChange} // Why: tree-view file rows are indented by a CSS left-padding proportional // to depth so the expand chevron of PRFileRow stays at position 0 while // the folder hierarchy is communicated purely through indentation. @@ -477,7 +498,7 @@ function PRDiffTreeNode({
{node.children.map((child) => ( ))}
@@ -501,7 +524,9 @@ type PRDiffTreeViewProps = { prNumber: number headSha: string | undefined baseSha: string | undefined + pendingViewedPaths: ReadonlySet onCommentAdded: (comment: PRComment) => void + onViewedChange: (path: string, viewed: boolean) => Promise } function PRDiffTreeView({ @@ -511,14 +536,16 @@ function PRDiffTreeView({ prNumber, headSha, baseSha, - onCommentAdded + pendingViewedPaths, + onCommentAdded, + onViewedChange }: PRDiffTreeViewProps): React.JSX.Element { const tree = useMemo(() => buildDiffTree(files), [files]) return (
{tree.map((node) => ( ))}
@@ -638,6 +667,35 @@ function invalidateWorkItemDetailsCacheByMatch(args: { } } +function patchCachedPRFileViewedState( + cacheKey: string, + path: string, + viewerViewedState: GitHubPRFileViewedState +): GitHubPRFileViewedState | undefined { + const prev = workItemDetailsCache.get(cacheKey) + const files = prev?.details?.files + if (!prev?.details || !files) { + return undefined + } + let previousState: GitHubPRFileViewedState | undefined + const nextFiles = files.map((file) => { + if (file.path !== path) { + return file + } + previousState = file.viewerViewedState ?? 'UNVIEWED' + return { ...file, viewerViewedState } + }) + if (previousState === undefined || previousState === viewerViewedState) { + return previousState + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, files: nextFiles }, + error: undefined + }) + return previousState +} + // Why: install once at module load — every dialog instance shares the cache, // so a single subscription is enough. The preload bridge re-emits the // main-process broadcast for every window, so each renderer invalidates its @@ -798,6 +856,24 @@ function addPRReviewCommentReplyForRepo(args: { }) } +function setPRFileViewedForRepo(args: { + repoId?: string + repoPath: string + prNumber: number + pullRequestId: string + path: string + viewed: boolean +}): Promise { + return window.api.gh.setPRFileViewed({ + repoPath: args.repoPath, + repoId: args.repoId, + prNumber: args.prNumber, + pullRequestId: args.pullRequestId, + path: args.path, + viewed: args.viewed + }) +} + function getWorkItemDetailsForRepo(args: { repoId?: string repoPath: string @@ -812,6 +888,60 @@ function getWorkItemDetailsForRepo(args: { }) } +function PRViewedCheckbox({ + checked, + pending, + filePath, + onToggle +}: { + checked: boolean + pending: boolean + filePath: string + onToggle: () => void +}): React.JSX.Element { + return ( + + + + + + {checked ? 'Unmark viewed' : 'Mark viewed'} + + + ) +} + function PRFileRow({ file, repoPath, @@ -819,6 +949,9 @@ function PRFileRow({ prNumber, headSha, baseSha, + viewed, + viewedPending, + onViewedChange, onCommentAdded, indentDepth = 0, label @@ -899,76 +1032,99 @@ function PRFileRow({ [file.path, headSha, onCommentAdded, prNumber, repoId, repoPath] ) + const handleViewedToggle = useCallback(async () => { + if (viewedPending) { + return + } + await onViewedChange(file.path, !viewed) + }, [file.path, onViewedChange, viewed, viewedPending]) + return ( -
- +{file.additions} / −{file.deletions} - + +
{expanded && ( // Why: DiffViewer's inner layout uses flex-1/min-h-0, so this wrapper @@ -2845,6 +3001,8 @@ export default function GitHubItemDialog({ const comments = details?.comments ?? [] const files = details?.files ?? [] const checks = details?.checks ?? [] + const viewedFileCount = files.filter(isPRFileViewed).length + const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) useEffect(() => { setLinkCopied(false) @@ -2907,6 +3065,45 @@ export default function GitHubItemDialog({ [detailsCacheKey] ) + const handlePRFileViewedChange = useCallback( + async (path: string, viewed: boolean): Promise => { + if (!repoPath || !details?.pullRequestId || !workItem || workItem.type !== 'pr') { + toast.error('Unable to sync viewed state for this pull request.') + return false + } + setPendingViewedPaths((prev) => new Set(prev).add(path)) + const nextState: GitHubPRFileViewedState = viewed ? 'VIEWED' : 'UNVIEWED' + const previousState = detailsCacheKey + ? patchCachedPRFileViewedState(detailsCacheKey, path, nextState) + : undefined + try { + const ok = await setPRFileViewedForRepo({ + repoId: workItem.repoId, + repoPath, + prNumber: workItem.number, + pullRequestId: details.pullRequestId, + path, + viewed + }) + if (!ok) { + if (detailsCacheKey && previousState) { + patchCachedPRFileViewedState(detailsCacheKey, path, previousState) + } + toast.error('Failed to sync viewed state with GitHub.') + return false + } + return true + } finally { + setPendingViewedPaths((prev) => { + const next = new Set(prev) + next.delete(path) + return next + }) + } + }, + [details?.pullRequestId, detailsCacheKey, repoPath, workItem] + ) + return ( !open && onClose()}> {/* Files-tab toolbar: view-mode toggle */} -
- - - - - - Flat view - - - - - - - - Tree view - - +
+ + {viewedFileCount} / {files.length} files viewed + +
+ + + + + + Flat view + + + + + + + + Tree view + + +
{diffViewMode === 'flat' ? ( files.map((file) => ( )) ) : ( @@ -3175,7 +3380,9 @@ export default function GitHubItemDialog({ prNumber={workItem.number} headSha={details?.headSha} baseSha={details?.baseSha} + pendingViewedPaths={pendingViewedPaths} onCommentAdded={appendOptimisticComment} + onViewedChange={handlePRFileViewedChange} /> )}
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6c8ba28f167..558e9108eaf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -8,6 +8,7 @@ import { CornerDownLeft, FolderPlus, LoaderCircle, + PlugZap, Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -28,6 +29,8 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' +import type { SshConnectionStatus } from '../../../shared/ssh-types' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -46,7 +49,7 @@ type NewWorkspaceComposerCardProps = { onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartGitLabItemSelect: (item: GitLabWorkItem) => void - onSmartBranchSelect: (refName: string) => void + onSmartBranchSelect: (refName: string, localBranchName: string) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void @@ -65,13 +68,29 @@ type NewWorkspaceComposerCardProps = { onSetupDecisionChange: (value: 'run' | 'skip') => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null - createError: string | null + createError: WorkspaceCreateErrorDisplay | null + selectedRepoConnectionId: string | null + selectedRepoSshStatus: SshConnectionStatus | null + selectedRepoRequiresConnection: boolean + selectedRepoConnectInProgress: boolean + onConnectSelectedRepo: () => Promise canUseSparseCheckout: boolean sparsePresets: SparsePreset[] sparseSelectedPresetId: string | null onSparseSelectPreset: (preset: SparsePreset | null) => void } +const SSH_STATUS_LABELS: Record = { + disconnected: 'SSH not connected', + connecting: 'Connecting SSH...', + 'auth-failed': 'SSH authentication failed', + 'deploying-relay': 'Preparing SSH connection...', + connected: 'Connected', + reconnecting: 'Reconnecting SSH...', + 'reconnection-failed': 'SSH reconnection failed', + error: 'SSH connection error' +} + function SetupCommandPreview({ setupConfig, headerAction @@ -212,6 +231,11 @@ export default function NewWorkspaceComposerCard({ shouldWaitForSetupCheck, resolvedSetupDecision, createError, + selectedRepoConnectionId, + selectedRepoSshStatus, + selectedRepoRequiresConnection, + selectedRepoConnectInProgress, + onConnectSelectedRepo, canUseSparseCheckout, sparsePresets, sparseSelectedPresetId, @@ -221,6 +245,17 @@ export default function NewWorkspaceComposerCard({ const openModal = useAppStore((s) => s.openModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const updateSettings = useAppStore((s) => s.updateSettings) + const selectedRepoName = React.useMemo(() => { + const repo = eligibleRepos.find((candidate) => candidate.id === repoId) + return repo?.displayName ?? repo?.path ?? 'This repository' + }, [eligibleRepos, repoId]) + const sshStatusLabel = selectedRepoSshStatus + ? SSH_STATUS_LABELS[selectedRepoSshStatus] + : 'Not connected' + const connectButtonLabel = + selectedRepoSshStatus === 'disconnected' || selectedRepoSshStatus === null + ? 'Connect' + : 'Reconnect' const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -268,7 +303,7 @@ export default function NewWorkspaceComposerCard({
- + +
+ ) : null}
@@ -322,6 +386,8 @@ export default function NewWorkspaceComposerCard({ onLinearIssueSelect={onSmartLinearIssueSelect} selectedSource={smartNameSelection} onClearSelectedSource={onClearSmartNameSelection} + disabled={selectedRepoRequiresConnection} + disabledPlaceholder="Connect this repo first" onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user @@ -549,8 +615,19 @@ export default function NewWorkspaceComposerCard({
{createError ? ( -
- {createError} +
+ {createError.help ? ( +
+

{createError.title}

+

{createError.message}

+

{createError.help}

+
+ ) : ( + createError.message + )}
) : null} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 9db64d763cd..5c78a7c6e22 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -10,7 +10,11 @@ import { shouldAllowComposerEnterSubmitTarget, shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import type { TuiAgent, WorkspaceCreateTelemetrySource } from '../../../shared/types' +import type { + TuiAgent, + WorkspaceCreateTelemetrySource, + WorkspaceStatus +} from '../../../shared/types' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -19,6 +23,7 @@ type ComposerModalData = { initialRepoId?: string linkedWorkItem?: LinkedWorkItemSummary | null initialBaseBranch?: string + initialWorkspaceStatus?: WorkspaceStatus /** Telemetry surface that opened the composer. Set by each * `openModal('new-workspace-composer', ...)` site so * `workspace_created.source` carries the right value. Falls back to @@ -109,10 +114,13 @@ function QuickTabBody({ initialPrompt: '', initialLinkedWorkItem: modalData.linkedWorkItem ?? null, initialRepoId: modalData.initialRepoId, + initialWorkspaceStatus: modalData.initialWorkspaceStatus, ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, onCreated: onClose, - ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}) + ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}), + enableIssueAutomation: false, + createGateMode: 'quick' }) // Why: the composer's built-in `onOpenAgentSettings` handler navigates to // the settings page and closes the modal. For the quick-create flow we want diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 53d0e646c36..be5719b9a27 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,11 +1,12 @@ /* oxlint-disable max-lines */ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Check, Copy, File } from 'lucide-react' +import { AlertTriangle, Check, Copy } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { getFileTypeIcon } from '@/lib/file-type-icons' import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { CommandDialog, @@ -379,6 +380,7 @@ export default function QuickOpen(): React.JSX.Element | null { const lastSlash = item.path.lastIndexOf('/') const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : '' const filename = item.path.slice(lastSlash + 1) + const FileIcon = getFileTypeIcon(item.path) return ( handleSelect(item.path)} className="flex items-center gap-2 px-3 py-1.5" > - + {filename} {dir && {dir}} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index a5b260ae00f..945f2ef8566 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -57,7 +57,7 @@ import TeamMultiCombobox from '@/components/ui/team-multi-combobox' import RepoDotLabel from '@/components/repo/RepoDotLabel' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' -import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill' +import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' import { stripRepoQualifiers } from '../../../shared/task-query' import GitHubItemDialog from '@/components/GitHubItemDialog' import GitLabItemDialog from '@/components/GitLabItemDialog' @@ -87,12 +87,17 @@ import type { LinearIssue, LinearTeam, Repo, + TaskProvider, TaskViewPresetId } from '../../../shared/types' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' import { linearCreateIssue, linearGetIssue, linearListTeams } from '@/runtime/runtime-linear-client' +import { + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider +} from '../../../shared/task-providers' -type TaskSource = 'github' | 'linear' | 'gitlab' +type TaskSource = TaskProvider type GitLabTaskFilter = 'opened' | 'merged' | 'closed' | 'all' @@ -604,6 +609,14 @@ export default function TaskPage(): React.JSX.Element { linearStatus.activeWorkspaceId ?? linearWorkspaces[0]?.id ?? null + const visibleTaskProviders = useMemo( + () => normalizeVisibleTaskProviders(settings?.visibleTaskProviders), + [settings?.visibleTaskProviders] + ) + const visibleSourceOptions = useMemo( + () => SOURCE_OPTIONS.filter((source) => visibleTaskProviders.includes(source.id)), + [visibleTaskProviders] + ) // Why: seed the preset + query from the user's saved default synchronously // so the first fetch effect issues exactly one request keyed to the final @@ -614,7 +627,9 @@ export default function TaskPage(): React.JSX.Element { const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset) const defaultTaskSource = settings?.defaultTaskSource ?? 'github' - const [taskSource, setTaskSource] = useState(pageData.taskSource ?? defaultTaskSource) + const [taskSource, setTaskSource] = useState( + resolveVisibleTaskProvider(pageData.taskSource ?? defaultTaskSource, visibleTaskProviders) + ) const taskResumeAppliedRef = useRef(false) const githubSearchPersistReadyRef = useRef(false) const linearSearchPersistReadyRef = useRef(false) @@ -625,9 +640,15 @@ export default function TaskPage(): React.JSX.Element { // initializes once, so sync from the store when the value changes. useEffect(() => { if (pageData.taskSource) { - setTaskSource(pageData.taskSource) + setTaskSource(resolveVisibleTaskProvider(pageData.taskSource, visibleTaskProviders)) } - }, [pageData.taskSource]) + }, [pageData.taskSource, visibleTaskProviders]) + + useEffect(() => { + if (!visibleTaskProviders.includes(taskSource)) { + setTaskSource(resolveVisibleTaskProvider(settings?.defaultTaskSource, visibleTaskProviders)) + } + }, [settings?.defaultTaskSource, taskSource, visibleTaskProviders]) // Why: Project mode is a sub-tab within the GitHub source. Visible whenever // the user is on the GitHub task source — actual entry into Project mode is @@ -875,7 +896,12 @@ export default function TaskPage(): React.JSX.Element { return } - setTaskSource(pageData.taskSource ?? settings.defaultTaskSource) + setTaskSource( + resolveVisibleTaskProvider( + pageData.taskSource ?? settings.defaultTaskSource, + visibleTaskProviders + ) + ) setRepoSelection(resolvedInitialSelection) const nextGithubMode = taskResumeState?.githubMode ?? 'items' @@ -905,7 +931,14 @@ export default function TaskPage(): React.JSX.Element { // Tasks context exactly once so later source/filter clicks remain local. taskResumeAppliedRef.current = true setTaskResumeApplied(true) - }, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState]) + }, [ + persistedUIReady, + settings, + pageData.taskSource, + resolvedInitialSelection, + taskResumeState, + visibleTaskProviders + ]) // Why: fetch the full team list from the Linear API so the selector shows // all teams the user belongs to, not just teams with issues in the current @@ -1078,15 +1111,13 @@ export default function TaskPage(): React.JSX.Element { return new Set(defaultLinearTeamSelection) }) - // Why: in sticky-all mode, auto-include all teams once the list arrives. - // In explicit-selection mode, the set is already correct from the initializer. + // Why: team IDs belong to one Linear workspace. Switching workspaces while a + // saved subset exists must not leave the task list filtered by stale team IDs. useEffect(() => { if (availableTeams.length === 0) { return } - if (!defaultLinearTeamSelection) { - setLinearTeamSelection(new Set(availableTeams.map((t) => t.id))) - } + setLinearTeamSelection(reconcileLinearTeamSelection(availableTeams, defaultLinearTeamSelection)) }, [availableTeams, defaultLinearTeamSelection]) const displayedLinearIssues = useMemo( @@ -1677,7 +1708,13 @@ export default function TaskPage(): React.JSX.Element { useEffect(() => { // Why: when a modal is open, let it own Esc dismissal. - if (dialogWorkItem || newIssueOpen || newLinearIssueOpen || activeModal !== 'none') { + if ( + dialogWorkItem || + selectedLinearIssue || + newIssueOpen || + newLinearIssueOpen || + activeModal !== 'none' + ) { return } @@ -1711,7 +1748,14 @@ export default function TaskPage(): React.JSX.Element { window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [activeModal, closeTaskPage, dialogWorkItem, newIssueOpen, newLinearIssueOpen]) + }, [ + activeModal, + closeTaskPage, + dialogWorkItem, + newIssueOpen, + newLinearIssueOpen, + selectedLinearIssue + ]) // Why: check Linear connection status on mount so the UI can show the // correct connected/disconnected state without requiring a settings visit. @@ -1929,7 +1973,7 @@ export default function TaskPage(): React.JSX.Element {
- {SOURCE_OPTIONS.map((source) => { + {visibleSourceOptions.map((source) => { const active = taskSource === source.id return ( @@ -2116,13 +2160,6 @@ export default function TaskPage(): React.JSX.Element {
- {/* Why: GitHub API budget pill is anchored next to the - Refresh button so the "maybe I shouldn't click - refresh again" decision is one glance away. Only - rendered in the GitHub section because Linear has - its own SDK-based quota and doesn't consume gh - budget. */} - -
@@ -698,7 +698,12 @@ function SimpleCardContent({ Release notes -
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index d56c546be6c..7420fe99580 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -5,6 +5,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { formatAgentTypeLabel } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' @@ -182,6 +183,54 @@ function makeThreads(result: ReturnType) { } describe('buildActivityEvents', () => { + it('keeps every pane visible before applying the global activity cap', () => { + const repo = makeRepo() + const worktree = makeWorktree() + const tabs: TerminalTab[] = [] + const entries: Record = {} + + for (let paneIndex = 0; paneIndex < 18; paneIndex += 1) { + const tabId = `tab-${paneIndex}` + const paneKey = makePaneKey( + tabId, + `00000000-0000-4000-8000-${String(paneIndex + 1).padStart(12, '0')}` + ) + tabs.push(makeTabWithIds(tabId, worktree.id, `Agent ${paneIndex}`)) + // Why: later pane indexes are older, so the pre-fix global 80-event cap + // would drop the final panes entirely when every pane had five events. + const newestTimestamp = 100_000 - paneIndex * 1_000 + entries[paneKey] = { + state: 'done', + prompt: `Prompt ${paneIndex} current`, + updatedAt: newestTimestamp, + stateStartedAt: newestTimestamp, + paneKey, + terminalTitle: `Agent ${paneIndex}`, + stateHistory: [1, 2, 3, 4].map((offset) => ({ + state: 'done', + prompt: `Prompt ${paneIndex} history ${offset}`, + startedAt: newestTimestamp - offset + })), + agentType: 'claude' + } + } + + const { events, liveAgentByPaneKey } = buildActivityEvents({ + agentStatusByPaneKey: entries, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { [worktree.id]: tabs }, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + acknowledgedAgentsByPaneKey: {}, + now: 100_000 + }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + + expect(events).toHaveLength(80) + expect(threads).toHaveLength(18) + expect(new Set(threads.map((thread) => thread.paneKey)).size).toBe(18) + }) + it('keeps a prior done event after the same pane starts working again', () => { const result = makeActivityResult({ entries: { @@ -240,6 +289,38 @@ describe('buildActivityEvents', () => { }) }) + it('creates a thread for a repo-less floating terminal agent', () => { + const tab = makeTabWithIds('tab-1', FLOATING_TERMINAL_WORKTREE_ID, 'Claude') + const result = buildActivityEvents({ + agentStatusByPaneKey: { + [PANE_KEY]: makeWorkingEntryWithoutHistory() + }, + retainedAgentsByPaneKey: {}, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [tab] + }, + worktreeMap: new Map(), + repoMap: new Map(), + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + + const threads = makeThreads(result) + + expect(result.events).toHaveLength(0) + expect(threads).toHaveLength(1) + expect(threads[0]).toMatchObject({ + paneKey: PANE_KEY, + paneTitle: 'New run', + currentAgentState: 'working', + repo: null + }) + expect(threads[0].worktree).toMatchObject({ + id: FLOATING_TERMINAL_WORKTREE_ID, + displayName: 'Floating terminal' + }) + }) + it('matches a custom-titled live thread by its current prompt', () => { const tab = { ...makeTab(), customTitle: 'Pinned agent title' } const entry = { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 6188cadfbca..d9977d65a3e 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -53,6 +53,7 @@ import { type ActivityTerminalPortalTarget } from './activity-terminal-portal' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStateHistoryEntry, @@ -145,6 +146,7 @@ const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [ 'done', 'interrupted' ] +const STANDALONE_ACTIVITY_WORKTREE_REPO_ID = '__activity_standalone__' const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { year: 'numeric', @@ -475,6 +477,30 @@ function freshActivityLiveAgentState( return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null } +function standaloneActivityWorktree(worktreeId: string): Worktree { + const displayName = + worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'Floating terminal' : 'Standalone terminal' + return { + id: worktreeId, + repoId: STANDALONE_ACTIVITY_WORKTREE_REPO_ID, + path: '', + head: '', + branch: displayName, + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + // Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history. const EVENTS_PER_PANE_CAP = 5 @@ -585,8 +611,8 @@ export function buildActivityEvents(args: { const tabContext = new Map() const liveAgentByPaneKey: Record = {} - for (const worktree of args.worktreeMap.values()) { - const tabs = args.tabsByWorktree[worktree.id] ?? [] + for (const [worktreeId, tabs] of Object.entries(args.tabsByWorktree)) { + const worktree = args.worktreeMap.get(worktreeId) ?? standaloneActivityWorktree(worktreeId) for (const tab of tabs) { tabContext.set(tab.id, { worktree, tab }) } @@ -671,7 +697,11 @@ export function buildActivityEvents(args: { if (!parsePaneKey(paneKey)) { continue } - const worktree = args.worktreeMap.get(retained.worktreeId) + const worktree = + args.worktreeMap.get(retained.worktreeId) ?? + (args.tabsByWorktree[retained.worktreeId] + ? standaloneActivityWorktree(retained.worktreeId) + : null) if (!worktree) { continue } @@ -691,20 +721,41 @@ export function buildActivityEvents(args: { const sorted = events.sort((a, b) => b.timestamp - a.timestamp) const perPaneCount = new Map() + const includedEventIds = new Set() const capped: ActivityEvent[] = [] + // Why: reserve each pane's most-recent event before applying the global 80 + // cap so a pane never disappears from the left list when many panes are + // active. The validator's scenario (>16 panes × ≥5 events) could push a + // pane's events past the 80-event window, hiding the pane entirely. for (const event of sorted) { + const paneKey = event.entry.paneKey + if (perPaneCount.has(paneKey)) { + continue + } + if (capped.length >= 80) { + break + } + perPaneCount.set(paneKey, 1) + includedEventIds.add(event.id) + capped.push(event) + } + for (const event of sorted) { + if (includedEventIds.has(event.id)) { + continue + } + if (capped.length >= 80) { + break + } const paneKey = event.entry.paneKey const count = perPaneCount.get(paneKey) ?? 0 if (count >= EVENTS_PER_PANE_CAP) { continue } perPaneCount.set(paneKey, count + 1) + includedEventIds.add(event.id) capped.push(event) - if (capped.length >= 80) { - break - } } - return { events: capped, liveAgentByPaneKey } + return { events: capped.sort((a, b) => b.timestamp - a.timestamp), liveAgentByPaneKey } } export function buildAgentPaneThreads(args: { @@ -1000,6 +1051,7 @@ function ThreadRow({ onSelect, onJump, onMarkUnread, + canJump, compactMode }: { thread: AgentPaneThread @@ -1007,6 +1059,7 @@ function ThreadRow({ onSelect: () => void onJump: () => void onMarkUnread: () => void + canJump: boolean compactMode: boolean }): React.JSX.Element { const renderedResponsePreview = activityThreadResponseRenderPreview({ @@ -1140,32 +1193,34 @@ function ThreadRow({ the worktree name. Reserved layout via `invisible` + `pointer-events-none` keeps the worktree-name's flex-1 width stable across hover. */} - - - - - - Jump to workspace - - + {canJump ? ( + + + + + + Jump to workspace + + + ) : null}
) @@ -1271,8 +1326,10 @@ export default function ActivityPrototypePage(): React.JSX.Element { ? (allThreads.find((thread) => thread.paneKey === selectedPaneKey) ?? null) : null const selectedTabId = selectedThread?.tab.id ?? null + // Why: repo-less terminal buckets can still produce Activity rows, but the + // workspace Terminal tree only portals real worktrees. const selectedHasLiveTab = - selectedThread && selectedTabId + selectedThread && selectedTabId && storeData.worktreeMap.has(selectedThread.worktree.id) ? (storeData.tabsByWorktree[selectedThread.worktree.id] ?? []).some( (tab) => tab.id === selectedTabId ) @@ -1282,7 +1339,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { : null const displayedTabId = displayedThread?.tab.id ?? null const displayedHasLiveTab = - displayedThread && displayedTabId + displayedThread && displayedTabId && storeData.worktreeMap.has(displayedThread.worktree.id) ? (storeData.tabsByWorktree[displayedThread.worktree.id] ?? []).some( (tab) => tab.id === displayedTabId ) @@ -1443,19 +1500,23 @@ export default function ActivityPrototypePage(): React.JSX.Element { const activateThreadTerminal = (thread: AgentPaneThread): void => { const state = useAppStore.getState() + const worktree = getWorktreeMapFromState(state).get(thread.worktree.id) + if (!worktree) { + return + } // Why: retained-agent threads can outlive their tab. With no live tab, the // right pane shows the empty-state placeholder; reorienting the workspace // and dispatching focus to a dead tab id would just confuse the user. - const liveTabs = state.tabsByWorktree[thread.worktree.id] ?? [] + const liveTabs = state.tabsByWorktree[worktree.id] ?? [] const hasLiveTab = liveTabs.some((t) => t.id === thread.tab.id) if (!hasLiveTab) { return } - if (state.activeRepoId !== thread.worktree.repoId) { - state.setActiveRepo(thread.worktree.repoId) + if (state.activeRepoId !== worktree.repoId) { + state.setActiveRepo(worktree.repoId) } - if (state.activeWorktreeId !== thread.worktree.id) { - state.setActiveWorktree(thread.worktree.id) + if (state.activeWorktreeId !== worktree.id) { + state.setActiveWorktree(worktree.id) } state.setActiveTabType('terminal') const parsed = parsePaneKey(thread.paneKey) @@ -1497,6 +1558,10 @@ export default function ActivityPrototypePage(): React.JSX.Element { ]) const jumpToWorkspace = (thread: AgentPaneThread): void => { + const state = useAppStore.getState() + if (!getWorktreeMapFromState(state).has(thread.worktree.id)) { + return + } markThreadRead(thread) activateAndRevealWorktree(thread.worktree.id) } @@ -1626,6 +1691,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { onSelect={() => selectThread(thread)} onJump={() => jumpToWorkspace(thread)} onMarkUnread={() => markThreadUnread(thread)} + canJump={storeData.worktreeMap.has(thread.worktree.id)} compactMode={compactMode} /> ))} @@ -1695,7 +1761,9 @@ export default function ActivityPrototypePage(): React.JSX.Element { return (
- Agent terminal closed. Open a new terminal in this workspace to continue. + {storeData.worktreeMap.has(selectedThread.worktree.id) + ? 'Agent terminal closed. Open a new terminal in this workspace to continue.' + : 'Standalone terminal unavailable in Activity.'}
) } diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 562f7ea5634..9d2f9bcda27 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -5,14 +5,13 @@ import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import type { Automation, AutomationRun } from '../../../../shared/automations-types' -import type { Worktree } from '../../../../shared/types' -import { parseAutomationRrule } from '../../../../shared/automation-schedules' +import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' import { - formatAutomationDateTime, - formatAutomationDateTimeWithRelative, - getAutomationRunStatusLabel, - getAutomationRunStatusVariant -} from './automation-page-parts' + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' type AutomationDetailProps = { automation: Automation | null @@ -20,10 +19,8 @@ type AutomationDetailProps = { projectName: string workspaceName: string projectDefaultBaseRef: string | null - worktreeMap: Map now: number onRunNow: (automation: Automation) => void - onOpenRunWorkspace: (run: AutomationRun) => void onEdit: (automation: Automation) => void onToggle: (automation: Automation) => void onDelete: (automation: Automation) => void @@ -38,15 +35,6 @@ function DetailMetric({ label, value }: { label: string; value: string }): React ) } -function formatTime(hour: number, minute: number): string { - const date = new Date() - date.setHours(hour, minute, 0, 0) - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit' - }).format(date) -} - function formatGrace(minutes: number): string { if (minutes <= 0) { return 'No grace' @@ -58,24 +46,6 @@ function formatGrace(minutes: number): string { return `${hours} ${hours === 1 ? 'hour' : 'hours'}` } -function formatSchedule(rrule: string): string { - const schedule = parseAutomationRrule(rrule) - if (schedule.preset === 'hourly') { - return `Hourly at :${String(schedule.minute).padStart(2, '0')}` - } - const time = formatTime(schedule.hour, schedule.minute) - if (schedule.preset === 'daily') { - return `Daily at ${time}` - } - if (schedule.preset === 'weekdays') { - return `Weekdays at ${time}` - } - const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format( - new Date(2026, 0, 4 + schedule.dayOfWeek) - ) - return `${day}s at ${time}` -} - function ToolbarIconButton({ label, children, @@ -114,10 +84,8 @@ export function AutomationDetail({ projectName, workspaceName, projectDefaultBaseRef, - worktreeMap, now, onRunNow, - onOpenRunWorkspace, onEdit, onToggle, onDelete @@ -129,6 +97,13 @@ export function AutomationDetail({ ) } + const usageSummary = summarizeAutomationRunUsage(runs) + const usageCoverage = + usageSummary.knownRuns > 0 + ? `${usageSummary.knownRuns}/${runs.length} runs` + : usageSummary.unavailableRuns > 0 + ? 'Unavailable' + : 'No runs' return (
@@ -175,8 +150,7 @@ export function AutomationDetail({
) : null} -
- +
+ + +
@@ -205,7 +185,7 @@ export function AutomationDetail({
- + - -
-
-
Run history
-
{runs.length} runs
-
-
-
Run
-
Workspace
-
Status
-
-
- {runs.map((run) => { - const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null - const workspaceLabel = run.workspaceId - ? (runWorktree?.displayName ?? 'Missing workspace') - : 'Not launched' - const rowClassName = - 'grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors' - const rowContent = ( - <> -
-
{formatAutomationDateTime(run.scheduledFor)}
- {run.error ? ( -
{run.error}
- ) : null} -
-
- {workspaceLabel} -
-
- - {getAutomationRunStatusLabel(run.status)} - -
- - ) - return runWorktree ? ( - - ) : ( -
- {rowContent} -
- ) - })} - {runs.length === 0 ? ( -
No runs yet.
- ) : null} -
-
) } diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 64f87107dca..481ffe5d6fa 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -1,8 +1,9 @@ import React from 'react' -import { Info, Plus } from 'lucide-react' +import { Info, Plus, Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, @@ -21,9 +22,16 @@ import type { } from '../../../../shared/automations-types' import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types' import { Field } from './automation-page-parts' +import { AutomationSchedulePicker } from './AutomationSchedulePicker' +import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates' import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' +const PICKER_TRIGGER_CLASS = + 'border-input bg-input/30 shadow-xs hover:bg-accent/60 dark:bg-input/30 dark:hover:bg-input/50' +const MODE_TOGGLE_ITEM_CLASS = + 'w-full border-input bg-input/30 shadow-xs hover:bg-accent/60 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90 dark:bg-input/30 dark:data-[state=on]:bg-primary dark:data-[state=on]:text-primary-foreground dark:data-[state=on]:hover:bg-primary/90' + export type AutomationDraft = { name: string prompt: string @@ -35,248 +43,322 @@ export type AutomationDraft = { preset: AutomationSchedulePreset time: string dayOfWeek: string + customSchedule: string missedRunGraceMinutes: string + scheduleWarning: string | null } +export type AutomationCreateTarget = 'orca' | 'hermes' + type AutomationEditorDialogProps = { open: boolean isEditing: boolean isSaving: boolean canSave: boolean + createTarget: AutomationCreateTarget repos: Repo[] repoMap: Map worktrees: Worktree[] settings: GlobalSettings | null draft: AutomationDraft onProjectChange: (projectId: string) => void + onCreateTargetChange: (target: AutomationCreateTarget) => void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onApplyTemplate: (template: AutomationTemplate) => void onSave: () => void } +function AutomationTemplateCard({ + template, + onSelect +}: { + template: AutomationTemplate + onSelect: () => void +}): React.JSX.Element { + return ( + + ) +} + export function AutomationEditorDialog({ open, isEditing, isSaving, canSave, + createTarget, repos, repoMap, worktrees, settings, draft, onProjectChange, + onCreateTargetChange, onOpenChange, onDraftChange, + onApplyTemplate, onSave }: AutomationEditorDialogProps): React.JSX.Element { + const [templateOpen, setTemplateOpen] = React.useState(false) + const isHermesCreate = !isEditing && createTarget === 'hermes' + return ( { event.preventDefault() }} > - - - {isEditing ? 'Edit Automation' : 'Create Automation'} - - -
- - - onDraftChange((current) => ({ ...current, name: event.target.value })) - } - /> - - - - - - - workspaceMode && - onDraftChange((current) => ({ - ...current, - workspaceMode: workspaceMode as AutomationWorkspaceMode - })) - } - variant="outline" - size="sm" - className="grid w-full grid-cols-2" - > - - Selected workspace - - - New workspace each run - - - - {draft.workspaceMode === 'existing' ? ( - - - onDraftChange((current) => ({ ...current, workspaceId })) - } - /> - - ) : ( - - - onDraftChange((current) => ({ ...current, baseBranch })) - } - /> - - )} -
- - - agentId && onDraftChange((current) => ({ ...current, agentId })) - } - defaultAgent={settings?.defaultTuiAgent ?? null} - triggerClassName="h-9 w-full min-w-0" - /> - - - - -
-
- + +
+
+ + {isEditing + ? 'Edit automation' + : isHermesCreate + ? 'Create Hermes cron' + : 'Create automation'} + - onDraftChange((current) => ({ ...current, time: event.target.value })) + onDraftChange((current) => ({ ...current, name: event.target.value })) } /> - - - - -
- - Missed-run grace - - - - - - If Orca or the execution host was unavailable at the scheduled time, Orca runs - one missed occurrence when it becomes available within this window. Older missed - runs are skipped. - - - - } - > - - + + Use template + + + +
+ {AUTOMATION_TEMPLATES.map((template) => ( + { + onApplyTemplate(template) + setTemplateOpen(false) + }} + /> + ))} +
+
+ +
+ ) : null} +
+ + +
+ {draft.scheduleWarning ? ( +
+ {draft.scheduleWarning} +
+ ) : null} ') + expect(markup).not.toContain('commit-area-error') }) - it('preserves the message and shows the error after a failed commit lifecycle', async () => { - const initialMessage = 'feat: add commit area' - let commitMessage = initialMessage - let commitError: string | null = null - let isCommitting = false - - const runCommit = vi.fn(async () => { - isCommitting = true - commitError = null - await Promise.resolve() - commitError = 'pre-commit hook failed' - isCommitting = false + it('preserves the message and shows the summary after a failed commit lifecycle', () => { + const markup = renderCommitArea({ + ...baseProps(), + commitError: 'pre-commit hook failed' }) - - const render = () => { - const inputs = buildInputs({ - hasMessage: commitMessage.trim().length > 0, - isCommitting - }) - return CommitArea({ - ...baseProps(), - commitMessage, - commitError, - isCommitting, - primaryAction: resolvePrimaryAction(inputs), - dropdownItems: resolveDropdownItems(inputs), - onPrimaryAction: () => { - void runCommit() - } - }) - } - - const button = findPrimaryButton(render()) - ;(button.props.onClick as () => void)() - await flushPromises() - - const updated = render() - expect(findTextarea(updated).props.value).toBe(initialMessage) - expect(hasText(updated, 'pre-commit hook failed')).toBe(true) - expect(runCommit).toHaveBeenCalledTimes(1) + expect(textarea(markup)).toContain('feat: add commit area') + expect(markup).toContain('Pre-commit hook failed.') }) it('locks the primary button while the commit is in flight', () => { const props = baseProps({ isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - const button = findPrimaryButton(element) - expect(button.props.disabled).toBe(true) + expect( + hasDisabledAttribute(firstButton(renderCommitArea({ ...props, isCommitting: true }))) + ).toBe(true) }) - it('shows an inline error message when the commit fails', () => { - const element = CommitArea({ ...baseProps(), commitError: 'pre-commit hook failed' }) - expect(hasText(element, 'pre-commit hook failed')).toBe(true) + it('shows a compact summary and not raw multiline text when the commit fails', () => { + const raw = 'husky - pre-commit hook\neslint found 2 errors\nfull lint output line' + const markup = renderCommitArea({ ...baseProps(), commitError: raw }) + + expect(markup).toContain('id="commit-area-error"') + expect(markup).toContain('role="alert"') + expect(markup).toContain('aria-live="polite"') + expect(markup).toContain('Lint failed during commit.') + expect(markup).not.toContain('full lint output line') + expect(markup).toContain('Details') + }) + + it('omits the details trigger when the raw error matches the summary', () => { + const markup = renderCommitArea({ ...baseProps(), commitError: 'nothing to commit' }) + expect(markup).toContain('nothing to commit') + expect(markup).not.toContain('Details') + }) + + it('shows an inline error message when a remote action fails', () => { + const markup = renderCommitArea({ + ...baseProps(), + remoteActionError: 'Fetch failed. network timeout' + }) + expect(markup).toContain('Fetch failed. network timeout') + expect(markup).toContain('commit-area-remote-error') + }) + + it('keeps generation errors separate from commit and remote errors', () => { + const markup = renderCommitArea({ + ...baseProps(), + generateError: 'No staged changes to summarize.' + }) + expect(markup).toContain('No staged changes to summarize.') + expect(markup).toContain('aria-describedby="commit-area-generate-error"') + }) + + it('keeps all visible errors linked to the textarea', () => { + const markup = renderCommitArea({ + ...baseProps(), + commitError: 'pre-commit hook failed', + remoteActionError: 'Fetch failed.', + generateError: 'No staged changes.' + }) + expect(markup).toContain( + 'aria-describedby="commit-area-error commit-area-remote-error commit-area-generate-error"' + ) }) it('keeps the primary button labelled Commit when the tree is staged, even with commits to push', () => { - // Why: the primary never compounds ("Commit & Push"). Users commit first, - // then the primary rotates to Push. Compound flows remain in the dropdown, - // so we check only the primary button, not the whole tree. - const props = baseProps({ - stagedCount: 1, - hasMessage: true, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Commit')).toBe(true) - expect(hasText(primary, 'Commit & Push')).toBe(false) - expect(hasText(primary, 'Commit & Sync')).toBe(false) - expect(hasText(primary, 'Commit & Publish')).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 1, + hasMessage: true, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } + }) + ) + expect(firstButton(markup)).toContain('Commit') + expect(firstButton(markup)).not.toContain('Commit & Push') }) - // Why: fetching from the dropdown sets isRemoteOperationActive, but the - // primary button is plain "Commit" — painting a spinner on it told the - // user their commit was running. The spinner must track the primary - // action itself, not every background remote op. it('does not show a spinner on a plain Commit primary when a dropdown remote op is running', () => { - const props = baseProps({ - // stagedCount + no message resolves to plain 'commit' kind (disabled - // because the message is empty). This is the scenario the user hit: - // a Commit button that falsely claimed their commit was in flight - // while a dropdown-triggered Fetch was the actual work. - stagedCount: 1, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - isCommitting: false, - isRemoteOperationActive: true - }) - const element = CommitArea(props) - expect(primaryHasSpinner(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 1, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + isCommitting: false, + isRemoteOperationActive: true + }) + ) + expect(firstButton(markup)).not.toContain('animate-spin') }) it('shows a spinner on a Commit primary while the commit itself is in flight', () => { @@ -326,85 +204,73 @@ describe('CommitArea', () => { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - expect(primaryHasSpinner(element)).toBe(true) + expect(firstButton(renderCommitArea({ ...props, isCommitting: true }))).toContain( + 'animate-spin' + ) }) - it('shows a spinner on a remote primary (Push) while the matching remote op is active', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push' - }) - const element = CommitArea(props) - expect(primaryHasSpinner(element)).toBe(true) + it('shows a spinner on a remote primary while the matching remote op is active', () => { + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'push' + }) + ) + expect(firstButton(markup)).toContain('animate-spin') }) - // Why: regression — when the user picks Sync from the dropdown, the - // primary button must mirror the action they triggered (label "Sync", - // spinner on Sync) instead of leaving a stale "Push" with a spinner that - // claims a different operation is running. it('mirrors a dropdown-triggered Sync on the primary button while it runs', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - // Pre-click state: ahead=3, behind=0 → primary's natural label is Push. - upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'sync' - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Sync')).toBe(true) - expect(hasText(primary, 'Push')).toBe(false) - expect(primaryHasSpinner(element)).toBe(true) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'sync' + }) + ) + expect(firstButton(markup)).toContain('Sync') + expect(firstButton(markup)).not.toContain('Push') + expect(firstButton(markup)).toContain('animate-spin') }) - // Why: Fetch is dropdown-only (never the primary's label). Spinning the - // primary on a fetch would mis-narrate "Push is running" while the actual - // work is fetching. Primary keeps its natural label, disabled, no spinner. it('does not spin or relabel the primary when a dropdown Fetch is in flight', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'fetch' - }) - const element = CommitArea(props) - const primary = findPrimaryButton(element) - expect(hasText(primary, 'Push')).toBe(true) - expect(primary.props.disabled).toBe(true) - expect(primaryHasSpinner(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }, + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'fetch' + }) + ) + expect(firstButton(markup)).toContain('Push') + expect(hasDisabledAttribute(firstButton(markup))).toBe(true) + expect(firstButton(markup)).not.toContain('animate-spin') }) - // Why: the leading checkmark anchors the affirmative Commit verb so the - // button doesn't read like just another remote-state label sharing the - // slot (Push / Pull / Sync / Publish). Decorative — verified by - // presence/absence rather than label text. it('renders a leading checkmark on a Commit primary', () => { - const element = CommitArea(baseProps()) - expect(primaryHasCheck(element)).toBe(true) + expect(firstButton(renderCommitArea(baseProps()))).toContain('lucide-check') }) it('omits the checkmark when the primary is a remote action', () => { - const props = baseProps({ - stagedCount: 0, - hasMessage: false, - upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } - }) - const element = CommitArea(props) - expect(primaryHasCheck(element)).toBe(false) + const markup = renderCommitArea( + baseProps({ + stagedCount: 0, + hasMessage: false, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } + }) + ) + expect(firstButton(markup)).not.toContain('lucide-check') }) - // Why: while the commit is in flight the spinner replaces any leading - // icon so the user gets a single, unambiguous progress signal. it('replaces the checkmark with a spinner while the commit is in flight', () => { const props = baseProps({ isCommitting: true }) - const element = CommitArea({ ...props, isCommitting: true }) - expect(primaryHasSpinner(element)).toBe(true) - expect(primaryHasCheck(element)).toBe(false) + const button = firstButton(renderCommitArea({ ...props, isCommitting: true })) + expect(button).toContain('animate-spin') + expect(button).not.toContain('lucide-check') }) }) diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index bcfefc5f50d..fe7692ba29f 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { @@ -12,25 +12,22 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { - getRuntimeRepoBaseRefDefault, - searchRuntimeRepoBaseRefs -} from '@/runtime/runtime-repo-client' import type { CreateHostedReviewResult, HostedReviewCreationEligibility } from '../../../../shared/hosted-review' -import { - normalizeHostedReviewBaseRef, - normalizeHostedReviewHeadRef -} from '../../../../shared/hosted-review-refs' +import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' +import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' type CreatePullRequestDialogProps = { open: boolean repoId: string repoPath: string + worktreeId: string | null + worktreePath: string branch: string eligibility: HostedReviewCreationEligibility | null pushBeforeCreate: boolean @@ -39,10 +36,6 @@ type CreatePullRequestDialogProps = { onCreated: (result: { number: number; url: string }) => Promise } -function stripBaseRef(ref: string): string { - return normalizeHostedReviewBaseRef(ref) -} - function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string { if (result.ok) { return '' @@ -57,6 +50,8 @@ export function CreatePullRequestDialog({ open, repoId, repoPath, + worktreeId, + worktreePath, branch, eligibility, pushBeforeCreate, @@ -67,89 +62,52 @@ export function CreatePullRequestDialog({ const settings = useAppStore((s) => s.settings) const createHostedReview = useAppStore((s) => s.createHostedReview) const submitInFlightRef = useRef(false) - const initializedFromEligibilityRef = useRef(null) - const [base, setBase] = useState('') - const [title, setTitle] = useState('') - const [body, setBody] = useState('') - const [draft, setDraft] = useState(false) - const [baseQuery, setBaseQuery] = useState('') - const [baseResults, setBaseResults] = useState([]) - const [baseSearchError, setBaseSearchError] = useState(null) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const { + aiGenerationEnabled, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } = useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting + }) useEffect(() => { - if (!open) { - submitInFlightRef.current = false - initializedFromEligibilityRef.current = null - setSubmitting(false) - setError(null) + if (open) { return } - const initializationKey = `${repoId}:${branch}:${eligibility ? 'ready' : 'pending'}` - if (initializedFromEligibilityRef.current === initializationKey) { - return - } - // Why: eligibility refreshes while the dialog is open; only seed fields - // once per branch so late refreshes do not overwrite user edits. - initializedFromEligibilityRef.current = initializationKey - const initialBase = eligibility?.defaultBaseRef ?? '' - setBase(stripBaseRef(initialBase)) - setTitle(eligibility?.title ?? '') - setBody(eligibility?.body ?? '') - setDraft(false) - setBaseQuery('') - setBaseResults([]) - setBaseSearchError(null) - }, [branch, eligibility, open, repoId]) - - useEffect(() => { - if (!open || base) { - return - } - let stale = false - void getRuntimeRepoBaseRefDefault(settings, repoId) - .then((result) => { - if (!stale && result.defaultBaseRef) { - setBase(stripBaseRef(result.defaultBaseRef)) - } - }) - .catch(() => undefined) - return () => { - stale = true - } - }, [base, open, repoId, settings]) - - useEffect(() => { - if (!open || baseQuery.trim().length < 2) { - setBaseResults([]) - setBaseSearchError(null) - return - } - let stale = false - const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) - .then((results) => { - if (!stale) { - setBaseResults(results.map(stripBaseRef)) - setBaseSearchError(null) - } - }) - .catch(() => { - if (!stale) { - setBaseResults([]) - setBaseSearchError('Branch discovery failed.') - } - }) - }, 200) - return () => { - stale = true - window.clearTimeout(timer) - } - }, [baseQuery, open, repoId, settings]) + submitInFlightRef.current = false + setSubmitting(false) + setError(null) + }, [open]) const submitDisabled = submitting || + generating || title.trim().length === 0 || base.trim().length === 0 || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() @@ -177,7 +135,8 @@ export function CreatePullRequestDialog({ head: normalizeHostedReviewHeadRef(branch), title: title.trim(), body, - draft + draft, + worktreePath }) if (result.ok) { toast.success(`Pull request #${result.number} created`, { @@ -224,7 +183,8 @@ export function CreatePullRequestDialog({ pushBeforeCreate, repoPath, submitDisabled, - title + title, + worktreePath ]) const handleOpenChange = useCallback( @@ -243,7 +203,47 @@ export function CreatePullRequestDialog({ - Create Pull Request +
+ Create Pull Request + {aiGenerationEnabled ? ( +
+ {generating ? ( + + + + + + Generating PR details. Click to stop. + + + ) : ( + + )} +
+ ) : null} +
Confirm the target branch and PR details before creating the hosted review. @@ -339,6 +339,7 @@ export function CreatePullRequestDialog({ Choose a different base branch before creating a pull request.

) : null} + {generateError ?

{generateError}

: null} {error ?

{error}

: null}
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 80f07e7fa29..1e2722b6220 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { Loader2, RefreshCw } from 'lucide-react' +import { EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { FileExplorerToolbar } from './FileExplorerToolbar' +import { FileExplorerRow, shouldShowCollapseFolderAction } from './FileExplorerRow' +import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' +import type { TreeNode } from './file-explorer-types' type ReactElementLike = { type: unknown @@ -36,6 +39,55 @@ function findRefreshButton(node: unknown): ReactElementLike { return found } +function findCollapseAllButton(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Collapse All') { + found = entry + } + }) + if (!found) { + throw new Error('collapse all button not found') + } + return found +} + +function findGitIgnoredButton(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') { + found = entry + } + }) + if (!found) { + throw new Error('git ignored button not found') + } + return found +} + +function queryGitIgnoredButton(node: unknown): ReactElementLike | null { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === 'Hide Git Ignored Files') { + found = entry + } + }) + return found +} + +function findFileExplorerRow(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.type === FileExplorerRow) { + found = entry + } + }) + if (!found) { + throw new Error('file explorer row not found') + } + return found +} + function findRepoNameLabel(node: unknown, repoName: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -74,13 +126,23 @@ function makeRefreshState( } } +function makeToolbar(overrides: Partial[0]> = {}) { + return FileExplorerToolbar({ + repoName: 'orca', + refresh: makeRefreshState(), + canCollapseAll: false, + onCollapseAll: vi.fn(), + showGitIgnoredFilesToggle: true, + showGitIgnoredFiles: true, + onToggleGitIgnoredFiles: vi.fn(), + ...overrides + }) +} + describe('FileExplorerToolbar', () => { it('fires the refresh action from the icon button', () => { const onRefresh = vi.fn() - const element = FileExplorerToolbar({ - repoName: 'orca', - refresh: makeRefreshState({ handleRefresh: onRefresh }) - }) + const element = makeToolbar({ refresh: makeRefreshState({ handleRefresh: onRefresh }) }) const button = findRefreshButton(element) ;(button.props.onClick as () => void)() @@ -93,10 +155,7 @@ describe('FileExplorerToolbar', () => { it('shows the repo name in a truncated label', () => { const repoName = 'really-long-repo-name-that-should-not-push-refresh-offscreen' - const element = FileExplorerToolbar({ - repoName, - refresh: makeRefreshState() - }) + const element = makeToolbar({ repoName }) const label = findRepoNameLabel(element, repoName) @@ -106,8 +165,7 @@ describe('FileExplorerToolbar', () => { }) it('disables the refresh button and shows a spinner while refreshing', () => { - const element = FileExplorerToolbar({ - repoName: 'orca', + const element = makeToolbar({ refresh: makeRefreshState({ isRefreshing: true, showRefreshSpinner: true }) }) @@ -117,4 +175,120 @@ describe('FileExplorerToolbar', () => { expect(hasIcon(button, Loader2)).toBe(true) expect(hasIcon(button, RefreshCw)).toBe(false) }) + + it('fires the collapse all action from the icon button', () => { + const onCollapseAll = vi.fn() + const element = makeToolbar({ + canCollapseAll: true, + onCollapseAll + }) + + const button = findCollapseAllButton(element) + ;(button.props.onClick as () => void)() + + expect(onCollapseAll).toHaveBeenCalledTimes(1) + expect(button.props.disabled).toBe(false) + expect(hasIcon(button, ListCollapse)).toBe(true) + }) + + it('disables collapse all when no directories are expanded', () => { + const element = makeToolbar({ canCollapseAll: false }) + + const button = findCollapseAllButton(element) + + expect(button.props.disabled).toBe(true) + expect(hasIcon(button, ListCollapse)).toBe(true) + }) + + it('fires the git ignored visibility toggle from the icon button', () => { + const onToggleGitIgnoredFiles = vi.fn() + const element = makeToolbar({ onToggleGitIgnoredFiles }) + + const button = findGitIgnoredButton(element) + ;(button.props.onClick as () => void)() + + expect(onToggleGitIgnoredFiles).toHaveBeenCalledTimes(1) + expect(hasIcon(button, EyeOff)).toBe(true) + }) + + it('hides the git ignored visibility toggle for non-git folders', () => { + const element = makeToolbar({ showGitIgnoredFilesToggle: false }) + + expect(queryGitIgnoredButton(element)).toBeNull() + }) +}) + +describe('FileExplorerRow collapse folder action', () => { + const directoryNode: TreeNode = { + name: 'src', + path: '/repo/src', + relativePath: 'src', + isDirectory: true, + depth: 0 + } + + it('only shows collapse folder for expanded directories', () => { + expect(shouldShowCollapseFolderAction(directoryNode, true)).toBe(true) + expect(shouldShowCollapseFolderAction(directoryNode, false)).toBe(false) + expect( + shouldShowCollapseFolderAction( + { + ...directoryNode, + name: 'index.ts', + path: '/repo/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false + }, + true + ) + ).toBe(false) + }) + + it('passes the row node to the collapse folder handler', () => { + const onCollapseFolderSubtree = vi.fn() + const element = FileExplorerVirtualRows({ + virtualizer: { + getTotalSize: () => 26, + getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }], + measureElement: vi.fn() + } as never, + inlineInputIndex: -1, + flatRows: [directoryNode], + inlineInput: null, + handleInlineSubmit: vi.fn(), + dismissInlineInput: vi.fn(), + folderStatusByRelativePath: new Map(), + statusByRelativePath: new Map(), + ignoredByRelativePath: new Set(), + expanded: new Set([directoryNode.path]), + dirCache: {}, + selectedPaths: new Set(), + activeFileId: null, + flashingPath: null, + deleteShortcutLabel: 'Del', + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onContextMenuSelect: vi.fn(), + onCopyPaths: vi.fn(), + onStartNew: vi.fn(), + onStartRename: vi.fn(), + onDuplicate: vi.fn(), + onRequestDelete: vi.fn(), + onCollapseFolderSubtree, + onMoveDrop: vi.fn(), + onDragTargetChange: vi.fn(), + onDragSourceChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + dropTargetDir: null, + dragSourcePath: null, + nativeDropTargetDir: null + }) + + const row = findFileExplorerRow(element) + ;(row.props.onCollapseFolderSubtree as () => void)() + + expect(onCollapseFolderSubtree).toHaveBeenCalledWith(directoryNode) + }) }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index ecda45f3348..c7f98e20e5b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- File Explorer coordinates tree state, selection, drag/drop, and toolbar actions in one component. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' @@ -5,6 +6,7 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { basename, dirname } from '@/lib/path' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' +import { isGitRepoKind } from '../../../../shared/repo-kind' import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu' import { FileExplorerToolbar } from './FileExplorerToolbar' import { FileExplorerTreeStatus } from './FileExplorerTreeStatus' @@ -24,6 +26,8 @@ import { useFileExplorerImport } from './useFileExplorerImport' import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh' import { useFileExplorerTree } from './useFileExplorerTree' import { useFileExplorerWatch } from './useFileExplorerWatch' +import { useFileExplorerSelection } from './useFileExplorerSelection' +import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows' function FileExplorerInner(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -31,6 +35,8 @@ function FileExplorerInner(): React.JSX.Element { const activeRepo = useRepoById(activeWorktree?.repoId ?? null) const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) const expandedDirs = useAppStore((s) => s.expandedDirs) + const collapseAllDirs = useAppStore((s) => s.collapseAllDirs) + const collapseDirSubtree = useAppStore((s) => s.collapseDirSubtree) const toggleDir = useAppStore((s) => s.toggleDir) const pendingExplorerReveal = useAppStore((s) => s.pendingExplorerReveal) const clearPendingExplorerReveal = useAppStore((s) => s.clearPendingExplorerReveal) @@ -43,6 +49,7 @@ function FileExplorerInner(): React.JSX.Element { const worktreePath = activeWorktree?.path ?? null const repoName = activeRepo?.displayName ?? (worktreePath ? basename(worktreePath) : '') + const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false const expanded = useMemo( () => @@ -54,7 +61,6 @@ function FileExplorerInner(): React.JSX.Element { dirCache, setDirCache, flatRows, - rowsByPath, rootCache, rootError, loadDir, @@ -62,9 +68,22 @@ function FileExplorerInner(): React.JSX.Element { refreshDir, resetAndLoad } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) + const { + visibleFlatRows, + rowsByPath, + ignoredByRelativePath, + showGitIgnoredFiles, + toggleGitIgnoredFiles + } = useFileExplorerGitIgnoredRows(activeWorktreeId, worktreePath, flatRows, activeRepoSupportsGit) const manualRefresh = useFileExplorerManualRefresh(refreshTree) + const canCollapseAll = expanded.size > 0 + const handleCollapseAll = useCallback(() => { + if (!activeWorktreeId) { + return + } + collapseAllDirs(activeWorktreeId) + }, [activeWorktreeId, collapseAllDirs]) - const [selectedPath, setSelectedPath] = useState(null) const [flashingPath, setFlashingPath] = useState(null) const [bgMenuOpen, setBgMenuOpen] = useState(false) const [bgMenuPoint, setBgMenuPoint] = useState({ x: 0, y: 0 }) @@ -74,6 +93,15 @@ function FileExplorerInner(): React.JSX.Element { const flashTimeoutRef = useRef(null) const isMac = useMemo(() => navigator.userAgent.includes('Mac'), []) const isWindows = useMemo(() => navigator.userAgent.includes('Windows'), []) + const { + selectedPath, + selectedPaths, + setSingleSelectedPath, + resetSelection, + selectRowWithModifiers, + preserveSelectionForContextMenu, + copyPathsForNode + } = useFileExplorerSelection(visibleFlatRows, isMac) const clearFlashTimeout = useCallback(() => { if (flashTimeoutRef.current !== null) { @@ -95,7 +123,7 @@ function FileExplorerInner(): React.JSX.Element { closeFile, refreshDir, selectedPath, - setSelectedPath, + setSelectedPath: setSingleSelectedPath, isMac, isWindows }) @@ -128,10 +156,10 @@ function FileExplorerInner(): React.JSX.Element { if (!worktreePath) { return } - setSelectedPath(null) + resetSelection() resetAndLoad() clearFileExplorerUndoHistory() - }, [worktreePath]) // eslint-disable-line react-hooks/exhaustive-deps + }, [worktreePath, resetSelection]) // eslint-disable-line react-hooks/exhaustive-deps // Why: on app startup the file explorer loads before SSH providers are // registered, so readDir fails for remote worktrees. When the SSH @@ -172,7 +200,7 @@ function FileExplorerInner(): React.JSX.Element { activeWorktreeId, worktreePath, expanded, - flatRows, + flatRows: visibleFlatRows, scrollRef, refreshDir }) @@ -183,7 +211,7 @@ function FileExplorerInner(): React.JSX.Element { dirCache, setDirCache, expanded, - setSelectedPath, + setSelectedPath: setSingleSelectedPath, refreshDir, refreshTree, inlineInput, @@ -196,10 +224,10 @@ function FileExplorerInner(): React.JSX.Element { activeWorktreeId, refreshDir, clearNativeDragState, - setSelectedPath + setSelectedPath: setSingleSelectedPath }) - const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0) + const totalCount = visibleFlatRows.length + (inlineInputIndex >= 0 ? 1 : 0) const virtualizer = useVirtualizer({ count: totalCount, @@ -212,9 +240,9 @@ function FileExplorerInner(): React.JSX.Element { return '__inline_input__' } const rowIndex = index > inlineInputIndex ? index - 1 : index - return flatRows[rowIndex]?.path ?? `__fallback_${index}` + return visibleFlatRows[rowIndex]?.path ?? `__fallback_${index}` } - return flatRows[index]?.path ?? `__fallback_${index}` + return visibleFlatRows[index]?.path ?? `__fallback_${index}` } }) @@ -227,9 +255,9 @@ function FileExplorerInner(): React.JSX.Element { dirCache, rootCache, rowsByPath, - flatRows, + flatRows: visibleFlatRows, loadDir, - setSelectedPath, + setSelectedPath: setSingleSelectedPath, setFlashingPath, flashTimeoutRef, virtualizer @@ -242,8 +270,8 @@ function FileExplorerInner(): React.JSX.Element { pendingExplorerReveal, openFiles, rowsByPath, - flatRows, - setSelectedPath, + flatRows: visibleFlatRows, + setSelectedPath: setSingleSelectedPath, virtualizer }) @@ -256,8 +284,9 @@ function FileExplorerInner(): React.JSX.Element { const selectedNode = selectedPath ? (rowsByPath.get(selectedPath) ?? null) : null useFileExplorerKeys({ containerRef: explorerShellRef, - flatRows, + flatRows: visibleFlatRows, inlineInput, + selectedPaths, selectedNode, startRename, requestDelete @@ -268,11 +297,25 @@ function FileExplorerInner(): React.JSX.Element { openFile, pinFile, toggleDir, - setSelectedPath, + setSelectedPath: setSingleSelectedPath, scrollRef }) const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir }) + const handleRowClick = useCallback( + (node: (typeof visibleFlatRows)[number], event: React.MouseEvent) => + selectRowWithModifiers(node, event, handleClick), + [handleClick, selectRowWithModifiers] + ) + const handleCollapseFolderSubtree = useCallback( + (node: (typeof flatRows)[number]) => { + if (!activeWorktreeId || !node.isDirectory) { + return + } + collapseDirSubtree(activeWorktreeId, node.path) + }, + [activeWorktreeId, collapseDirSubtree] + ) if (!worktreePath) { return ( @@ -286,7 +329,7 @@ function FileExplorerInner(): React.JSX.Element { // and empty states so the data-native-file-drop-target marker is always // present. Without this, external file drops would have no target surface // when the tree is empty, still loading, or showing a read error. - const isEmptyState = flatRows.length === 0 && !inlineInput + const isEmptyState = visibleFlatRows.length === 0 && !inlineInput const isLoading = isEmptyState && (rootCache?.loading ?? true) const hasError = isEmptyState && !isLoading && !!rootError const isEmpty = isEmptyState && !isLoading && !hasError @@ -295,7 +338,15 @@ function FileExplorerInner(): React.JSX.Element { return ( <>
- + void + selectionSize: number + onClick: (event: React.MouseEvent) => void onDoubleClick: () => void - onSelect: () => void + onContextMenuSelect: () => void + onCopyPaths: (pathKind: 'absolute' | 'relative') => void onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void onStartRename: (node: TreeNode) => void onDuplicate: (node: TreeNode) => void onRequestDelete: () => void + onCollapseFolderSubtree: () => void onMoveDrop: (sourcePath: string, destDir: string) => void onDragTargetChange: (dir: string | null) => void onDragSourceChange: (path: string | null) => void @@ -215,6 +223,10 @@ type FileExplorerRowProps = { onNativeDragExpandDir: (dirPath: string) => void } +export function shouldShowCollapseFolderAction(node: TreeNode, isExpanded: boolean): boolean { + return node.isDirectory && isExpanded +} + export function FileExplorerRow({ node, isExpanded, @@ -223,16 +235,20 @@ export function FileExplorerRow({ isFlashing, nodeStatus, statusColor, + isIgnored, deleteShortcutLabel, targetDir, targetDepth, + selectionSize, onClick, onDoubleClick, - onSelect, + onContextMenuSelect, + onCopyPaths, onStartNew, onStartRename, onDuplicate, onRequestDelete, + onCollapseFolderSubtree, onMoveDrop, onDragTargetChange, onDragSourceChange, @@ -242,6 +258,7 @@ export function FileExplorerRow({ }: FileExplorerRowProps): React.JSX.Element { const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const FileIcon = getFileTypeIcon(node.relativePath || node.name) const rowDropDir = node.isDirectory ? node.path : targetDir const { handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({ rowDropDir, @@ -280,8 +297,7 @@ export function FileExplorerRow({ onDrop={handleDrop} onClick={onClick} onDoubleClick={onDoubleClick} - onFocus={onSelect} - onContextMenu={onSelect} + onContextMenu={onContextMenuSelect} > {node.isDirectory ? ( <> @@ -302,12 +318,22 @@ export function FileExplorerRow({ ) : ( <> - + )} { // Why: the row itself swallows double-click for "pin preview" / // directory toggle. Scope rename to the filename text only so @@ -319,14 +345,20 @@ export function FileExplorerRow({ > {node.name} - {nodeStatus && ( + {nodeStatus ? ( {STATUS_LABELS[nodeStatus]} - )} + ) : isIgnored ? ( + + ) : null} - window.api.ui.writeClipboardText(node.path)}> + onCopyPaths('absolute')}> - Copy Path + {selectionSize > 1 ? 'Copy Paths' : 'Copy Path'} {isMac ? '⌥⌘C' : 'Shift+Alt+C'} - window.api.ui.writeClipboardText(node.relativePath)}> + onCopyPaths('relative')}> - Copy Relative Path + {selectionSize > 1 ? 'Copy Relative Paths' : 'Copy Relative Path'} {isMac ? '⌥⇧⌘C' : 'Ctrl+Shift+Alt+C'} {!node.isDirectory && ( @@ -373,6 +405,12 @@ export function FileExplorerRow({ Open Markdown Preview )} + {shouldShowCollapseFolderAction(node, isExpanded) && ( + + + Collapse Folder + + )} { const state = useAppStore.getState() diff --git a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx index 5f94ade1c90..acc477ab0b7 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Loader2, RefreshCw } from 'lucide-react' +import { Eye, EyeOff, ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -10,12 +10,23 @@ type FileExplorerToolbarProps = { showRefreshSpinner: boolean handleRefresh: () => void } + canCollapseAll: boolean + onCollapseAll: () => void + showGitIgnoredFilesToggle: boolean + showGitIgnoredFiles: boolean + onToggleGitIgnoredFiles: () => void } export function FileExplorerToolbar({ repoName, - refresh + refresh, + canCollapseAll, + onCollapseAll, + showGitIgnoredFilesToggle, + showGitIgnoredFiles, + onToggleGitIgnoredFiles }: FileExplorerToolbarProps): React.JSX.Element { + const gitIgnoredLabel = showGitIgnoredFiles ? 'Hide Git Ignored Files' : 'Show Git Ignored Files' return (
{repoName} + {showGitIgnoredFilesToggle ? ( + + + + + + {gitIgnoredLabel} + + + ) : null} + + + + + + Collapse All + + + ) +} + +export function GitHistoryPanel({ + state, + collapsed, + onToggle, + onRefresh, + onOpenCommit +}: { + state: GitHistoryPanelState + collapsed: boolean + onToggle: () => void + onRefresh: () => void + onOpenCommit?: (item: GitHistoryItem) => void +}): React.JSX.Element | null { + const result = state.result + const viewModels = useMemo(() => { + if (!result) { + return [] + } + return buildGitHistoryViewModels( + result.items, + buildDefaultGitHistoryColorMap(result), + result.currentRef, + result.remoteRef, + result.baseRef, + result.hasIncomingChanges, + result.hasOutgoingChanges, + result.mergeBase + ) + }, [result]) + + const loading = state.status === 'loading' || state.status === 'refreshing' + const count = result?.items.length ?? 0 + + if (!result && state.status === 'idle') { + return null + } + + return ( +
+
+
+ + + + + + + Refs are branch or tag names pointing at that exact commit. They only appear where Git + has a named ref for the commit. + + + + + + + + Refresh graph + + +
+
+ {!collapsed && state.status === 'error' && !result && ( +
{state.error}
+ )} + {!collapsed && state.status === 'loading' && !result && ( +
+ + Loading graph... +
+ )} + {!collapsed && result && viewModels.length === 0 && ( +
No commits yet
+ )} + {!collapsed && viewModels.length > 0 && ( +
+ {viewModels.map((viewModel) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx index 17a7fdac4b8..9314bc67ef1 100644 --- a/src/renderer/src/components/right-sidebar/PRActions.tsx +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -28,6 +28,9 @@ export default function PRActions({ }): React.JSX.Element | null { const openModal = useAppStore((s) => s.openModal) const skipDeleteConfirm = useAppStore((s) => s.settings?.skipDeleteWorktreeConfirm ?? false) + const isDeletingWorktree = useAppStore( + (s) => s.deleteStateByWorktreeId[worktree.id]?.isDeleting ?? false + ) const [merging, setMerging] = useState(false) const [mergeError, setMergeError] = useState(null) const [mergeMenuOpen, setMergeMenuOpen] = useState(false) @@ -174,11 +177,16 @@ export default function PRActions({ type="button" variant="secondary" size="xs" - className="w-full text-[11px]" + className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleDeleteWorktree} + disabled={isDeletingWorktree} > - - Delete Worktree + {isDeletingWorktree ? ( + + ) : ( + + )} + {isDeletingWorktree ? 'Deleting…' : 'Delete Worktree'} ) } diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index c5c535317d0..6e07502817f 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -33,8 +33,10 @@ export default function Search(): React.JSX.Element { const fileSearchResults = searchState?.results ?? null const fileSearchLoading = searchState?.loading ?? false const fileSearchCollapsedFiles = searchState?.collapsedFiles ?? EMPTY_COLLAPSED_FILES + const fileSearchSeedRequestId = searchState?.seedRequestId const updateFileSearchState = useAppStore((s) => s.updateFileSearchState) + const consumeFileSearchSeedRequest = useAppStore((s) => s.consumeFileSearchSeedRequest) const toggleFileSearchCollapsedFile = useAppStore((s) => s.toggleFileSearchCollapsedFile) const clearFileSearch = useAppStore((s) => s.clearFileSearch) @@ -44,6 +46,7 @@ export default function Search(): React.JSX.Element { const resultsScrollRef = useRef(null) const revealRafRef = useRef(null) const revealInnerRafRef = useRef(null) + const seededInputSelectionRafRef = useRef(null) const includeInputRef = useRef(null) const excludeInputRef = useRef(null) @@ -85,6 +88,24 @@ export default function Search(): React.JSX.Element { const worktreePath = activeWorktree?.path ?? null + const cancelSeededInputSelectionFrame = useCallback(() => { + if (seededInputSelectionRafRef.current !== null) { + cancelAnimationFrame(seededInputSelectionRafRef.current) + seededInputSelectionRafRef.current = null + } + }, []) + + const scheduleSeededInputSelection = useCallback(() => { + cancelSeededInputSelectionFrame() + // Why: match VS Code's seeded file search behavior; typing should replace + // the selected query after the sidebar finishes opening/loading. + seededInputSelectionRafRef.current = requestAnimationFrame(() => { + seededInputSelectionRafRef.current = null + inputRef.current?.focus() + inputRef.current?.select() + }) + }, [cancelSeededInputSelectionFrame]) + // Focus input on mount useEffect(() => { inputRef.current?.focus() @@ -94,10 +115,11 @@ export default function Search(): React.JSX.Element { useEffect(() => { return () => { cancelPendingSearch() + cancelSeededInputSelectionFrame() cancelRevealFrame(revealRafRef) cancelRevealFrame(revealInnerRafRef) } - }, [cancelPendingSearch]) + }, [cancelPendingSearch, cancelSeededInputSelectionFrame]) useEffect(() => { if (!worktreePath) { @@ -215,6 +237,27 @@ export default function Search(): React.JSX.Element { [worktreePath, updateActiveSearchState, activeWorktreeId] ) + useEffect(() => { + if (!activeWorktreeId || fileSearchSeedRequestId === undefined) { + return + } + + // Why: Cmd/Ctrl+Shift+F can seed the query before this lazy panel mounts. + // The one-shot request lets the mounted panel run the real runtime search. + if (fileSearchQuery.trim()) { + executeSearch(fileSearchQuery) + scheduleSeededInputSelection() + } + consumeFileSearchSeedRequest(activeWorktreeId, fileSearchSeedRequestId) + }, [ + activeWorktreeId, + consumeFileSearchSeedRequest, + executeSearch, + fileSearchQuery, + fileSearchSeedRequestId, + scheduleSeededInputSelection + ]) + const handleClearSearch = useCallback(() => { cancelPendingSearch() clearActiveSearch() diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx index 9ba66270ef6..6221515aeb1 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react' import { ChevronRight, Copy } from 'lucide-react' import { basename, dirname } from '@/lib/path' import { cn } from '@/lib/utils' +import { getFileTypeIcon } from '@/lib/file-type-icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { @@ -61,6 +62,7 @@ export function FileResultRow({ const fileName = basename(fileResult.relativePath) const parentDir = dirname(fileResult.relativePath) const dirPath = parentDir === '.' ? '' : parentDir + const FileIcon = getFileTypeIcon(fileResult.relativePath) return (
@@ -82,6 +84,7 @@ export function FileResultRow({ !collapsed && 'rotate-90' )} /> +
{fileName} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 1e05fb0ab0b..afcc064b98e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -12,25 +12,27 @@ import { Sparkles, Square, Undo2, - FileEdit, - FileMinus, - FilePlus, - FileQuestion, - ArrowRightLeft, Check, Copy, + Folder, FolderOpen, GitMerge, GitPullRequestArrow, + List, + ListTree, MessageSquare, Send, Trash, + Trash2, TriangleAlert, CircleCheck, Search, - X + X, + MoreHorizontal, + type LucideIcon } from 'lucide-react' import { useAppStore } from '@/store' +import { resolveRemoteOperationErrorMessage } from '@/store/slices/editor' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { detectLanguage } from '@/lib/language-detect' @@ -65,6 +67,15 @@ import { runDiscardAllForArea, type DiscardAllArea } from './discard-all-sequence' +import { getFileTypeIcon } from '@/lib/file-type-icons' +import { + buildGitStatusSourceControlTree, + buildSourceControlTree, + collectSourceControlTreeFileEntries, + compactSourceControlTree, + flattenSourceControlTree, + type SourceControlTreeNode +} from './source-control-tree' import { getDiscardAreaConfirmationCopy, getDiscardEntryConfirmationCopy, @@ -80,6 +91,7 @@ import { } from '@/components/ui/context-menu' import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -97,17 +109,24 @@ import { } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' import { + bulkDiscardRuntimeGitPaths, bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, + cancelRuntimeGenerateCommitMessage, commitRuntimeGit, discardRuntimeGitPath, + generateRuntimeCommitMessage, getRuntimeGitBranchCompare, + getRuntimeGitCommitCompare, + getRuntimeGitHistory, stageRuntimeGitPath, unstageRuntimeGitPath } from '@/runtime/runtime-git-client' import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' import { CreatePullRequestDialog } from './CreatePullRequestDialog' +import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' +import type { GitHistoryItem } from '../../../../shared/git-history' import type { DiffComment, GitBranchChangeEntry, @@ -122,20 +141,15 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { STATUS_COLORS, STATUS_LABELS } from './status-display' +import { + isCustomAgentId, + resolveCommitMessageAgentChoice +} from '../../../../shared/commit-message-agent-spec' +import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary' type SourceControlScope = 'all' | 'uncommitted' - -const STATUS_ICONS: Record< - string, - React.ComponentType<{ className?: string; style?: React.CSSProperties }> -> = { - modified: FileEdit, - added: FilePlus, - deleted: FileMinus, - renamed: ArrowRightLeft, - untracked: FileQuestion, - copied: FilePlus -} +type SourceControlViewMode = 'list' | 'tree' +type RemoteActionError = { kind: RemoteOpKind; message: string } // Why: directional signifiers ahead of each primary action label. Commit // (✓) is affirmative; Push (↑) points in the direction data flows; Sync @@ -170,6 +184,19 @@ const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = { } const BRANCH_REFRESH_INTERVAL_MS = 5000 +// Why: row action buttons host Radix Tooltip triggers. Keeping the overlay +// measurable prevents transient top-left tooltip placement during hover. +const SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS = + 'absolute right-0 top-0 bottom-0 flex shrink-0 items-center gap-1.5 bg-accent pr-3 pl-2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto' +const SOURCE_CONTROL_TREE_INDENT_PX = 12 +const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8 +const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20 +const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } +const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const + +function createDefaultCollapsedSections(): Set { + return new Set(DEFAULT_COLLAPSED_SECTIONS) +} // Why: the pure state-machine logic now lives in // ./source-control-primary-action.ts. It is imported directly by callers @@ -181,6 +208,44 @@ type PendingDiscardConfirmation = | { kind: 'entry'; entry: GitStatusEntry } | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] } +type GitStatusSourceControlTreeNode = SourceControlTreeNode< + GitStatusEntry, + (typeof SECTION_ORDER)[number] +> +type SourceControlTreeDirectoryNode = Extract +type BranchSourceControlTreeNode = SourceControlTreeNode +type BranchSourceControlTreeDirectoryNode = Extract< + BranchSourceControlTreeNode, + { type: 'directory' } +> + +type SourceControlDirectoryActionPaths = { + stagePaths: string[] + unstagePaths: string[] + discardPaths: string[] +} + +function getSourceControlDirectoryActionPaths( + node: SourceControlTreeDirectoryNode +): SourceControlDirectoryActionPaths { + const entries = collectSourceControlTreeFileEntries(node) + return { + stagePaths: + node.area === 'unstaged' || node.area === 'untracked' + ? getStageAllPaths(entries, node.area) + : [], + unstagePaths: node.area === 'staged' ? getUnstageAllPaths(entries) : [], + discardPaths: + node.area === 'unstaged' || node.area === 'untracked' + ? getDiscardAllPaths(entries, node.area) + : [] + } +} + +type PendingDiffCommentsClear = + | { kind: 'all'; worktreeId: string } + | { kind: 'file'; worktreeId: string; filePath: string } + export function readCommitDraftForWorktree( drafts: CommitDraftsByWorktree, worktreeId: string | null | undefined @@ -219,6 +284,15 @@ function hostedReviewStateClass(review: HostedReviewInfo): string { return 'text-muted-foreground/50' } +function resolveRemoteActionError(kind: RemoteOpKind, error: unknown): string { + return resolveRemoteOperationErrorMessage(error, { + publish: kind === 'publish', + isPush: kind === 'push', + isSync: kind === 'sync', + isFetch: kind === 'fetch' + }) +} + function HostedReviewIcon({ review, className @@ -282,7 +356,10 @@ function SourceControlInner(): React.JSX.Element { const openBranchDiff = useAppStore((s) => s.openBranchDiff) const openAllDiffs = useAppStore((s) => s.openAllDiffs) const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) + const openCommitAllDiffs = useAppStore((s) => s.openCommitAllDiffs) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) + const clearDiffComments = useAppStore((s) => s.clearDiffComments) + const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile) const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId) const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) @@ -309,6 +386,9 @@ function SourceControlInner(): React.JSX.Element { ) const [diffCommentsExpanded, setDiffCommentsExpanded] = useState(false) const [diffCommentsCopied, setDiffCommentsCopied] = useState(false) + const [pendingDiffCommentsClear, setPendingDiffCommentsClear] = + useState(null) + const [isClearingDiffComments, setIsClearingDiffComments] = useState(false) const handleCopyDiffComments = useCallback(async (): Promise => { if (diffCommentsForActive.length === 0) { @@ -333,8 +413,78 @@ function SourceControlInner(): React.JSX.Element { return () => window.clearTimeout(handle) }, [diffCommentsCopied]) + const pendingDiffCommentsClearCount = useMemo(() => { + if (!pendingDiffCommentsClear || pendingDiffCommentsClear.worktreeId !== activeWorktreeId) { + return 0 + } + if (pendingDiffCommentsClear.kind === 'all') { + return diffCommentsForActive.length + } + return diffCommentsForActive.filter((c) => c.filePath === pendingDiffCommentsClear.filePath) + .length + }, [activeWorktreeId, diffCommentsForActive, pendingDiffCommentsClear]) + + const pendingDiffCommentsClearDescription = pendingDiffCommentsClear + ? pendingDiffCommentsClear.kind === 'all' + ? `Clear ${pendingDiffCommentsClearCount} ${pendingDiffCommentsClearCount === 1 ? 'note' : 'notes'} from this worktree?` + : `Clear ${pendingDiffCommentsClearCount} ${pendingDiffCommentsClearCount === 1 ? 'note' : 'notes'} from ${pendingDiffCommentsClear.filePath}?` + : '' + + useEffect(() => { + if (!pendingDiffCommentsClear || isClearingDiffComments) { + return + } + if ( + pendingDiffCommentsClear.worktreeId !== activeWorktreeId || + pendingDiffCommentsClearCount === 0 + ) { + setPendingDiffCommentsClear(null) + } + }, [ + activeWorktreeId, + isClearingDiffComments, + pendingDiffCommentsClear, + pendingDiffCommentsClearCount + ]) + + const handleConfirmDiffCommentsClear = useCallback(async (): Promise => { + const pending = pendingDiffCommentsClear + if (!pending || isClearingDiffComments || pending.worktreeId !== activeWorktreeId) { + return + } + if (pendingDiffCommentsClearCount === 0) { + setPendingDiffCommentsClear(null) + return + } + setIsClearingDiffComments(true) + try { + const ok = + pending.kind === 'all' + ? await clearDiffComments(pending.worktreeId) + : await clearDiffCommentsForFile(pending.worktreeId, pending.filePath) + if (ok) { + setPendingDiffCommentsClear(null) + } else { + toast.error('Failed to clear notes.') + } + } finally { + setIsClearingDiffComments(false) + } + }, [ + activeWorktreeId, + clearDiffComments, + clearDiffCommentsForFile, + isClearingDiffComments, + pendingDiffCommentsClear, + pendingDiffCommentsClearCount + ]) + const [scope, setScope] = useState('all') - const [collapsedSections, setCollapsedSections] = useState>(new Set()) + const [collapsedSections, setCollapsedSections] = useState>( + createDefaultCollapsedSections + ) + const [sourceControlViewMode, setSourceControlViewMode] = useState('list') + const [collapsedTreeDirs, setCollapsedTreeDirs] = useState>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(null) // Why: start null rather than 'origin/main' so branch compare doesn't fire @@ -346,6 +496,9 @@ function SourceControlInner(): React.JSX.Element { // so switching worktrees restores each draft instead of wiping it. const [commitDrafts, setCommitDrafts] = useState({}) const [commitErrors, setCommitErrors] = useState>({}) + const [remoteActionErrors, setRemoteActionErrors] = useState< + Record + >({}) // Why: keep commit-in-flight state per-worktree. A single boolean would be // cleared when the user switched worktrees, letting them double-click Commit // on worktree A after briefly navigating to B and back while A's original @@ -368,9 +521,22 @@ function SourceControlInner(): React.JSX.Element { const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false) const [createPrPushFirst, setCreatePrPushFirst] = useState(false) const commitMessageAi = useAppStore((s) => s.settings?.commitMessageAi) + const effectiveCommitMessageAgentId = useMemo( + () => resolveCommitMessageAgentChoice(commitMessageAi?.agentId, settings?.defaultTuiAgent), + [commitMessageAi?.agentId, settings?.defaultTuiAgent] + ) const filterInputRef = useRef(null) const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null + const remoteActionError = remoteActionErrors[activeWorktreeId ?? ''] ?? null + const [gitHistoryByWorktree, setGitHistoryByWorktree] = useState< + Record + >({}) + const gitHistoryRequestSeqRef = useRef(0) + const gitHistoryRequestByWorktreeRef = useRef>({}) + const gitHistoryState = activeWorktreeId + ? (gitHistoryByWorktree[activeWorktreeId] ?? EMPTY_GIT_HISTORY_STATE) + : EMPTY_GIT_HISTORY_STATE const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null @@ -491,8 +657,16 @@ function SourceControlInner(): React.JSX.Element { const linkedGitHubPR = activeWorktree?.linkedPR ?? null const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null + // Why: when activeRepo.connectionId is truthy, neither the SourceControl + // effect below nor WorktreeCard.tsx fetches hostedReview for this branch, + // so hostedReviewEntry would stay undefined forever and would permanently + // block Publish Branch on SSH-backed worktrees with a linkedPR/linkedGitLabMR + // and no upstream. Skip the loading state for those repos so the publish + // gate doesn't latch. const isHostedReviewStateLoading = - (linkedGitHubPR !== null || linkedGitLabMR !== null) && hostedReviewEntry === undefined + !activeRepo?.connectionId && + (linkedGitHubPR !== null || linkedGitLabMR !== null) && + hostedReviewEntry === undefined useEffect(() => { if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') { return @@ -505,7 +679,8 @@ function SourceControlInner(): React.JSX.Element { void fetchHostedReviewForBranch(activeRepo.path, branchName, { repoId: activeRepo.id, linkedGitHubPR, - linkedGitLabMR + linkedGitLabMR, + staleWhileRevalidate: true }) }, [ activeRepo, @@ -525,6 +700,7 @@ function SourceControlInner(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: activeRepo.path, + ...(worktreePath ? { worktreePath } : {}), branch: branchName, base: effectiveBaseRef ?? null, hasUncommittedChanges: hasUncommittedEntries, @@ -560,7 +736,8 @@ function SourceControlInner(): React.JSX.Element { linkedGitLabMR, remoteStatus?.ahead, remoteStatus?.behind, - remoteStatus?.hasUpstream + remoteStatus?.hasUpstream, + worktreePath ]) const grouped = useMemo(() => { @@ -610,6 +787,58 @@ function SourceControlInner(): React.JSX.Element { return arr }, [filteredGrouped, collapsedSections]) + const treeRootsByArea = useMemo( + () => ({ + staged: compactSourceControlTree( + buildGitStatusSourceControlTree('staged', filteredGrouped.staged) + ), + unstaged: compactSourceControlTree( + buildGitStatusSourceControlTree('unstaged', filteredGrouped.unstaged) + ), + untracked: compactSourceControlTree( + buildGitStatusSourceControlTree('untracked', filteredGrouped.untracked) + ) + }), + [filteredGrouped] + ) + + const visibleTreeRowsByArea = useMemo( + () => ({ + staged: flattenSourceControlTree(treeRootsByArea.staged, collapsedTreeDirs), + unstaged: flattenSourceControlTree(treeRootsByArea.unstaged, collapsedTreeDirs), + untracked: flattenSourceControlTree(treeRootsByArea.untracked, collapsedTreeDirs) + }), + [collapsedTreeDirs, treeRootsByArea] + ) + + const branchTreeRoots = useMemo( + () => compactSourceControlTree(buildSourceControlTree('branch', filteredBranchEntries)), + [filteredBranchEntries] + ) + const visibleBranchTreeRows = useMemo( + () => flattenSourceControlTree(branchTreeRoots, collapsedTreeDirs), + [branchTreeRoots, collapsedTreeDirs] + ) + + const visibleSelectionEntries = useMemo(() => { + if (sourceControlViewMode === 'list') { + return flatEntries + } + + const arr: FlatEntry[] = [] + for (const area of SECTION_ORDER) { + if (collapsedSections.has(area)) { + continue + } + for (const node of visibleTreeRowsByArea[area]) { + if (node.type === 'file') { + arr.push({ key: node.key, entry: node.entry, area: node.area }) + } + } + } + return arr + }, [collapsedSections, flatEntries, sourceControlViewMode, visibleTreeRowsByArea]) + const [isExecutingBulk, setIsExecutingBulk] = useState(false) const pendingDiscardCopy = useMemo(() => { if (!pendingDiscard) { @@ -654,9 +883,11 @@ function SourceControlInner(): React.JSX.Element { } setCommitDrafts((prev) => pruneRecord(prev)) setCommitErrors((prev) => pruneRecord(prev)) + setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateErrors((prev) => pruneRecord(prev)) + setGitHistoryByWorktree((prev) => pruneRecord(prev)) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { if (!worktreeMap.has(key)) { @@ -668,6 +899,11 @@ function SourceControlInner(): React.JSX.Element { delete generateInFlightRef.current[key] } } + for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { + if (!worktreeMap.has(key)) { + delete gitHistoryRequestByWorktreeRef.current[key] + } + } }, [worktreeMap]) // Why: the sidebar no longer uses key={activeWorktreeId} to force a full @@ -676,9 +912,12 @@ function SourceControlInner(): React.JSX.Element { // worktree's UI state doesn't leak into the new one. useEffect(() => { setScope('all') - setCollapsedSections(new Set()) + setCollapsedSections(createDefaultCollapsedSections()) + setCollapsedTreeDirs(new Set()) setBaseRefDialogOpen(false) setPendingDiscard(null) + setPendingDiffCommentsClear(null) + setIsClearingDiffComments(false) // Why: do NOT reset defaultBaseRef here. It is repo-scoped, not // worktree-scoped, and is resolved by the effect above on activeRepo // change. Resetting it to a hard-coded 'origin/main' on every worktree @@ -773,6 +1012,7 @@ function SourceControlInner(): React.JSX.Element { ) } void refreshBranchCompareRef.current() + void refreshGitHistoryRef.current() return true } catch (error) { setCommitErrors((prev) => ({ @@ -802,11 +1042,11 @@ function SourceControlInner(): React.JSX.Element { if (generateInFlightRef.current[activeWorktreeId]) { return } - if (!commitMessageAi?.enabled || !commitMessageAi.agentId) { + if (!commitMessageAi?.enabled || !effectiveCommitMessageAgentId) { return } - if (commitMessageAi.agentId === 'custom') { + if (isCustomAgentId(effectiveCommitMessageAgentId)) { const command = commitMessageAi.customAgentCommand?.trim() ?? '' if (!command) { setGenerateErrors((prev) => ({ @@ -823,12 +1063,12 @@ function SourceControlInner(): React.JSX.Element { setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { - const result = (await window.api.git.generateCommitMessage({ + const result = await generateRuntimeCommitMessage({ + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, worktreePath, connectionId - })) as - | { success: true; message: string; agentLabel?: string } - | { success: false; error: string; canceled?: boolean } + }) if (!result.success) { // Why: cancellation is a deliberate user action, not a failure to @@ -865,7 +1105,7 @@ function SourceControlInner(): React.JSX.Element { setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) generateInFlightRef.current[activeWorktreeId] = false } - }, [activeWorktreeId, commitMessageAi, worktreePath]) + }, [activeWorktreeId, commitMessageAi, effectiveCommitMessageAgentId, worktreePath]) const handleCancelGenerate = useCallback((): void => { if (!activeWorktreeId || !worktreePath) { @@ -878,7 +1118,12 @@ function SourceControlInner(): React.JSX.Element { // Why: fire-and-forget — the in-flight generateCommitMessage promise // resolves with `{canceled: true}` once the kill propagates, which is // where the spinner is cleared. Awaiting here would just delay UI feedback. - void window.api.git.cancelGenerateCommitMessage({ worktreePath, connectionId }) + void cancelRuntimeGenerateCommitMessage({ + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }) }, [activeWorktreeId, worktreePath]) // Why: a single dispatcher for every remote-only action the split button or @@ -891,6 +1136,7 @@ function SourceControlInner(): React.JSX.Element { return } const connectionId = getConnectionId(activeWorktreeId) ?? undefined + setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) try { if (kind === 'publish') { await pushBranch( @@ -921,9 +1167,21 @@ function SourceControlInner(): React.JSX.Element { return } await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) - } catch { + setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep // one consistent toast path and avoid duplicate notifications in the UI. + // Keep the latest failure inline too: dropdown-only actions like Fetch can + // otherwise look like nothing happened once the menu closes. + setRemoteActionErrors((prev) => ({ + ...prev, + [activeWorktreeId]: { + kind, + message: resolveRemoteActionError(kind, error) + } + })) + } finally { + void refreshGitHistoryRef.current() } }, [ @@ -997,11 +1255,13 @@ function SourceControlInner(): React.JSX.Element { await Promise.all([ fetchHostedReviewForBranch(activeRepo.path, branchName, { force: true, + repoId: activeRepo.id, linkedGitHubPR: result.number, linkedGitLabMR }), fetchPRForBranch(activeRepo.path, branchName, { force: true, + repoId: activeRepo.id, linkedPRNumber: result.number }) ]) @@ -1026,12 +1286,20 @@ function SourceControlInner(): React.JSX.Element { ) const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0 + const hasPartiallyStagedChanges = useMemo(() => { + if (grouped.staged.length === 0 || grouped.unstaged.length === 0) { + return false + } + const unstagedPaths = new Set(grouped.unstaged.map((entry) => entry.path)) + return grouped.staged.some((entry) => unstagedPaths.has(entry.path)) + }, [grouped.staged, grouped.unstaged]) const primaryAction: PrimaryAction = useMemo( () => resolvePrimaryAction({ stagedCount: grouped.staged.length, hasUnstagedChanges, + hasPartiallyStagedChanges, hasMessage: commitMessage.trim().length > 0, hasUnresolvedConflicts: unresolvedConflicts.length > 0, isCommitting, @@ -1040,18 +1308,23 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, grouped.staged.length, hasUnstagedChanges, + hasPartiallyStagedChanges, isCommitting, isRemoteOperationActive, inFlightRemoteOpKind, hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -1062,6 +1335,7 @@ function SourceControlInner(): React.JSX.Element { resolveDropdownItems({ stagedCount: grouped.staged.length, hasUnstagedChanges, + hasPartiallyStagedChanges, hasMessage: commitMessage.trim().length > 0, hasUnresolvedConflicts: unresolvedConflicts.length > 0, isCommitting, @@ -1070,18 +1344,23 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, grouped.staged.length, hasUnstagedChanges, + hasPartiallyStagedChanges, isCommitting, isRemoteOperationActive, inFlightRemoteOpKind, hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -1176,15 +1455,15 @@ function SourceControlInner(): React.JSX.Element { const { selectedKeys, handleSelect, handleContextMenu, clearSelection } = useSourceControlSelection({ - flatEntries, + flatEntries: visibleSelectionEntries, onOpenDiff: handleOpenDiff, containerRef: sourceControlRef }) - // clear selection on scope change + // clear selection on scope or list/tree presentation change useEffect(() => { clearSelection() - }, [scope, clearSelection]) + }, [scope, sourceControlViewMode, clearSelection]) // Clear selection on worktree or tab change useEffect(() => { @@ -1192,8 +1471,8 @@ function SourceControlInner(): React.JSX.Element { }, [activeWorktreeId, rightSidebarTab, clearSelection]) const flatEntriesByKey = useMemo( - () => new Map(flatEntries.map((entry) => [entry.key, entry])), - [flatEntries] + () => new Map(visibleSelectionEntries.map((entry) => [entry.key, entry])), + [visibleSelectionEntries] ) const selectedEntries = useMemo( @@ -1282,6 +1561,70 @@ function SourceControlInner(): React.JSX.Element { refreshActiveGitStatusAfterMutation ]) + const handleStageAllPaths = useCallback( + async (paths: readonly string[]) => { + if (!worktreePath || isExecutingBulk || paths.length === 0) { + return + } + setIsExecutingBulk(true) + try { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await bulkStageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + [...paths] + ) + await refreshActiveGitStatusAfterMutation() + clearSelection() + } finally { + setIsExecutingBulk(false) + } + }, + [ + activeWorktreeId, + clearSelection, + isExecutingBulk, + refreshActiveGitStatusAfterMutation, + worktreePath + ] + ) + + const handleUnstagePaths = useCallback( + async (paths: readonly string[]) => { + if (!worktreePath || isExecutingBulk || paths.length === 0) { + return + } + setIsExecutingBulk(true) + try { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + await bulkUnstageRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + [...paths] + ) + await refreshActiveGitStatusAfterMutation() + clearSelection() + } finally { + setIsExecutingBulk(false) + } + }, + [ + activeWorktreeId, + clearSelection, + isExecutingBulk, + refreshActiveGitStatusAfterMutation, + worktreePath + ] + ) + // Why: "Stage all" on the Changes section intentionally skips unresolved // conflict rows. `git add` on a conflicted file silently clears the `u` // record — the only live signal we have — before the user has reviewed it, @@ -1494,6 +1837,60 @@ function SourceControlInner(): React.JSX.Element { const refreshBranchCompareRef = useRef(refreshBranchCompare) refreshBranchCompareRef.current = refreshBranchCompare + const refreshGitHistory = useCallback(async (): Promise => { + if (!activeWorktreeId || !worktreePath || isFolder || !isBranchVisible) { + return + } + + const worktreeId = activeWorktreeId + const requestId = gitHistoryRequestSeqRef.current + 1 + gitHistoryRequestSeqRef.current = requestId + gitHistoryRequestByWorktreeRef.current[worktreeId] = requestId + setGitHistoryByWorktree((prev) => { + const previous = prev[worktreeId] + return { + ...prev, + [worktreeId]: previous?.result + ? { status: 'refreshing', result: previous.result } + : { status: 'loading' } + } + }) + + try { + const connectionId = getConnectionId(worktreeId) ?? undefined + const result = await getRuntimeGitHistory( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { limit: 50, baseRef: effectiveBaseRef } + ) + if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) { + return + } + setGitHistoryByWorktree((prev) => ({ ...prev, [worktreeId]: { status: 'ready', result } })) + } catch (error) { + if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) { + return + } + const message = error instanceof Error ? error.message : 'Failed to load git graph' + setGitHistoryByWorktree((prev) => { + const previous = prev[worktreeId] + return { + ...prev, + [worktreeId]: previous?.result + ? { status: 'error', result: previous.result, error: message } + : { status: 'error', error: message } + } + }) + } + }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + + const refreshGitHistoryRef = useRef(refreshGitHistory) + refreshGitHistoryRef.current = refreshGitHistory + useEffect(() => { if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) { return @@ -1516,6 +1913,16 @@ function SourceControlInner(): React.JSX.Element { } }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + useEffect(() => { + // Why: history shells out to git, but unlike branch compare it only needs + // visible-load and mutation refreshes. Avoid polling so long sessions don't + // spawn git processes for a decorative graph. + if (!isBranchVisible) { + return + } + void refreshGitHistoryRef.current() + }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + useEffect(() => { // Why: gate on isBranchVisible so we don't spawn git processes while the // sidebar is closed. Store-slice remote operations refresh upstream-status @@ -1540,6 +1947,18 @@ function SourceControlInner(): React.JSX.Element { }) }, []) + const toggleTreeDir = useCallback((key: string) => { + setCollapsedTreeDirs((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + }, []) + const openCommittedDiff = useCallback( (entry: GitBranchChangeEntry) => { if ( @@ -1561,6 +1980,42 @@ function SourceControlInner(): React.JSX.Element { [activeWorktreeId, branchSummary, openBranchDiff, worktreePath] ) + const openHistoryCommitDiff = useCallback( + async (item: GitHistoryItem): Promise => { + if (!activeWorktreeId || !worktreePath) { + return + } + + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const result = await getRuntimeGitCommitCompare( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + item.id + ) + if (result.summary.status !== 'ready') { + toast.error(result.summary.errorMessage ?? 'Failed to load commit diff') + return + } + openCommitAllDiffs( + activeWorktreeId, + worktreePath, + result.summary, + result.entries, + item.subject, + item.message + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load commit diff') + } + }, + [activeWorktreeId, openCommitAllDiffs, worktreePath] + ) + // Why: a note's filePath is the same relative path used by GitStatusEntry / // GitBranchChangeEntry, so we can route the click to whichever diff surface // currently owns that file. Prefer the `unstaged` entry when a path is also @@ -1767,7 +2222,15 @@ function SourceControlInner(): React.JSX.Element { ) ) const connectionId = getConnectionId(activeWorktreeId) ?? undefined - await window.api.git.bulkDiscard({ worktreePath, filePaths, connectionId }) + await bulkDiscardRuntimeGitPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + filePaths + ) for (const relativePath of filePaths) { notifyEditorExternalFileChange({ worktreeId: activeWorktreeId, @@ -1942,6 +2405,8 @@ function SourceControlInner(): React.JSX.Element { open={createPrDialogOpen} repoId={activeRepo.id} repoPath={activeRepo.path} + worktreeId={currentWorktreeId} + worktreePath={activeWorktree.path} branch={branchName} eligibility={hostedReviewCreation} pushBeforeCreate={createPrPushFirst} @@ -1986,7 +2451,11 @@ function SourceControlInner(): React.JSX.Element {
setBaseRefDialogOpen(true)} + onToggleViewMode={() => + setSourceControlViewMode((prev) => (prev === 'tree' ? 'list' : 'tree')) + } onRetry={() => void refreshBranchCompare()} />
@@ -2076,12 +2545,54 @@ function SourceControlInner(): React.JSX.Element { )} + + + + + + + + + + More note actions + + + + + { + if (!activeWorktreeId || diffCommentCount === 0) { + return + } + setPendingDiffCommentsClear({ kind: 'all', worktreeId: activeWorktreeId }) + }} + > + + Clear all notes... + + +
{diffCommentsExpanded && ( void deleteDiffComment(activeWorktreeId, id)} onOpen={(comment) => handleOpenComment(comment)} + onClearFile={(filePath) => + setPendingDiffCommentsClear({ + kind: 'file', + worktreeId: activeWorktreeId, + filePath + }) + } /> )}
@@ -2113,7 +2624,7 @@ function SourceControlInner(): React.JSX.Element {
0 ? 50 : undefined }} > {unresolvedConflictReviewEntries.length > 0 && ( @@ -2178,17 +2689,19 @@ function SourceControlInner(): React.JSX.Element { clears. */} {(scope === 'all' || scope === 'uncommitted') && ( 0) } isGenerating={isGenerating} @@ -2344,27 +2857,72 @@ function SourceControlInner(): React.JSX.Element { } /> {!isCollapsed && - items.map((entry) => { - const key = `${entry.area}::${entry.path}` - return ( - - ) - })} + (sourceControlViewMode === 'tree' + ? visibleTreeRowsByArea[area].map((node) => { + if (node.type === 'directory') { + return ( + toggleTreeDir(node.key)} + onRequestDiscardPaths={(discardArea, paths) => + setPendingDiscard({ + kind: 'area', + area: discardArea, + paths + }) + } + onStagePaths={handleStageAllPaths} + onUnstagePaths={handleUnstagePaths} + /> + ) + } + return ( + + ) + }) + : items.map((entry) => { + const key = `${entry.area}::${entry.path}` + return ( + + ) + }))}
) })} @@ -2407,17 +2965,58 @@ function SourceControlInner(): React.JSX.Element { } /> {!collapsedSections.has('branch') && - filteredBranchEntries.map((entry) => ( - openCommittedDiff(entry)} - commentCount={diffCommentCountByPath.get(entry.path) ?? 0} - /> - ))} + (sourceControlViewMode === 'tree' + ? visibleBranchTreeRows.map((node) => { + if (node.type === 'directory') { + return ( + toggleTreeDir(node.key)} + /> + ) + } + return ( + openCommittedDiff(node.entry)} + commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0} + showPathHint={false} + /> + ) + }) + : filteredBranchEntries.map((entry) => ( + openCommittedDiff(entry)} + commentCount={diffCommentCountByPath.get(entry.path) ?? 0} + /> + )))} +
+ )} + + {scope === 'all' && !normalizedFilter && ( + // Why: the graph is reference context for the whole panel, so when + // file sections are short it should occupy the bottom instead of + // crowding the commit controls. +
+ toggleSection('history')} + onRefresh={() => void refreshGitHistory()} + onOpenCommit={(item) => void openHistoryCommitDiff(item)} + />
)}
@@ -2435,6 +3034,43 @@ function SourceControlInner(): React.JSX.Element { )} + { + if (!open && !isClearingDiffComments) { + setPendingDiffCommentsClear(null) + } + }} + > + + + Clear Notes + + {pendingDiffCommentsClearDescription} + + + + + + + + + { @@ -2507,8 +3143,10 @@ const SourceControl = React.memo(SourceControlInner) export default SourceControl type CommitAreaProps = { + worktreeId: string | null commitMessage: string commitError: string | null + remoteActionError: string | null isCommitting: boolean aiEnabled: boolean aiAgentConfigured: boolean @@ -2528,8 +3166,10 @@ type CommitAreaProps = { } export function CommitArea({ + worktreeId, commitMessage, commitError, + remoteActionError, isCommitting, aiEnabled, aiAgentConfigured, @@ -2572,6 +3212,38 @@ export function CommitArea({ // while still leaving the menu reachable to read the disabled-row // tooltips. const showChevronSpinner = (isCommitting || isRemoteOperationActive) && !showSpinner + const commitFailureSummary = useMemo( + () => (commitError ? summarizeCommitFailure(commitError) : null), + [commitError] + ) + const hasCommitFailureDetails = useMemo( + () => + commitError && commitFailureSummary + ? hasExpandedCommitFailureDetails(commitError, commitFailureSummary) + : false, + [commitError, commitFailureSummary] + ) + const commitFailureIdentity = `${worktreeId ?? 'no-worktree'}:${commitError ?? ''}` + const [commitFailureDialogState, setCommitFailureDialogState] = useState<{ + identity: string + open: boolean + }>({ identity: commitFailureIdentity, open: false }) + const isCommitFailureDialogOpen = + commitFailureDialogState.open && commitFailureDialogState.identity === commitFailureIdentity + const setCommitFailureDialogOpen = useCallback( + (open: boolean) => { + setCommitFailureDialogState({ identity: commitFailureIdentity, open }) + }, + [commitFailureIdentity] + ) + + useEffect(() => { + setCommitFailureDialogState((current) => + current.identity === commitFailureIdentity + ? current + : { identity: commitFailureIdentity, open: false } + ) + }, [commitFailureIdentity]) // Why: most primary-kind labels are anchored by a directional icon so // the affirmative Commit (✓) reads distinctly from the remote-state @@ -2582,6 +3254,13 @@ export function CommitArea({ const PrimaryIcon = PRIMARY_ICONS[primaryAction.kind] const hasMessage = commitMessage.trim().length > 0 + const describedBy = [ + commitError ? 'commit-area-error' : null, + remoteActionError ? 'commit-area-remote-error' : null, + generateError ? 'commit-area-generate-error' : null + ] + .filter(Boolean) + .join(' ') // Why: only render the Generate button when the user has opted into the // feature. Mounting a perma-disabled button would leak space and add noise @@ -2616,13 +3295,7 @@ export function CommitArea({ onChange={(e) => onCommitMessageChange(e.target.value)} placeholder="Message" aria-label="Commit message" - aria-describedby={ - commitError - ? 'commit-area-error' - : generateError - ? 'commit-area-generate-error' - : undefined - } + aria-describedby={describedBy || undefined} // Why: reserve right padding so typed text does not slide under the // absolute-positioned Generate icon in the top-right corner. className={`mt-0.5 w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring ${ @@ -2636,16 +3309,23 @@ export function CommitArea({ // swap to a Square ("stop") with a destructive tint so the user // sees that clicking will abort the run. Group/group-hover toggles // keep this stateless on the React side. - + + + + + + Generating commit message. Click to stop. + + ) : ( + )} + + )} + {commitError && commitFailureSummary && ( + + + + Commit Failed + {commitFailureSummary} + +
+              {commitError}
+            
+ + + + + +
+
+ )} + {remoteActionError && ( + )} {generateError && ( @@ -2770,11 +3496,15 @@ export function CommitArea({ function CompareSummary({ summary, + viewMode, onChangeBaseRef, + onToggleViewMode, onRetry }: { summary: GitBranchCompareSummary | null + viewMode: SourceControlViewMode onChangeBaseRef: () => void + onToggleViewMode: () => void onRetry: () => void }): React.JSX.Element { if (!summary || summary.status === 'loading') { @@ -2788,18 +3518,23 @@ function CompareSummary({ if (summary.status !== 'ready') { return ( -
- {summary.errorMessage ?? 'Branch compare unavailable'} - - +
+ + {summary.errorMessage ?? 'Branch compare unavailable'} + +
+ + + +
) } @@ -2811,34 +3546,57 @@ function CompareSummary({ {summary.commitsAhead} commits ahead )} - -
- - - - - - Change base ref - - - - - - - - Refresh branch compare - - -
-
+
+ + + +
) } +function CompareSummaryToolbarButton({ + icon: Icon, + label, + onClick +}: { + icon: LucideIcon + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} + function CompareUnavailable({ summary, onChangeBaseRef, @@ -2930,10 +3688,12 @@ function SectionHeader({ function DiffCommentsInlineList({ comments, onDelete, + onClearFile, onOpen }: { comments: DiffComment[] onDelete: (commentId: string) => void + onClearFile: (filePath: string) => void // Why: clicking the note row navigates the user to that file's diff (or // editor as a fallback) and, when a `commentId` is supplied, scrolls the // diff to that specific note via the scrollToDiffCommentId UI slice. @@ -2988,19 +3748,30 @@ function DiffCommentsInlineList({
{groups.map(([filePath, list]) => (
- +
+ + +
    {list.map((c) => (
  • void + onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void + onStagePaths: (paths: readonly string[]) => Promise + onUnstagePaths: (paths: readonly string[]) => Promise +}): React.JSX.Element { + // Why: filtered tree nodes only contain visible descendants. Folder-wide + // bulk labels would overpromise if they acted on that filtered subset. + const canStage = !hideBulkActions && actionPaths.stagePaths.length > 0 + const canUnstage = !hideBulkActions && actionPaths.unstagePaths.length > 0 + const canDiscard = !hideBulkActions && actionPaths.discardPaths.length > 0 + + return ( +
    + + + {node.fileCount} + + {(canDiscard || canStage || canUnstage) && ( +
    + {canDiscard && ( + { + event.stopPropagation() + onRequestDiscardPaths(node.area, actionPaths.discardPaths) + }} + disabled={isExecutingBulk} + /> + )} + {canStage && ( + { + event.stopPropagation() + void onStagePaths(actionPaths.stagePaths) + }} + disabled={isExecutingBulk} + /> + )} + {canUnstage && ( + { + event.stopPropagation() + void onUnstagePaths(actionPaths.unstagePaths) + }} + disabled={isExecutingBulk} + /> + )} +
    + )} +
    + ) +} + +function SourceControlBranchTreeDirectoryRow({ + node, + isCollapsed, + onToggle +}: { + node: BranchSourceControlTreeDirectoryNode + isCollapsed: boolean + onToggle: () => void +}): React.JSX.Element { + return ( +
    + + + {node.fileCount} + +
    + ) +} + const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ entryKey, entry, currentWorktreeId, worktreePath, + depth = 0, selected, onSelect, onContextMenu, @@ -3149,12 +4054,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ onStage, onUnstage, onDiscard, - commentCount + commentCount, + showPathHint = true }: { entryKey: string entry: GitStatusEntry currentWorktreeId: string worktreePath: string + depth?: number selected?: boolean onSelect?: (e: React.MouseEvent, key: string, entry: GitStatusEntry) => void onContextMenu?: (key: string) => void @@ -3164,8 +4071,9 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ onUnstage: (filePath: string) => Promise onDiscard: (entry: GitStatusEntry) => void commentCount: number + showPathHint?: boolean }): React.JSX.Element { - const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion + const FileIcon = getFileTypeIcon(entry.path) const fileName = basename(entry.path) const parentDir = dirname(entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -3208,9 +4116,12 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ data-source-control-path={entry.path} data-source-control-area={entry.area} className={cn( - 'group relative flex cursor-pointer items-center gap-1 pl-5 pr-3 py-1 transition-colors hover:bg-accent/40', + 'group relative flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors hover:bg-accent/40', selected && 'bg-accent/60' )} + style={{ + paddingLeft: `${depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_FILE_PADDING_PX}px` + }} draggable onDragStart={(e) => { if (isUnresolvedConflict && entry.status === 'deleted') { @@ -3229,11 +4140,13 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ } }} > - +
    {fileName} - {dirPath && {dirPath}} + {showPathHint && dirPath && ( + {dirPath} + )} {conflictLabel && (
    {conflictLabel}
    @@ -3261,7 +4174,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ {STATUS_LABELS[entry.status]} )} -
    +
    {canDiscard && ( void onOpen: () => void commentCount: number + showPathHint?: boolean }): React.JSX.Element { - const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion + const FileIcon = getFileTypeIcon(entry.path) const fileName = basename(entry.path) const parentDir = dirname(entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -3367,7 +4284,10 @@ function BranchEntryRow({ onRevealInExplorer={onRevealInExplorer} >
    { const absolutePath = joinPath(worktreePath, entry.path) @@ -3376,10 +4296,12 @@ function BranchEntryRow({ }} onClick={onOpen} > - + {fileName} - {dirPath && {dirPath}} + {showPathHint && dirPath && ( + {dirPath} + )} {commentCount > 0 && ( { + it('builds a stable repo-scoped key', () => { + expect(checksPanelAsyncResultKey('repo-id', 'feature/test', 12)).toBe( + 'repo-id::feature/test::12' + ) + }) + + it('uses explicit none marker when PR is absent', () => { + expect(checksPanelAsyncResultKey('repo-id', 'feature/test', null)).toBe( + 'repo-id::feature/test::none' + ) + }) +}) + +describe('shouldCommitChecksPanelAsyncResult', () => { + it('suppresses stale async completions', () => { + expect( + shouldCommitChecksPanelAsyncResult( + checksPanelAsyncResultKey('repo-id', 'feature/new', 99), + checksPanelAsyncResultKey('repo-id', 'feature/old', 12) + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts new file mode 100644 index 00000000000..bcc2e446a7f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts @@ -0,0 +1,14 @@ +export function checksPanelAsyncResultKey( + repoId: string, + branch: string, + prNumber: number | null +): string { + return `${repoId}::${branch}::${prNumber ?? 'none'}` +} + +export function shouldCommitChecksPanelAsyncResult( + currentKey: string, + requestKey: string +): boolean { + return currentKey === requestKey +} diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts new file mode 100644 index 00000000000..e019284d9d4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { createCoalescedPollRunner } from './coalesced-poll-runner' + +function deferred(): { + promise: Promise + resolve: () => void +} { + let resolve: () => void = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('createCoalescedPollRunner', () => { + it('keeps one task in flight and runs one trailing task after skipped triggers', async () => { + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const runner = createCoalescedPollRunner(task) + + runner.run() + runner.run() + runner.run() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(1) + calls[0]?.resolve() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(2) + calls[1]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(2) + }) + + it('drops queued trailing work after disposal', async () => { + const call = deferred() + const task = vi.fn(() => call.promise) + const runner = createCoalescedPollRunner(task) + + runner.run() + runner.run() + runner.dispose() + call.resolve() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts new file mode 100644 index 00000000000..60a7ace8d28 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -0,0 +1,43 @@ +export type CoalescedPollRunner = { + run: () => void + dispose: () => void +} + +export function createCoalescedPollRunner(task: () => Promise): CoalescedPollRunner { + let disposed = false + let inFlight = false + let rerun = false + + const run = (): void => { + if (disposed) { + return + } + if (inFlight) { + rerun = true + return + } + inFlight = true + void task() + .catch(() => { + // Poll callers handle their own expected transient errors. A rejected + // task must still release the in-flight latch and optional trailing run. + }) + .finally(() => { + inFlight = false + if (rerun && !disposed) { + rerun = false + run() + return + } + rerun = false + }) + } + + return { + run, + dispose: () => { + disposed = true + rerun = false + } + } +} diff --git a/src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts b/src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts new file mode 100644 index 00000000000..90cc911cb47 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary' + +describe('commit failure summary', () => { + it('collapses lint-staged, husky, and oxlint failures to a lint summary', () => { + const raw = [ + 'npm warn Unknown env config "python". This will stop working.', + 'husky - pre-commit hook exited with code 1', + 'lint-staged failed', + 'oxlint found 3 errors' + ].join('\n') + + expect(summarizeCommitFailure(raw)).toBe('Lint failed during commit.') + }) + + it('collapses pre-commit hook failures without lint output to a hook summary', () => { + expect(summarizeCommitFailure('pre-commit hook failed: secret scan blocked commit')).toBe( + 'Pre-commit hook failed.' + ) + }) + + it('falls back to the first meaningful line for generic failures', () => { + expect(summarizeCommitFailure('\n fatal: unable to auto-detect email address\nmore')).toBe( + 'fatal: unable to auto-detect email address' + ) + }) + + it('strips ANSI/control sequences and handles empty input', () => { + expect(summarizeCommitFailure('\u001b[31meslint found 2 errors\u001b[0m')).toBe( + 'Lint failed during commit.' + ) + expect(summarizeCommitFailure(' \n\t ')).toBe('Commit failed.') + }) + + it('reports whether expanded details add information beyond the summary', () => { + expect(hasExpandedCommitFailureDetails('nothing to commit', 'nothing to commit')).toBe(false) + expect( + hasExpandedCommitFailureDetails( + 'husky - pre-commit hook\neslint found 2 errors\nfull output', + 'Lint failed during commit.' + ) + ).toBe(true) + expect(hasExpandedCommitFailureDetails('', 'Commit failed.')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/commit-failure-summary.ts b/src/renderer/src/components/right-sidebar/commit-failure-summary.ts new file mode 100644 index 00000000000..8597c643ce5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/commit-failure-summary.ts @@ -0,0 +1,63 @@ +const FALLBACK_COMMIT_FAILURE_SUMMARY = 'Commit failed.' +const LINT_COMMIT_FAILURE_SUMMARY = 'Lint failed during commit.' +const PRE_COMMIT_FAILURE_SUMMARY = 'Pre-commit hook failed.' + +const ANSI_PATTERN = + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g +const CONTROL_PATTERN = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g +const LOW_SIGNAL_LINE_PATTERN = + /^(?:npm\s+(?:warn|warning)\b.*(?:env|config)|npm\s+notice\b|husky\s+-\s+deprecated\b)/i +const HOOK_PATTERN = /\b(?:pre-commit|precommit|husky|lint-staged)\b/i +const LINT_PATTERN = + /\b(?:eslint|oxlint|lint-staged|lint)\b|(?:found|found:)\s+\d+\s+errors?\b|\b\d+\s+errors?\b/i + +function normalizeCommitFailure(raw: string): string { + return raw.replace(ANSI_PATTERN, '').replace(/\r\n?/g, '\n').replace(CONTROL_PATTERN, '').trim() +} + +function getMeaningfulLines(raw: string): string[] { + const lines = normalizeCommitFailure(raw) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const hasSignalLine = lines.some((line) => HOOK_PATTERN.test(line) || LINT_PATTERN.test(line)) + + if (!hasSignalLine) { + return lines + } + + const filtered = lines.filter((line) => !LOW_SIGNAL_LINE_PATTERN.test(line)) + return filtered.length > 0 ? filtered : lines +} + +export function summarizeCommitFailure(raw: string): string { + const lines = getMeaningfulLines(raw) + + if (lines.length === 0) { + return FALLBACK_COMMIT_FAILURE_SUMMARY + } + + if (lines.some((line) => LINT_PATTERN.test(line))) { + return LINT_COMMIT_FAILURE_SUMMARY + } + + if (lines.some((line) => HOOK_PATTERN.test(line))) { + return PRE_COMMIT_FAILURE_SUMMARY + } + + return lines[0] ?? FALLBACK_COMMIT_FAILURE_SUMMARY +} + +export function hasExpandedCommitFailureDetails(raw: string, summary: string): boolean { + const normalizedRaw = normalizeCommitFailure(raw) + const normalizedSummary = normalizeCommitFailure(summary) + + if (!normalizedRaw) { + return false + } + + return normalizedRaw.replace(/\s+/g, ' ') !== normalizedSummary.replace(/\s+/g, ' ') +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts new file mode 100644 index 00000000000..ba187d4e21b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { TreeNode } from './file-explorer-types' +import { + countVisibleFileExplorerSelections, + createSingleFileExplorerSelection, + formatFileExplorerPathsForClipboard, + getFileExplorerActionNodes, + getFileExplorerSelectionMode, + updateFileExplorerSelection, + updateFileExplorerSelectionPaths +} from './file-explorer-selection' + +function node(path: string, relativePath = path): TreeNode { + return { + name: path.split(/[\\/]/).at(-1) ?? path, + path, + relativePath, + isDirectory: false, + depth: 0 + } +} + +describe('file explorer selection', () => { + it('uses Ctrl for multi-selection on Windows and Linux', () => { + expect( + getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: false }, false) + ).toBe('toggle') + expect( + getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: true }, false) + ).toBe('additive-range') + }) + + it('uses Command for multi-selection on macOS', () => { + expect( + getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: false }, true) + ).toBe('replace') + expect( + getFileExplorerSelectionMode({ ctrlKey: false, metaKey: true, shiftKey: false }, true) + ).toBe('toggle') + }) + + it('selects a contiguous visible range from the anchor with Shift', () => { + const current = createSingleFileExplorerSelection('/repo/a.ts') + const next = updateFileExplorerSelection( + current, + ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'], + '/repo/c.ts', + 'range' + ) + + expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/b.ts', '/repo/c.ts']) + expect(next.anchorPath).toBe('/repo/a.ts') + expect(next.activePath).toBe('/repo/c.ts') + }) + + it('toggles a selected path without losing the remaining visible selection', () => { + const current = updateFileExplorerSelection( + createSingleFileExplorerSelection('/repo/a.ts'), + ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'], + '/repo/c.ts', + 'range' + ) + const next = updateFileExplorerSelection( + current, + ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'], + '/repo/b.ts', + 'toggle' + ) + + expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/c.ts']) + expect(next.activePath).toBe('/repo/a.ts') + }) + + it('copies selected nodes in visible tree order', () => { + const rows = [ + node('/repo/b.ts', 'b.ts'), + node('/repo/a.ts', 'a.ts'), + node('/repo/c.ts', 'c.ts') + ] + const selectedPaths = new Set(['/repo/a.ts', '/repo/b.ts']) + const actionNodes = getFileExplorerActionNodes(rows, selectedPaths, rows[0]) + + expect(actionNodes.map((entry) => entry.path)).toEqual(['/repo/b.ts', '/repo/a.ts']) + expect(formatFileExplorerPathsForClipboard(actionNodes, 'absolute')).toBe( + '/repo/b.ts\n/repo/a.ts' + ) + expect(formatFileExplorerPathsForClipboard(actionNodes, 'relative')).toBe('b.ts\na.ts') + }) + + it('copies only the context-clicked node when it is outside the current selection', () => { + const rows = [node('/repo/a.ts'), node('/repo/b.ts')] + const actionNodes = getFileExplorerActionNodes(rows, new Set(['/repo/a.ts']), rows[1]) + + expect(actionNodes.map((entry) => entry.path)).toEqual(['/repo/b.ts']) + }) + + it('applies legacy path cleanup across the selected set', () => { + const current = updateFileExplorerSelection( + createSingleFileExplorerSelection('/repo/a.ts'), + ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'], + '/repo/c.ts', + 'range' + ) + const next = updateFileExplorerSelectionPaths(current, (path) => + path === '/repo/b.ts' ? null : path + ) + + expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/c.ts']) + expect(next.activePath).toBe('/repo/c.ts') + expect(next.anchorPath).toBe('/repo/a.ts') + }) + + it('does not scan rows for empty or single selection counts', () => { + const rows = new Proxy([node('/repo/a.ts')], { + get(target, prop, receiver) { + if (prop === 'reduce') { + throw new Error('unexpected row scan') + } + return Reflect.get(target, prop, receiver) + } + }) + + expect(countVisibleFileExplorerSelections(rows, new Set())).toBe(0) + expect(countVisibleFileExplorerSelections(rows, new Set(['/repo/a.ts']))).toBe(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-selection.ts b/src/renderer/src/components/right-sidebar/file-explorer-selection.ts new file mode 100644 index 00000000000..dfb002a6957 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-selection.ts @@ -0,0 +1,200 @@ +import type { TreeNode } from './file-explorer-types' + +export type FileExplorerSelectionState = { + activePath: string | null + anchorPath: string | null + selectedPaths: Set +} + +export type FileExplorerSelectionMode = 'replace' | 'toggle' | 'range' | 'additive-range' + +export type FileExplorerSelectionModifiers = { + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean +} + +export function createEmptyFileExplorerSelection(): FileExplorerSelectionState { + return { + activePath: null, + anchorPath: null, + selectedPaths: new Set() + } +} + +export function createSingleFileExplorerSelection(path: string | null): FileExplorerSelectionState { + return { + activePath: path, + anchorPath: path, + selectedPaths: path ? new Set([path]) : new Set() + } +} + +export function getFileExplorerSelectionMode( + modifiers: FileExplorerSelectionModifiers, + isMac: boolean +): FileExplorerSelectionMode { + const hasToggleModifier = isMac ? modifiers.metaKey : modifiers.ctrlKey + if (modifiers.shiftKey && hasToggleModifier) { + return 'additive-range' + } + if (modifiers.shiftKey) { + return 'range' + } + if (hasToggleModifier) { + return 'toggle' + } + return 'replace' +} + +function firstSelectedPathInTreeOrder( + selectedPaths: Set, + orderedPaths: readonly string[] +): string | null { + return orderedPaths.find((path) => selectedPaths.has(path)) ?? null +} + +function getRangePaths( + orderedPaths: readonly string[], + anchorPath: string | null, + targetPath: string +): string[] { + const targetIndex = orderedPaths.indexOf(targetPath) + const anchorIndex = anchorPath ? orderedPaths.indexOf(anchorPath) : -1 + if (targetIndex === -1 || anchorIndex === -1) { + return [targetPath] + } + + const start = Math.min(anchorIndex, targetIndex) + const end = Math.max(anchorIndex, targetIndex) + return orderedPaths.slice(start, end + 1) +} + +export function updateFileExplorerSelection( + current: FileExplorerSelectionState, + orderedPaths: readonly string[], + targetPath: string, + mode: FileExplorerSelectionMode +): FileExplorerSelectionState { + if (mode === 'replace') { + return createSingleFileExplorerSelection(targetPath) + } + + if (mode === 'toggle') { + const selectedPaths = new Set(current.selectedPaths) + if (selectedPaths.has(targetPath)) { + selectedPaths.delete(targetPath) + } else { + selectedPaths.add(targetPath) + } + + const activePath = selectedPaths.has(targetPath) + ? targetPath + : firstSelectedPathInTreeOrder(selectedPaths, orderedPaths) + return { + activePath, + anchorPath: activePath, + selectedPaths + } + } + + const rangeAnchor = + current.anchorPath && orderedPaths.includes(current.anchorPath) + ? current.anchorPath + : targetPath + const rangePaths = getRangePaths(orderedPaths, rangeAnchor, targetPath) + const selectedPaths = + mode === 'additive-range' ? new Set(current.selectedPaths) : new Set() + for (const path of rangePaths) { + selectedPaths.add(path) + } + + return { + activePath: targetPath, + anchorPath: rangeAnchor, + selectedPaths + } +} + +export function updateFileExplorerSelectionPaths( + current: FileExplorerSelectionState, + updatePath: (path: string) => string | null +): FileExplorerSelectionState { + const updatedPathByPath = new Map() + const getUpdatedPath = (path: string | null): string | null => { + if (path === null) { + return null + } + if (!updatedPathByPath.has(path)) { + updatedPathByPath.set(path, updatePath(path)) + } + return updatedPathByPath.get(path) ?? null + } + + let changed = false + const selectedPaths = new Set() + for (const path of current.selectedPaths) { + const nextPath = getUpdatedPath(path) + if (nextPath !== path) { + changed = true + } + if (nextPath !== null) { + selectedPaths.add(nextPath) + } + } + + const updatedActivePath = getUpdatedPath(current.activePath) + const activePath = + updatedActivePath && selectedPaths.has(updatedActivePath) + ? updatedActivePath + : (selectedPaths.values().next().value ?? null) + const updatedAnchorPath = getUpdatedPath(current.anchorPath) + const anchorPath = + updatedAnchorPath && selectedPaths.has(updatedAnchorPath) ? updatedAnchorPath : activePath + + if ( + !changed && + activePath === current.activePath && + anchorPath === current.anchorPath && + selectedPaths.size === current.selectedPaths.size + ) { + return current + } + + return { + activePath, + anchorPath, + selectedPaths + } +} + +export function getFileExplorerActionNodes( + flatRows: readonly TreeNode[], + selectedPaths: Set, + fallbackNode: TreeNode +): TreeNode[] { + if (!selectedPaths.has(fallbackNode.path)) { + return [fallbackNode] + } + + const selectedNodes = flatRows.filter((node) => selectedPaths.has(node.path)) + return selectedNodes.length > 0 ? selectedNodes : [fallbackNode] +} + +export function countVisibleFileExplorerSelections( + flatRows: readonly TreeNode[], + selectedPaths: Set +): number { + if (selectedPaths.size <= 1) { + return selectedPaths.size + } + + return flatRows.reduce((count, node) => count + (selectedPaths.has(node.path) ? 1 : 0), 0) +} + +export function formatFileExplorerPathsForClipboard( + nodes: readonly TreeNode[], + pathKind: 'absolute' | 'relative' +): string { + return nodes.map((node) => (pathKind === 'absolute' ? node.path : node.relativePath)).join('\n') +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index dc612f389ec..9b4d3b8882a 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -40,7 +40,10 @@ describe('refreshGitStatusForWorktree', () => { deps }) - expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: 'ssh-1' }) + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1' + }) expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status) expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', { head: 'abc123', @@ -76,4 +79,27 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2') }) + + it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown' + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-3', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) + }) }) diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index f277f827f9d..a5a69821891 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -22,7 +22,6 @@ import SourceControl from './SourceControl' import SearchPanel from './Search' import ChecksPanel from './ChecksPanel' import PortsPanel from './PortsPanel' -import { GitHubRateLimitCompact } from '@/components/github/github-rate-limit-display' const MIN_WIDTH = 220 // Why: long file names (e.g. construction drawing sheets, multi-part document @@ -214,8 +213,6 @@ function RightSidebarInner(): React.JSX.Element { const effectiveTab = visibleItems.some((item) => item.id === rightSidebarTab) ? rightSidebarTab : visibleItems[0].id - const showGitHubBudgetInPanel = - !isFolder && (effectiveTab === 'source-control' || effectiveTab === 'checks') const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0 const maxWidth = useWindowAwareMaxWidth() @@ -336,13 +333,6 @@ function RightSidebarInner(): React.JSX.Element {
    )} - {showGitHubBudgetInPanel ? ( - - ) : null} - {panelContent} {/* Resize handle on LEFT side */} diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 2f30edc6038..33e3611519e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -8,6 +8,7 @@ function inputs(overrides: Partial = {}): PrimaryActionInpu return { stagedCount: 0, hasUnstagedChanges: false, + hasPartiallyStagedChanges: false, hasMessage: false, hasUnresolvedConflicts: false, isCommitting: false, @@ -54,6 +55,25 @@ describe('resolveDropdownItems', () => { expect(byKind.commit_sync.disabled).toBe(true) }) + it('disables commit actions when staged files also have unstaged changes', () => { + const items = resolveDropdownItems( + inputs({ + stagedCount: 1, + hasUnstagedChanges: true, + hasPartiallyStagedChanges: true, + hasMessage: true, + upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.commit.disabled).toBe(true) + expect(byKind.commit_push.disabled).toBe(true) + expect(byKind.commit_sync.disabled).toBe(true) + expect(byKind.commit.title).toBe('Stage all changes before committing partially staged files') + }) + it('disables push actions but keeps Fetch enabled when branch has no upstream', () => { const items = resolveDropdownItems( inputs({ @@ -170,6 +190,21 @@ describe('resolveDropdownItems', () => { expect(byKind.publish.disabled).toBe(false) }) + it('does not show Publish Branch when an unpublished branch has no commits ahead', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + branchCommitsAhead: 0 + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.publish.label).toBe('No Branch Changes') + expect(byKind.publish.title).toBe('Nothing to publish') + expect(byKind.publish.disabled).toBe(true) + }) + it('does not mention Publish Branch when the linked PR is already merged', () => { const items = resolveDropdownItems( inputs({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index ed7fca8d87c..1231ca659fb 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -57,6 +57,7 @@ function formatSyncLabel(base: string, ahead: number, behind: number): string { export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry[] { const { stagedCount, + hasPartiallyStagedChanges, hasMessage, hasUnresolvedConflicts, isCommitting, @@ -64,7 +65,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry upstreamStatus, prState, isPRStateLoading, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs const hasStaged = stagedCount > 0 @@ -79,6 +81,7 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const hasUpstream = upstreamStatus?.hasUpstream ?? false const publishBlockedByMergedPR = !hasUpstream && prState === 'merged' const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading + const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0 const ahead = upstreamStatus?.ahead ?? 0 const behind = upstreamStatus?.behind ?? 0 @@ -94,6 +97,9 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry if (!hasStaged) { return 'Stage at least one file to commit' } + if (hasPartiallyStagedChanges) { + return 'Stage all changes before committing partially staged files' + } if (!hasMessage) { return 'Enter a commit message to commit' } @@ -225,22 +231,30 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const publishItem: DropdownItem = { kind: 'publish', - label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch', + label: + publishBlockedByMergedPR || publishBlockedByPRLoading + ? 'PR Status' + : publishBlockedByNoBranchCommits + ? 'No Branch Changes' + : 'Publish Branch', title: upstreamLoading ? 'Checking branch status…' : publishBlockedByPRLoading ? 'Checking PR status…' : publishBlockedByMergedPR ? 'PR is already merged' - : hasUpstream - ? 'Branch is already published' - : 'Publish this branch to origin', + : publishBlockedByNoBranchCommits + ? 'Nothing to publish' + : hasUpstream + ? 'Branch is already published' + : 'Publish this branch to origin', disabled: globalBusy || upstreamLoading || hasUpstream || publishBlockedByPRLoading || - publishBlockedByMergedPR + publishBlockedByMergedPR || + publishBlockedByNoBranchCommits } const createBlockedHint = (() => { diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index 8cfb44aff3c..6fec6468391 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -8,6 +8,7 @@ function inputs(overrides: Partial = {}): PrimaryActionInpu return { stagedCount: 0, hasUnstagedChanges: false, + hasPartiallyStagedChanges: false, hasMessage: false, hasUnresolvedConflicts: false, isCommitting: false, @@ -173,7 +174,7 @@ describe('resolvePrimaryAction', () => { it('returns Publish Branch on a clean tree when no upstream exists', () => { const result = resolvePrimaryAction( - inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 }) ) expect(result).toEqual({ kind: 'publish', @@ -183,6 +184,18 @@ describe('resolvePrimaryAction', () => { }) }) + it('does not offer Publish Branch when an unpublished branch has no commits ahead', () => { + const result = resolvePrimaryAction( + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 }) + ) + expect(result).toEqual({ + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + }) + }) + it.each([ [{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'], [{ isPRStateLoading: true }, 'Checking PR status…'] @@ -244,11 +257,11 @@ describe('resolvePrimaryAction', () => { }) // Why: dirty trees (no staged, has unstaged/untracked) must surface a - // 'Stage Files' primary regardless of upstream state. Pulling/syncing on + // 'Stage All' primary regardless of upstream state. Pulling/syncing on // a dirty tree fails ("Please commit or stash them"), and pushing skips // the immediate user need (prepare a commit), so the staging rung // intercepts before any remote rung fires. - it('returns Stage Files on a dirty tree that is behind upstream', () => { + it('returns Stage All on a dirty tree that is behind upstream', () => { const result = resolvePrimaryAction( inputs({ hasUnstagedChanges: true, @@ -257,13 +270,13 @@ describe('resolvePrimaryAction', () => { ) expect(result).toEqual({ kind: 'stage', - label: 'Stage Files', + label: 'Stage All', title: 'Stage all changes', disabled: false }) }) - it('returns Stage Files on a dirty tree that is ahead of upstream', () => { + it('returns Stage All on a dirty tree that is ahead of upstream', () => { const result = resolvePrimaryAction( inputs({ hasUnstagedChanges: true, @@ -271,11 +284,11 @@ describe('resolvePrimaryAction', () => { }) ) expect(result.kind).toBe('stage') - expect(result.label).toBe('Stage Files') + expect(result.label).toBe('Stage All') expect(result.disabled).toBe(false) }) - it('returns Stage Files on a dirty tree with no upstream branch', () => { + it('returns Stage All on a dirty tree with no upstream branch', () => { const result = resolvePrimaryAction( inputs({ hasUnstagedChanges: true, @@ -285,7 +298,7 @@ describe('resolvePrimaryAction', () => { expect(result.kind).toBe('stage') }) - it('returns Stage Files on a dirty tree while upstream status is still loading', () => { + it('returns Stage All on a dirty tree while upstream status is still loading', () => { const result = resolvePrimaryAction( inputs({ hasUnstagedChanges: true, upstreamStatus: undefined }) ) @@ -293,11 +306,27 @@ describe('resolvePrimaryAction', () => { expect(result.disabled).toBe(false) }) - it('still resolves to Commit when both staged and unstaged exist (staged wins)', () => { + it('returns Stage All when a staged file also has unstaged changes', () => { const result = resolvePrimaryAction( inputs({ stagedCount: 1, hasUnstagedChanges: true, + hasPartiallyStagedChanges: true, + hasMessage: true, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + }) + ) + expect(result.kind).toBe('stage') + expect(result.label).toBe('Stage All') + expect(result.disabled).toBe(false) + }) + + it('still resolves to Commit when staged and unrelated unstaged files exist', () => { + const result = resolvePrimaryAction( + inputs({ + stagedCount: 1, + hasUnstagedChanges: true, + hasPartiallyStagedChanges: false, hasMessage: true, upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } }) @@ -315,13 +344,13 @@ describe('resolvePrimaryAction', () => { expect(result.title).toBe('Enter a commit message to commit') }) - it('returns Stage Files when unstaged changes exist on an in-sync branch', () => { + it('returns Stage All when unstaged changes exist on an in-sync branch', () => { const result = resolvePrimaryAction( inputs({ hasUnstagedChanges: true, upstreamStatus: upstreamInSync }) ) expect(result).toEqual({ kind: 'stage', - label: 'Stage Files', + label: 'Stage All', title: 'Stage all changes', disabled: false }) diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts index 022c0d38327..e6bc470dc40 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts @@ -42,6 +42,7 @@ export type PrimaryAction = { export type PrimaryActionInputs = { stagedCount: number hasUnstagedChanges: boolean + hasPartiallyStagedChanges: boolean hasMessage: boolean hasUnresolvedConflicts: boolean isCommitting: boolean @@ -55,10 +56,14 @@ export type PrimaryActionInputs = { // stale label that no longer matches what the slice is doing. inFlightRemoteOpKind?: RemoteOpKind | null hostedReviewCreation?: HostedReviewCreationEligibility | null + // Why: an unpublished branch is only worth publishing when it actually + // carries commits beyond the compare base. Undefined preserves the old + // behavior while the branch compare request is still unavailable/loading. + branchCommitsAhead?: number } const PRIMARY_LABEL_BY_KIND: Record, string> = { - stage: 'Stage Files', + stage: 'Stage All', push: 'Push', pull: 'Pull', sync: 'Sync', @@ -85,11 +90,13 @@ function describeSyncCounts(ahead: number, behind: number): string { * 1. In-flight commit locks the primary to a disabled "Commit". * 2. In-flight remote operation keeps the current label but disables it. * 3. Unresolved conflicts block the commit path entirely. - * 4. Has staged files + message → plain "Commit" (compound flows live in - * the dropdown; after the commit lands, step 6 rotates the primary to + * 4. Has partially staged files → "Stage All" to avoid hook-time partial + * stash conflicts. + * 5. Has staged files + message → plain "Commit" (compound flows live in + * the dropdown; after the commit lands, step 7 rotates the primary to * the appropriate single remote action). - * 5. Has staged files + no message → disabled "Commit" with a reason. - * 6. Clean tree → adaptive remote action (or disabled "Commit" no-op). + * 6. Has staged files + no message → disabled "Commit" with a reason. + * 7. Clean tree → adaptive remote action (or disabled "Commit" no-op). * * An undefined upstream status means fetchUpstreamStatus has not resolved * yet for this worktree. We return a disabled Commit so the button has a @@ -100,6 +107,7 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction const { stagedCount, hasUnstagedChanges, + hasPartiallyStagedChanges, hasMessage, hasUnresolvedConflicts, isCommitting, @@ -108,7 +116,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction prState, isPRStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs // 1. Commit in flight — lock the primary no matter what else is true. @@ -180,10 +189,22 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction const hasStaged = stagedCount > 0 - // 4. Has staged files + message → plain Commit. The primary button never + // 4. A path with both staged and unstaged edits can make lint-staged's + // partial-stash restore fail after formatters rewrite the staged copy. Push + // the user through Stage All first so the index matches the worktree. + if (hasStaged && hasPartiallyStagedChanges) { + return { + kind: 'stage', + label: 'Stage All', + title: 'Stage all changes before committing partially staged files', + disabled: false + } + } + + // 5. Has staged files + message → plain Commit. The primary button never // compounds ("Commit & Push" etc.) — after the commit lands, the primary // naturally rotates to the appropriate remote action (Push / Sync / - // Publish Branch) via step 6 below. Users who want the one-click + // Publish Branch) via step 7 below. Users who want the one-click // compound flow can still reach it from the dropdown. if (hasStaged && hasMessage) { return { @@ -194,7 +215,7 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction } } - // 5. Has staged files but no message — user just needs to type something. + // 6. Has staged files but no message — user just needs to type something. if (hasStaged && !hasMessage) { return { kind: 'commit', @@ -204,7 +225,7 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction } } - // 5b. Nothing staged but local changes exist — surface staging as the + // 6b. Nothing staged but local changes exist — surface staging as the // primary so dirty trees don't invite a remote op (pull/sync would fail // with uncommitted changes; push/publish skips the actual user need). // Sits before the upstream-status checks so it works regardless of @@ -212,13 +233,13 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction if (!hasStaged && hasUnstagedChanges) { return { kind: 'stage', - label: 'Stage Files', + label: 'Stage All', title: 'Stage all changes', disabled: false } } - // 6. Clean tree + no staged files → adaptive remote action. + // 7. Clean tree + no staged files → adaptive remote action. if (!upstreamStatus) { return { kind: 'commit', @@ -229,6 +250,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction } if (!upstreamStatus.hasUpstream) { + if (branchCommitsAhead === 0) { + return { + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + } + } + if (isPRStateLoading) { return { kind: 'commit', diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts new file mode 100644 index 00000000000..a2582db74f1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts @@ -0,0 +1,18 @@ +import type { GitStatusEntry } from '../../../../shared/types' + +export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number { + return ( + getConflictSortRank(a) - getConflictSortRank(b) || + a.path.localeCompare(b.path, undefined, { numeric: true }) + ) +} + +function getConflictSortRank(entry: GitStatusEntry): number { + if (entry.conflictStatus === 'unresolved') { + return 0 + } + if (entry.conflictStatus === 'resolved_locally') { + return 1 + } + return 2 +} diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.test.ts b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts new file mode 100644 index 00000000000..422c6ceb0a1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-tree.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import { + buildGitStatusSourceControlTree, + buildSourceControlTree, + collectSourceControlTreeFileEntries, + compactSourceControlTree, + flattenSourceControlTree +} from './source-control-tree' + +function entry(partial: Partial & { path: string }): GitStatusEntry { + return { + status: 'modified', + area: 'unstaged', + ...partial + } +} + +function labels(nodes: ReturnType): string[] { + return nodes.map((node) => `${node.type}:${node.path}`) +} + +describe('buildSourceControlTree', () => { + it('groups changed files by path segments', () => { + const tree = buildSourceControlTree('unstaged', [ + entry({ path: 'src/components/Button.tsx' }), + entry({ path: 'src/index.ts' }), + entry({ path: 'README.md' }) + ]) + + expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([ + 'directory:src', + 'directory:src/components', + 'file:src/components/Button.tsx', + 'file:src/index.ts', + 'file:README.md' + ]) + }) + + it('keeps conflict rows ahead of normal rows within a directory', () => { + const tree = buildGitStatusSourceControlTree('unstaged', [ + entry({ path: 'src/a.ts' }), + entry({ + path: 'src/z.ts', + conflictKind: 'both_modified', + conflictStatus: 'unresolved' + }) + ]) + + expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([ + 'directory:src', + 'file:src/z.ts', + 'file:src/a.ts' + ]) + }) + + it('omits collapsed directory descendants from the visible rows', () => { + const tree = buildSourceControlTree('staged', [ + entry({ path: 'src/app.ts', area: 'staged' }), + entry({ path: 'docs/readme.md', area: 'staged' }) + ]) + const collapsed = new Set(['dir::staged::src']) + + expect(labels(flattenSourceControlTree(tree, collapsed))).toEqual([ + 'directory:docs', + 'file:docs/readme.md', + 'directory:src' + ]) + }) + + it('collects all descendant file entries for directory actions', () => { + const tree = buildSourceControlTree('unstaged', [ + entry({ path: 'src/app.ts' }), + entry({ path: 'src/components/Button.tsx' }), + entry({ path: 'docs/readme.md' }) + ]) + + const src = tree.find((node) => node.type === 'directory' && node.path === 'src') + + expect(src ? collectSourceControlTreeFileEntries(src).map((item) => item.path) : []).toEqual([ + 'src/components/Button.tsx', + 'src/app.ts' + ]) + }) + + it('groups committed branch entries with branch-scoped directory keys', () => { + const entries: GitBranchChangeEntry[] = [ + { path: 'packages/app/src/index.ts', status: 'modified' }, + { path: 'packages/app/package.json', status: 'added' } + ] + const tree = buildSourceControlTree('branch', entries) + + expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([ + 'directory:packages', + 'directory:packages/app', + 'directory:packages/app/src', + 'file:packages/app/src/index.ts', + 'file:packages/app/package.json' + ]) + expect(tree[0].key).toBe('dir::branch::packages') + }) + + it('compacts single-child directory chains for visible source-control rows', () => { + const tree = buildSourceControlTree('branch', [ + { path: 'src/renderer/src/mockups.css', status: 'modified' }, + { path: 'src/renderer/src/components/App.tsx', status: 'added' } + ]) + + const compacted = compactSourceControlTree(tree) + + expect(labels(flattenSourceControlTree(compacted, new Set()))).toEqual([ + 'directory:src/renderer/src', + 'directory:src/renderer/src/components', + 'file:src/renderer/src/components/App.tsx', + 'file:src/renderer/src/mockups.css' + ]) + expect(compacted[0]).toMatchObject({ + name: 'src/renderer/src', + path: 'src/renderer/src', + depth: 0, + key: 'dir::branch::src/renderer/src' + }) + }) + + it('preserves real branching folders when compacting directory chains', () => { + const tree = buildSourceControlTree('unstaged', [ + entry({ path: 'src/main/index.ts' }), + entry({ path: 'src/renderer/index.ts' }) + ]) + + const compacted = compactSourceControlTree(tree) + + expect(labels(flattenSourceControlTree(compacted, new Set()))).toEqual([ + 'directory:src', + 'directory:src/main', + 'file:src/main/index.ts', + 'directory:src/renderer', + 'file:src/renderer/index.ts' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.ts b/src/renderer/src/components/right-sidebar/source-control-tree.ts new file mode 100644 index 00000000000..15f33ffa354 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts @@ -0,0 +1,241 @@ +import { normalizeRelativePath } from '@/lib/path' +import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types' +import { splitPathSegments } from './path-tree' +import { compareGitStatusEntries } from './source-control-status-sort' + +export type SourceControlTreeArea = Extract +// Why: committed branch rows share the same path tree but do not carry +// uncommitted status metadata, so the tree builder stays entry-shape generic. +export type SourceControlTreeEntry = { + path: string +} + +export type SourceControlTreeFileNode< + Entry extends SourceControlTreeEntry = GitStatusEntry, + Area extends string = SourceControlTreeArea +> = { + type: 'file' + key: string + name: string + path: string + entry: Entry + area: Area + depth: number +} + +export type SourceControlTreeDirectoryNode< + Entry extends SourceControlTreeEntry = GitStatusEntry, + Area extends string = SourceControlTreeArea +> = { + type: 'directory' + key: string + name: string + path: string + area: Area + depth: number + fileCount: number + children: SourceControlTreeNode[] +} + +export type SourceControlTreeNode< + Entry extends SourceControlTreeEntry = GitStatusEntry, + Area extends string = SourceControlTreeArea +> = SourceControlTreeFileNode | SourceControlTreeDirectoryNode + +type MutableDirectoryNode = Omit< + SourceControlTreeDirectoryNode, + 'children' +> & { + children: SourceControlTreeNode[] + directoryChildren: Map> +} + +function compareTreeEntriesByPath(a: SourceControlTreeEntry, b: SourceControlTreeEntry): number { + return a.path.localeCompare(b.path, undefined, { numeric: true }) +} + +function makeDirectoryNode( + area: Area, + path: string, + name: string, + depth: number +): MutableDirectoryNode { + return { + type: 'directory', + key: `dir::${area}::${path}`, + name, + path, + area, + depth, + fileCount: 0, + children: [], + directoryChildren: new Map() + } +} + +function finalizeDirectoryNode( + node: MutableDirectoryNode, + compareEntries: (a: Entry, b: Entry) => number +): SourceControlTreeDirectoryNode { + const directories: SourceControlTreeDirectoryNode[] = [] + const files: SourceControlTreeFileNode[] = [] + + for (const child of node.children) { + if (child.type === 'directory') { + directories.push( + finalizeDirectoryNode(child as MutableDirectoryNode, compareEntries) + ) + } else { + files.push(child) + } + } + + directories.sort((a, b) => a.name.localeCompare(b.name)) + files.sort((a, b) => compareEntries(a.entry, b.entry)) + const fileCount = + files.length + directories.reduce((count, directory) => count + directory.fileCount, 0) + + return { + type: 'directory', + key: node.key, + name: node.name, + path: node.path, + area: node.area, + depth: node.depth, + fileCount, + children: [...directories, ...files] + } +} + +export function buildSourceControlTree< + Entry extends SourceControlTreeEntry = GitStatusEntry, + Area extends string = SourceControlTreeArea +>( + area: Area, + entries: Entry[], + compareEntries: (a: Entry, b: Entry) => number = compareTreeEntriesByPath +): SourceControlTreeNode[] { + const root = makeDirectoryNode(area, '', '', -1) + + for (const entry of entries) { + const normalizedPath = normalizeRelativePath(entry.path) + const segments = splitPathSegments(normalizedPath) + if (segments.length === 0) { + continue + } + + let parent = root + for (let index = 0; index < segments.length - 1; index += 1) { + const name = segments[index] + const path = segments.slice(0, index + 1).join('/') + let dir = parent.directoryChildren.get(name) + if (!dir) { + dir = makeDirectoryNode(area, path, name, index) + parent.directoryChildren.set(name, dir) + parent.children.push(dir) + } + parent = dir + } + + const fileName = segments.at(-1)! + parent.children.push({ + type: 'file', + key: `${area}::${entry.path}`, + name: fileName, + path: normalizedPath, + entry, + area, + depth: segments.length - 1 + }) + } + + return finalizeDirectoryNode(root, compareEntries).children +} + +export function buildGitStatusSourceControlTree( + area: SourceControlTreeArea, + entries: GitStatusEntry[] +): SourceControlTreeNode[] { + // Why: uncommitted trees must preserve the conflict-first ordering used by + // the flat Source Control list; branch trees can sort by path. + return buildSourceControlTree(area, entries, compareGitStatusEntries) +} + +export function flattenSourceControlTree( + nodes: SourceControlTreeNode[], + collapsedDirectoryKeys: ReadonlySet +): SourceControlTreeNode[] { + const result: SourceControlTreeNode[] = [] + + const visit = (node: SourceControlTreeNode): void => { + result.push(node) + if (node.type === 'directory' && !collapsedDirectoryKeys.has(node.key)) { + for (const child of node.children) { + visit(child) + } + } + } + + for (const node of nodes) { + visit(node) + } + + return result +} + +export function compactSourceControlTree( + nodes: SourceControlTreeNode[] +): SourceControlTreeNode[] { + const compactNode = ( + node: SourceControlTreeNode, + depth: number + ): SourceControlTreeNode => { + if (node.type === 'file') { + return { ...node, depth } + } + + const names = [node.name] + let compacted = node + while (compacted.children.length === 1 && compacted.children[0]?.type === 'directory') { + compacted = compacted.children[0] + names.push(compacted.name) + } + + // Why: Source Control trees often contain path-only folder chains from a + // small changed-file subset. Compressing them matches VS Code and keeps + // branch change trees readable without changing the underlying file set. + return { + ...compacted, + name: names.join('/'), + depth, + children: compacted.children.map((child) => compactNode(child, depth + 1)) + } + } + + return nodes.map((node) => compactNode(node, 0)) +} + +export function collectSourceControlTreeFileEntries< + Entry extends SourceControlTreeEntry, + Area extends string +>(node: SourceControlTreeNode): Entry[] { + if (node.type === 'file') { + return [node.entry] + } + + const entries: Entry[] = [] + const collect = (child: SourceControlTreeNode): void => { + if (child.type === 'file') { + entries.push(child.entry) + return + } + for (const grandchild of child.children) { + collect(grandchild) + } + } + + for (const child of node.children) { + collect(child) + } + return entries +} diff --git a/src/renderer/src/components/right-sidebar/status-display.test.ts b/src/renderer/src/components/right-sidebar/status-display.test.ts new file mode 100644 index 00000000000..5ec169088d9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/status-display.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildIgnoredSet, isPathIgnored, shouldShowIgnoredDecoration } from './status-display' + +describe('buildIgnoredSet', () => { + it('returns an empty set when ignoredPaths is undefined', () => { + expect(buildIgnoredSet(undefined).size).toBe(0) + }) + + it('strips trailing slash from directory entries so lookups by TreeNode.relativePath hit', () => { + const set = buildIgnoredSet(['dist/', 'node_modules/', '.env']) + expect(set.has('dist')).toBe(true) + expect(set.has('node_modules')).toBe(true) + expect(set.has('.env')).toBe(true) + expect(set.has('dist/')).toBe(false) + }) +}) + +describe('isPathIgnored', () => { + it('returns false on an empty set without walking ancestors', () => { + expect(isPathIgnored(new Set(), 'a/b/c.ts')).toBe(false) + }) + + it('matches direct hits', () => { + expect(isPathIgnored(new Set(['.env']), '.env')).toBe(true) + }) + + it('inherits ignored status from an ancestor directory', () => { + const ignored = new Set(['dist']) + expect(isPathIgnored(ignored, 'dist/index.js')).toBe(true) + expect(isPathIgnored(ignored, 'dist/sub/deep/file.js')).toBe(true) + }) + + it('does not match sibling paths that share a prefix', () => { + const ignored = new Set(['dist']) + expect(isPathIgnored(ignored, 'distance.ts')).toBe(false) + }) +}) + +describe('shouldShowIgnoredDecoration', () => { + it('shows ignored decoration only when no real git status exists', () => { + const ignored = new Set(['dist']) + + expect(shouldShowIgnoredDecoration(null, ignored, 'dist/index.js')).toBe(true) + expect(shouldShowIgnoredDecoration('modified', ignored, 'dist/index.js')).toBe(false) + expect(shouldShowIgnoredDecoration('untracked', ignored, 'dist/index.js')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index c8a7ee7b447..92fa3e7769b 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -95,3 +95,43 @@ export function buildFolderStatusMap(entries: GitStatusEntry[]): Map, relativePath: string): boolean { + if (ignored.size === 0) { + return false + } + if (ignored.has(relativePath)) { + return true + } + let candidate = relativePath + for (;;) { + const idx = candidate.lastIndexOf('/') + if (idx <= 0) { + return false + } + candidate = candidate.slice(0, idx) + if (ignored.has(candidate)) { + return true + } + } +} + +export function shouldShowIgnoredDecoration( + nodeStatus: GitFileStatus | null, + ignored: Set, + relativePath: string +): boolean { + return !nodeStatus && isPathIgnored(ignored, relativePath) +} + +export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set { + const set = new Set() + if (!ignoredPaths) { + return set + } + for (const rawPath of ignoredPaths) { + const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath + set.add(normalizeRelativePath(trimmed)) + } + return set +} diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts new file mode 100644 index 00000000000..37bee7d330f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getConnectionId } from '@/lib/connection-context' +import { useAppStore, type AppState } from '@/store' +import { + cancelRuntimeGeneratePullRequestFields, + generateRuntimePullRequestFields +} from '@/runtime/runtime-git-client' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' +import { + isCustomAgentId, + resolveCommitMessageAgentChoice +} from '../../../../shared/commit-message-agent-spec' +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' + +type UseCreatePullRequestDialogFieldsOptions = { + open: boolean + repoId: string + worktreeId: string | null + worktreePath: string + branch: string + eligibility: HostedReviewCreationEligibility | null + settings: AppState['settings'] + submitting: boolean +} + +type GenerationSeed = { + requestId: number + base: string + title: string + body: string + draft: boolean +} + +export function stripBaseRef(ref: string): string { + return normalizeHostedReviewBaseRef(ref) +} + +export function useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting +}: UseCreatePullRequestDialogFieldsOptions) { + const commitMessageAi = settings?.commitMessageAi + const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice( + commitMessageAi?.agentId, + settings?.defaultTuiAgent + ) + const initializedFromEligibilityRef = useRef(null) + const generateInFlightRef = useRef(false) + const generationRequestIdRef = useRef(0) + const generationSeedRef = useRef(null) + const latestFieldsRef = useRef({ + base: '', + title: '', + body: '', + draft: false + }) + const [base, setBase] = useState('') + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [draft, setDraft] = useState(false) + const [baseQuery, setBaseQuery] = useState('') + const [baseResults, setBaseResults] = useState([]) + const [baseSearchError, setBaseSearchError] = useState(null) + const [generating, setGenerating] = useState(false) + const [generateError, setGenerateError] = useState(null) + + useEffect(() => { + latestFieldsRef.current = { base, title, body, draft } + }, [base, body, draft, title]) + + useEffect(() => { + if (!open) { + generationRequestIdRef.current += 1 + if (generateInFlightRef.current && worktreePath) { + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings, + worktreeId, + worktreePath, + connectionId + }) + } + generateInFlightRef.current = false + generationSeedRef.current = null + initializedFromEligibilityRef.current = null + setGenerating(false) + setGenerateError(null) + return + } + if (!eligibility) { + return + } + const initializationKey = `${repoId}:${branch}` + if (initializedFromEligibilityRef.current === initializationKey) { + return + } + // Why: eligibility refreshes while the dialog is open; only seed fields + // once per branch so late refreshes do not overwrite user edits. + initializedFromEligibilityRef.current = initializationKey + const initialBase = eligibility.defaultBaseRef ?? '' + setBase(stripBaseRef(initialBase)) + setTitle(eligibility.title ?? '') + setBody(eligibility.body ?? '') + setDraft(false) + setBaseQuery('') + setBaseResults([]) + setBaseSearchError(null) + setGenerateError(null) + }, [branch, eligibility, open, repoId, settings, worktreeId, worktreePath]) + + useEffect(() => { + if (!open || base) { + return + } + let stale = false + void getRuntimeRepoBaseRefDefault(settings, repoId) + .then((result) => { + if (!stale && result.defaultBaseRef) { + setBase(stripBaseRef(result.defaultBaseRef)) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [base, open, repoId, settings]) + + useEffect(() => { + if (!open || baseQuery.trim().length < 2) { + setBaseResults([]) + setBaseSearchError(null) + return + } + let stale = false + const timer = window.setTimeout(() => { + void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) + .then((results) => { + if (!stale) { + setBaseResults(results.map(stripBaseRef)) + setBaseSearchError(null) + } + }) + .catch(() => { + if (!stale) { + setBaseResults([]) + setBaseSearchError('Branch discovery failed.') + } + }) + }, 200) + return () => { + stale = true + window.clearTimeout(timer) + } + }, [baseQuery, open, repoId, settings]) + + let generateDisabledReason: string | undefined + if (submitting) { + generateDisabledReason = 'Create PR in progress...' + } else if (!commitMessageAi?.enabled) { + generateDisabledReason = 'Enable AI commit messages in Settings -> Git.' + } else if (!effectiveCommitMessageAgentId) { + generateDisabledReason = 'Pick an agent in Settings -> Git -> AI Commit Messages.' + } else if (isCustomAgentId(effectiveCommitMessageAgentId)) { + const command = commitMessageAi.customAgentCommand?.trim() ?? '' + if (!command) { + generateDisabledReason = + 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + } + } else if (!base.trim()) { + generateDisabledReason = 'Choose a base branch before generating.' + } + const generateDisabled = !generating && Boolean(generateDisabledReason) + + const handleGenerate = useCallback(async (): Promise => { + if (!worktreePath || !base.trim() || generateInFlightRef.current || generateDisabled) { + return + } + const requestId = generationRequestIdRef.current + 1 + generationRequestIdRef.current = requestId + const seed = { requestId, base, title, body, draft } + generationSeedRef.current = seed + generateInFlightRef.current = true + setGenerating(true) + setGenerateError(null) + try { + const connectionId = getConnectionId(worktreeId) ?? undefined + const result = await generateRuntimePullRequestFields( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { + base: stripBaseRef(base.trim()), + title, + body, + draft + } + ) + if (generationRequestIdRef.current !== requestId) { + return + } + if (!result.success) { + if (result.canceled) { + setGenerateError(null) + return + } + setGenerateError(result.error) + return + } + + const currentSeed = generationSeedRef.current + const latestFields = latestFieldsRef.current + if ( + !currentSeed || + currentSeed.requestId !== requestId || + currentSeed.base !== latestFields.base || + currentSeed.title !== latestFields.title || + currentSeed.body !== latestFields.body || + currentSeed.draft !== latestFields.draft + ) { + setGenerateError('Fields changed while generating. Run generate again for a fresh draft.') + return + } + setBase(stripBaseRef(result.fields.base)) + setBaseQuery('') + setBaseResults([]) + setTitle(result.fields.title) + setBody(result.fields.body) + setDraft(result.fields.draft) + setGenerateError(null) + } catch (error) { + if (generationRequestIdRef.current !== requestId) { + return + } + setGenerateError( + error instanceof Error ? error.message : 'Failed to generate pull request details' + ) + } finally { + if (generationRequestIdRef.current === requestId) { + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + } + } + }, [base, body, draft, generateDisabled, title, worktreeId, worktreePath]) + + const handleCancelGenerate = useCallback((): void => { + if (!worktreePath || !generateInFlightRef.current) { + return + } + generationRequestIdRef.current += 1 + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + setGenerateError(null) + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }) + }, [worktreeId, worktreePath]) + + return { + aiGenerationEnabled: commitMessageAi?.enabled === true, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.test.ts new file mode 100644 index 00000000000..2b3b252484d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { TreeNode } from './file-explorer-types' +import { getVisibleFileExplorerRows } from './useFileExplorerGitIgnoredRows' + +function row(relativePath: string): TreeNode { + return { + name: relativePath.split('/').at(-1) ?? relativePath, + path: `/repo/${relativePath}`, + relativePath, + isDirectory: false, + depth: 0 + } +} + +describe('getVisibleFileExplorerRows', () => { + it('keeps ignored files visible when the toggle is on', () => { + const rows = [row('src/index.ts'), row('dist/bundle.js')] + + expect(getVisibleFileExplorerRows(rows, new Set(['dist']), true)).toBe(rows) + }) + + it('filters ignored files and descendants when the toggle is off', () => { + const rows = [ + row('src/index.ts'), + row('dist'), + row('dist/bundle.js'), + row('dist2/bundle.js'), + row('.env') + ] + + expect( + getVisibleFileExplorerRows(rows, new Set(['dist', '.env']), false).map((r) => r.relativePath) + ).toEqual(['src/index.ts', 'dist2/bundle.js']) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.ts b/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.ts new file mode 100644 index 00000000000..f3d7335a515 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerGitIgnoredRows.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' +import type { TreeNode } from './file-explorer-types' +import { buildIgnoredSet, isPathIgnored } from './status-display' + +export function getVisibleFileExplorerRows( + flatRows: TreeNode[], + ignoredSet: Set, + showGitIgnoredFiles: boolean +): TreeNode[] { + return showGitIgnoredFiles + ? flatRows + : flatRows.filter((row) => !isPathIgnored(ignoredSet, row.relativePath)) +} + +export function useFileExplorerGitIgnoredRows( + activeWorktreeId: string | null, + worktreePath: string | null, + flatRows: TreeNode[], + activeRepoSupportsGit: boolean +): { + visibleFlatRows: TreeNode[] + rowsByPath: Map + ignoredByRelativePath: Set + showGitIgnoredFiles: boolean + toggleGitIgnoredFiles: () => void +} { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true + const [ignoredPaths, setIgnoredPaths] = useState([]) + const relativePaths = useMemo(() => flatRows.map((row) => row.relativePath), [flatRows]) + + useEffect(() => { + if ( + !activeRepoSupportsGit || + !activeWorktreeId || + !worktreePath || + relativePaths.length === 0 + ) { + setIgnoredPaths([]) + return + } + + let canceled = false + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + void getRuntimeGitIgnoredPaths( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + relativePaths + ) + .then((nextIgnoredPaths) => { + if (!canceled) { + setIgnoredPaths(nextIgnoredPaths) + } + }) + .catch(() => { + if (!canceled) { + setIgnoredPaths([]) + } + }) + + return () => { + canceled = true + } + }, [activeRepoSupportsGit, activeWorktreeId, relativePaths, worktreePath]) + + const ignoredSet = useMemo(() => buildIgnoredSet(ignoredPaths), [ignoredPaths]) + const visibleFlatRows = useMemo( + () => getVisibleFileExplorerRows(flatRows, ignoredSet, showGitIgnoredFiles), + [flatRows, ignoredSet, showGitIgnoredFiles] + ) + const rowsByPath = useMemo( + () => new Map(visibleFlatRows.map((row) => [row.path, row])), + [visibleFlatRows] + ) + const ignoredByRelativePath = useMemo( + () => (showGitIgnoredFiles ? ignoredSet : new Set()), + [ignoredSet, showGitIgnoredFiles] + ) + const toggleGitIgnoredFiles = useCallback(() => { + void updateSettings({ showGitIgnoredFiles: !showGitIgnoredFiles }) + }, [showGitIgnoredFiles, updateSettings]) + + return { + visibleFlatRows, + rowsByPath, + ignoredByRelativePath, + showGitIgnoredFiles, + toggleGitIgnoredFiles + } +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts index 29d89e3882f..1a583a852b2 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' +import { formatFileExplorerPathsForClipboard } from './file-explorer-selection' import { fileExplorerHasRedo, fileExplorerHasUndo, @@ -34,6 +35,18 @@ function isCmdZUndo(e: KeyboardEvent): boolean { return e.code === 'KeyZ' || e.key.toLowerCase() === 'z' } +function isCopyRelativePathShortcut(e: KeyboardEvent): boolean { + return e.code === 'KeyC' && e.altKey && e.shiftKey && (isMac ? e.metaKey : e.ctrlKey) +} + +function isCopyPathShortcut(e: KeyboardEvent): boolean { + return ( + e.code === 'KeyC' && + e.altKey && + ((isMac && e.metaKey && !e.shiftKey) || (!isMac && e.shiftKey && !e.ctrlKey)) + ) +} + /** * Keyboard shortcuts for the file explorer. * @@ -44,6 +57,7 @@ export function useFileExplorerKeys(opts: { containerRef: React.RefObject flatRows: TreeNode[] inlineInput: InlineInput | null + selectedPaths: Set selectedNode: TreeNode | null startRename: (node: TreeNode) => void requestDelete: (node: TreeNode) => void @@ -55,6 +69,8 @@ export function useFileExplorerKeys(opts: { flatRowsRef.current = opts.flatRows const inlineInputRef = useRef(opts.inlineInput) inlineInputRef.current = opts.inlineInput + const selectedPathsRef = useRef(opts.selectedPaths) + selectedPathsRef.current = opts.selectedPaths const selectedNodeRef = useRef(opts.selectedNode) selectedNodeRef.current = opts.selectedNode const startRenameRef = useRef(opts.startRename) @@ -144,24 +160,34 @@ export function useFileExplorerKeys(opts: { if (!focusInExplorer()) { return } - const node = selectedNodeRef.current - if (!node) { + const wantsCopyRelativePath = isCopyRelativePathShortcut(e) + const wantsCopyPath = isCopyPathShortcut(e) + if (!wantsCopyRelativePath && !wantsCopyPath) { + return + } + + const node = selectedNodeRef.current ?? findFocusedNode() + const selectedNodes = flatRowsRef.current.filter((row) => + selectedPathsRef.current.has(row.path) + ) + const fallbackNodes = selectedNodes.length > 0 ? selectedNodes : node ? [node] : [] + if (fallbackNodes.length === 0) { return } // ⌥⇧⌘C (Mac) / Ctrl+Shift+Alt+C (Win) — Copy Relative Path - if (e.code === 'KeyC' && e.altKey && e.shiftKey && (isMac ? e.metaKey : e.ctrlKey)) { + if (wantsCopyRelativePath) { e.preventDefault() - window.api.ui.writeClipboardText(node.relativePath) + window.api.ui.writeClipboardText( + formatFileExplorerPathsForClipboard(fallbackNodes, 'relative') + ) return } // ⌥⌘C (Mac) / Shift+Alt+C (Win) — Copy Path - if ( - e.code === 'KeyC' && - e.altKey && - ((isMac && e.metaKey && !e.shiftKey) || (!isMac && e.shiftKey && !e.ctrlKey)) - ) { + if (wantsCopyPath) { e.preventDefault() - window.api.ui.writeClipboardText(node.path) + window.api.ui.writeClipboardText( + formatFileExplorerPathsForClipboard(fallbackNodes, 'absolute') + ) } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts new file mode 100644 index 00000000000..f56341f4436 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts @@ -0,0 +1,111 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type React from 'react' +import type { TreeNode } from './file-explorer-types' +import { + createEmptyFileExplorerSelection, + createSingleFileExplorerSelection, + formatFileExplorerPathsForClipboard, + getFileExplorerActionNodes, + getFileExplorerSelectionMode, + updateFileExplorerSelection, + updateFileExplorerSelectionPaths +} from './file-explorer-selection' + +type UseFileExplorerSelectionResult = { + selectedPath: string | null + selectedPaths: Set + setSingleSelectedPath: React.Dispatch> + resetSelection: () => void + selectRowWithModifiers: ( + node: TreeNode, + event: React.MouseEvent, + onReplaceClick: (node: TreeNode) => void + ) => void + preserveSelectionForContextMenu: (node: TreeNode) => void + copyPathsForNode: (node: TreeNode, pathKind: 'absolute' | 'relative') => void +} + +export function useFileExplorerSelection( + flatRows: TreeNode[], + isMac: boolean +): UseFileExplorerSelectionResult { + const [selectionState, setSelectionState] = useState(createEmptyFileExplorerSelection) + const selectionStateRef = useRef(selectionState) + const flatRowsRef = useRef(flatRows) + selectionStateRef.current = selectionState + flatRowsRef.current = flatRows + + const orderedPaths = useMemo(() => flatRows.map((row) => row.path), [flatRows]) + + const setSingleSelectedPath = useCallback((value: React.SetStateAction) => { + setSelectionState((prev) => { + if (typeof value === 'function') { + // Why: legacy watcher cleanup still speaks in single-path updater terms; + // apply it across the whole selected set so stale multi-selections converge. + return updateFileExplorerSelectionPaths(prev, value) + } + const nextPath = value + return createSingleFileExplorerSelection(nextPath) + }) + }, []) + + const resetSelection = useCallback(() => { + setSelectionState(createEmptyFileExplorerSelection()) + }, []) + + const selectRowWithModifiers = useCallback( + ( + node: TreeNode, + event: React.MouseEvent, + onReplaceClick: (node: TreeNode) => void + ) => { + const selectionMode = getFileExplorerSelectionMode( + { + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey + }, + isMac + ) + + if (selectionMode === 'replace') { + onReplaceClick(node) + return + } + + setSelectionState((prev) => + updateFileExplorerSelection(prev, orderedPaths, node.path, selectionMode) + ) + }, + [isMac, orderedPaths] + ) + + const preserveSelectionForContextMenu = useCallback((node: TreeNode) => { + // Why: right-clicking an existing multi-selection should keep the copy + // target set; right-clicking outside it should behave like a single item. + setSelectionState((prev) => + prev.selectedPaths.has(node.path) ? prev : createSingleFileExplorerSelection(node.path) + ) + }, []) + + const copyPathsForNode = useCallback((node: TreeNode, pathKind: 'absolute' | 'relative') => { + const actionNodes = getFileExplorerActionNodes( + flatRowsRef.current, + selectionStateRef.current.selectedPaths, + node + ) + void window.api.ui.writeClipboardText( + formatFileExplorerPathsForClipboard(actionNodes, pathKind) + ) + }, []) + + return { + selectedPath: selectionState.activePath, + selectedPaths: selectionState.selectedPaths, + setSingleSelectedPath, + resetSelection, + selectRowWithModifiers, + preserveSelectionForContextMenu, + copyPathsForNode + } +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index f7b0f80eeb2..80f3d49f57b 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -58,7 +58,8 @@ async function usePollingOnce( useEffect: (effect: () => void | (() => void)) => { effect() }, - useMemo: (factory: () => unknown) => factory() + useMemo: (factory: () => unknown) => factory(), + useRef: (initial: T) => ({ current: initial }) } }) @@ -159,4 +160,87 @@ describe('useGitStatusPolling', () => { expect(gitStatus).not.toHaveBeenCalled() expect(state.setGitStatus).not.toHaveBeenCalled() }) + + it('does not overlap slow git status polls and runs one trailing refresh', async () => { + vi.resetModules() + let intervalCallback: (() => void) | null = null + let resolveFirst!: (value: GitStatusResult) => void + const firstStatus = new Promise((resolve) => { + resolveFirst = resolve + }) + const state: PollState = { + activeWorktreeId: worktree.id, + updateWorktreeGitIdentity: vi.fn(), + setGitStatus: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined), + setUpstreamStatus: vi.fn(), + setConflictOperation: vi.fn(), + gitConflictOperationByWorktree: {}, + sshConnectionStates: new Map() + } + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'refs/heads/main' + } + const gitStatus = vi.fn().mockReturnValueOnce(firstStatus).mockResolvedValue(status) + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useMemo: (factory: () => unknown) => factory(), + useRef: (initial: T) => ({ current: initial }) + } + }) + + vi.doMock('@/store', () => ({ + useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { + getState: () => ({ settings: null }) + }) + })) + vi.doMock('@/store/selectors', () => ({ + useActiveWorktree: () => worktree, + useAllWorktrees: () => [worktree], + useRepoById: () => repo, + useRepoMap: () => new Map([[repo.id, repo]]) + })) + vi.doMock('@/lib/connection-context', () => ({ + getConnectionId: () => undefined + })) + + vi.stubGlobal('window', { + api: { git: { status: gitStatus } }, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { hasFocus: () => true }) + vi.stubGlobal( + 'setInterval', + vi.fn((callback: () => void) => { + intervalCallback = callback + return 1 + }) + ) + vi.stubGlobal('clearInterval', vi.fn()) + + const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling') + GitStatusPollingHarness({ runPolling }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + + expect(intervalCallback).toBeTypeOf('function') + const tick = intervalCallback as unknown as () => void + tick() + tick() + expect(gitStatus).toHaveBeenCalledTimes(1) + + resolveFirst(status) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(2)) + }) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index e989b921bdf..f39d9272e37 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/store/selectors' import type { GitConflictOperation } from '../../../../shared/types' @@ -6,6 +6,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' +import { createCoalescedPollRunner } from './coalesced-poll-runner' const POLL_INTERVAL_MS = 3000 @@ -21,6 +22,9 @@ export function useGitStatusPolling(): void { const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const repoMap = useRepoMap() + const statusPollInFlightRef = useRef(false) + const statusPollRerunRef = useRef(false) + const fetchStatusRef = useRef<() => void>(() => {}) const worktreePath = activeWorktree?.path ?? null const activeRepoId = activeWorktree?.repoId ?? null @@ -55,7 +59,7 @@ export function useGitStatusPolling(): void { return result }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) - const fetchStatus = useCallback(async () => { + const runFetchStatus = useCallback(async () => { if (!activeWorktreeId || !worktreePath || !activeRepoSupportsGit) { return } @@ -91,6 +95,25 @@ export function useGitStatusPolling(): void { updateWorktreeGitIdentity ]) + const fetchStatus = useCallback(() => { + if (statusPollInFlightRef.current) { + statusPollRerunRef.current = true + return + } + statusPollInFlightRef.current = true + // Why: git status can exceed the 3s poll interval on large repos. Keep at + // most one subprocess chain in flight, then run one trailing refresh if a + // tick was skipped so the UI catches up without process pileups. + void runFetchStatus().finally(() => { + statusPollInFlightRef.current = false + if (statusPollRerunRef.current) { + statusPollRerunRef.current = false + fetchStatusRef.current() + } + }) + }, [runFetchStatus]) + fetchStatusRef.current = fetchStatus + useEffect(() => { void fetchStatus() // Why: skip IPC-heavy git status calls when the window is not focused. @@ -142,15 +165,20 @@ export function useGitStatusPolling(): void { } } - void pollStale() + // Why: remote conflict probes can exceed the 3s interval. Keep one poll in + // flight and coalesce skipped ticks into one trailing pass so stale badges + // catch up without stacking SSH/RPC work. + const pollRunner = createCoalescedPollRunner(pollStale) + pollRunner.run() const intervalId = setInterval(() => { if (document.hasFocus()) { - void pollStale() + pollRunner.run() } }, POLL_INTERVAL_MS) - const onFocus = (): void => void pollStale() + const onFocus = (): void => pollRunner.run() window.addEventListener('focus', onFocus) return () => { + pollRunner.dispose() clearInterval(intervalId) window.removeEventListener('focus', onFocus) } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts index 0896742cd37..1113b8b1cbf 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts @@ -1,6 +1,10 @@ import { useState, useCallback, useEffect, useRef, type RefObject } from 'react' import type { GitStatusEntry } from '../../../../shared/types' +function isMacPlatform(): boolean { + return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') +} + export type FlatEntry = { key: string entry: GitStatusEntry @@ -92,7 +96,7 @@ export function useSourceControlSelection({ const handleSelect = useCallback((e: React.MouseEvent, key: string, entry: GitStatusEntry) => { const isShift = e.shiftKey - const isCmdOrCtrl = e.metaKey || e.ctrlKey + const isCmdOrCtrl = isMacPlatform() ? e.metaKey : e.ctrlKey if (isShift) { const nextSelected = getSelectionRangeKeys(flatEntriesRef.current, anchorKeyRef.current, key) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index ee1d6567d75..d3759de4466 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -1,3 +1,4 @@ +import type React from 'react' import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' import { Label } from '../ui/label' import { Separator } from '../ui/separator' @@ -16,6 +17,35 @@ type AppearancePaneProps = { fontSuggestions: string[] } +function ToggleSwitchButton({ + checked, + onToggle, + ariaLabel +}: { + checked: boolean + onToggle: () => void + ariaLabel?: string +}): React.JSX.Element { + return ( + + ) +} + const STATUS_BAR_TOGGLES: readonly { id: StatusBarItem title: string @@ -98,6 +128,11 @@ const LAYOUT_ENTRIES: SettingsSearchEntry[] = [ title: 'Open Right Sidebar by Default', description: 'Automatically expand the file explorer panel when creating a new worktree.', keywords: ['layout', 'file explorer', 'sidebar'] + }, + { + title: 'Show Git-Ignored Files', + description: 'Dim files matched by .gitignore in the file explorer.', + keywords: ['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide'] } ] @@ -248,24 +283,32 @@ export function AppearancePane({ Automatically expand the file explorer panel when creating a new worktree.

    - + /> + + + +
    + +

    + Turn off to hide files matched by .gitignore from the file explorer. +

    +
    + + updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) }) + } + />
    ) : null, @@ -288,24 +331,10 @@ export function AppearancePane({

    Show Orca in the titlebar.

    - + updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })} + /> ) : null, @@ -333,22 +362,11 @@ export function AppearancePane({

    {toggle.toggleDescription}

    - + toggleStatusBarItem(toggle.id)} + ariaLabel={toggle.title} + /> ) })} @@ -372,24 +390,10 @@ export function AppearancePane({ Show the Tasks button at the top of the left sidebar.

- + updateSettings({ showTasksButton: !settings.showTasksButton })} + /> ) : null diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index f9478fd1998..dd1c16b2292 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -64,6 +64,8 @@ describe('CommitMessageAiPane', () => { expect(markup).toContain('Thinking effort') expect(markup).toContain('Higher effort produces more careful messages') expect(markup).toContain('Use Conventional Commits.') + expect(markup).toContain('Save') + expect(markup).toContain('Saved') }) it('renders custom command settings for custom agents', () => { @@ -85,6 +87,28 @@ describe('CommitMessageAiPane', () => { expect(markup).toContain('ollama run llama3.1 {prompt}') }) + it('shows an unconfigured state when the default agent is unsupported', () => { + const markup = renderPane( + buildSettings({ + defaultTuiAgent: 'gemini', + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + }) + ) + + expect(markup).toContain('Not configured') + expect(markup).toContain('Your default agent is Gemini') + expect(markup).toContain('Choose Claude, Codex, or Custom') + expect(markup).not.toContain('Which model the selected agent uses') + expect(markup).not.toContain('Thinking effort') + }) + it('keeps custom command discoverable in settings search metadata', () => { const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find( (entry) => entry.title === 'Custom command' diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index ff9000b4162..58887223bf9 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -2,21 +2,21 @@ model dropdown, thinking effort dropdown, custom command, custom prompt) is a SearchableSetting block, and splitting the pane across files would scatter the ~6 conditional render branches without making any of them clearer. */ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Terminal } from 'lucide-react' import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types' import { CUSTOM_AGENT_ID, - DEFAULT_COMMIT_MESSAGE_AGENT_ID, getCommitMessageAgentCapability, isCustomAgentId, listCommitMessageAgentCapabilities, - type CommitMessageAgentChoice, + resolveCommitMessageAgentChoice, type CommitMessageAgentCapability, type CommitMessageModelCapability } from '../../../../shared/commit-message-agent-spec' import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { Button } from '../ui/button' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { useAppStore } from '../../store' @@ -25,7 +25,9 @@ import { matchesSettingsSearch } from './settings-search' type CommitMessageAiPaneProps = { settings: GlobalSettings - updateSettings: (updates: Partial) => void + updateSettings: (updates: Partial) => void | Promise + onCustomPromptDirtyChange?: (dirty: boolean) => void + customPromptDiscardSignal?: number } const EMPTY_SETTINGS: CommitMessageAiSettings = { @@ -37,6 +39,8 @@ const EMPTY_SETTINGS: CommitMessageAiSettings = { customAgentCommand: '' } +const UNCONFIGURED_AGENT_SELECT_VALUE = '' + function readSettings(settings: GlobalSettings): CommitMessageAiSettings { return settings.commitMessageAi ?? EMPTY_SETTINGS } @@ -76,15 +80,64 @@ function resolveSelectedThinking( export function CommitMessageAiPane({ settings, - updateSettings + updateSettings, + onCustomPromptDirtyChange, + customPromptDiscardSignal }: CommitMessageAiPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const config = readSettings(settings) + const persistedCustomPrompt = config.customPrompt + const [customPromptDraft, setCustomPromptDraft] = useState(persistedCustomPrompt) + const [isSavingCustomPrompt, setIsSavingCustomPrompt] = useState(false) + const persistedCustomPromptRef = useRef(persistedCustomPrompt) + const isCustomPromptDirty = customPromptDraft !== persistedCustomPrompt + + useEffect(() => { + persistedCustomPromptRef.current = persistedCustomPrompt + }, [persistedCustomPrompt]) + + useEffect(() => { + if (!isCustomPromptDirty) { + setCustomPromptDraft(persistedCustomPrompt) + } + }, [isCustomPromptDirty, persistedCustomPrompt]) + + useEffect(() => { + setCustomPromptDraft(persistedCustomPromptRef.current) + // Why: parent navigation guards use this signal after the user confirms + // they want to leave without saving the prompt draft. + }, [customPromptDiscardSignal]) + + useEffect(() => { + onCustomPromptDirtyChange?.(isCustomPromptDirty) + }, [isCustomPromptDirty, onCustomPromptDirtyChange]) + + useEffect( + () => () => { + onCustomPromptDirtyChange?.(false) + }, + [onCustomPromptDirtyChange] + ) const agentCapabilities = useMemo(listCommitMessageAgentCapabilities, []) - const activeAgentId: CommitMessageAgentChoice = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID - const isCustom = isCustomAgentId(activeAgentId) - const activeCapability = isCustom ? undefined : getCommitMessageAgentCapability(activeAgentId) + const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) + const activeAgentSelectValue = resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE + const unsupportedDefaultAgent = + resolvedAgentId === null && + !config.agentId && + settings.defaultTuiAgent && + settings.defaultTuiAgent !== 'blank' + ? settings.defaultTuiAgent + : null + const unsupportedDefaultAgentLabel = unsupportedDefaultAgent + ? (AGENT_CATALOG.find((a) => a.id === unsupportedDefaultAgent)?.label ?? + unsupportedDefaultAgent) + : null + const isCustom = isCustomAgentId(resolvedAgentId) + const activeCapability = + resolvedAgentId && !isCustomAgentId(resolvedAgentId) + ? getCommitMessageAgentCapability(resolvedAgentId) + : undefined const activeModel = activeCapability ? resolveSelectedModel(config, activeCapability) : null const activeThinking = activeModel ? resolveSelectedThinking(config, activeModel) : undefined @@ -99,11 +152,15 @@ export function CommitMessageAiPane({ return } // Why: when the user enables the feature for the first time, hydrate the - // agent / model / thinking choices from provider capabilities so the - // Generate button works immediately without forcing them to pick first. - // If the user previously persisted 'custom', we keep that and let them - // re-edit the command — no implicit reset to a preset. - const seedAgentId: TuiAgent | 'custom' = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID + // agent / model / thinking choices from their default agent when possible + // so Generate works without maintaining a second agent preference. If the + // user previously persisted 'custom', keep it and let them re-edit the + // command — no implicit reset to a preset. + const seedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) + if (!seedAgentId) { + writeConfig({ enabled: true, agentId: null }) + return + } const seedCapability = isCustomAgentId(seedAgentId) ? undefined : getCommitMessageAgentCapability(seedAgentId) @@ -127,6 +184,9 @@ export function CommitMessageAiPane({ } const onAgentChange = (newAgentId: string): void => { + if (newAgentId === UNCONFIGURED_AGENT_SELECT_VALUE) { + return + } if (isCustomAgentId(newAgentId)) { writeConfig({ agentId: CUSTOM_AGENT_ID }) return @@ -197,8 +257,20 @@ export function CommitMessageAiPane({ }) } - const onCustomPromptChange = (value: string): void => { - writeConfig({ customPrompt: value }) + const onSaveCustomPrompt = async (): Promise => { + if (!isCustomPromptDirty || isSavingCustomPrompt) { + return + } + setIsSavingCustomPrompt(true) + try { + await updateSettings({ commitMessageAi: { ...config, customPrompt: customPromptDraft } }) + } finally { + setIsSavingCustomPrompt(false) + } + } + + const onDiscardCustomPrompt = (): void => { + setCustomPromptDraft(persistedCustomPrompt) } const sections: React.ReactNode[] = [] @@ -268,30 +340,38 @@ export function CommitMessageAiPane({ worktrees, or the SSH host for remote ones.

- +
+ + {unsupportedDefaultAgentLabel ? ( +

+ Your default agent is {unsupportedDefaultAgentLabel}, which does not support commit + message generation yet. Choose Claude, Codex, or Custom. +

+ ) : null} +
) } @@ -364,8 +444,8 @@ export function CommitMessageAiPane({

- Smaller models default to lower latency and cost. Pick a larger one if the diffs you - review tend to need more reasoning. + Defaults to the strongest available model for the selected agent. Pick a smaller one if + you prefer lower latency or cost.