diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 33645a3c253..efcdf2b521e 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -31,11 +31,11 @@ jobs: os: [ubuntu-22.04, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - if: runner.os == 'Linux' @@ -60,11 +60,11 @@ jobs: env: ORCA_COMPUTER_E2E: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: pnpm install --frozen-lockfile @@ -80,11 +80,11 @@ jobs: ORCA_COMPUTER_E2E: '1' ACCESSIBILITY_ENABLED: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool @@ -105,11 +105,11 @@ jobs: env: ORCA_COMPUTER_E2E: '1' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: run_install: false - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b1fcaf1f36f..c07a2cc32e0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -37,12 +37,12 @@ jobs: run: sudo apt-get update && sudo apt-get install -y build-essential python3 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -65,7 +65,7 @@ jobs: run: npx electron-vite build --mode e2e - name: Upload E2E build output - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: e2e-build-out path: out/ @@ -94,7 +94,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -111,12 +111,12 @@ jobs: run: sudo apt-get install -y xvfb - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -136,7 +136,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download E2E build output - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: e2e-build-out path: out/ @@ -156,7 +156,7 @@ jobs: # re-running locally. - name: Upload Playwright traces if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-traces-${{ matrix.shard_name }} path: test-results/ diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 28dac408130..db1970ae5ed 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -48,7 +48,7 @@ jobs: contents: read steps: - name: Checkout orca - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Resolve tag and version id: tag @@ -113,7 +113,7 @@ jobs: # installation token can. - name: Generate buf0-bot token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: 2590194 private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} @@ -121,7 +121,7 @@ jobs: repositories: homebrew-orca - name: Checkout tap - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: stablyai/homebrew-orca path: tap diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index d281293318b..c437db7f21a 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 5 steps: - name: Apply OS label from issue form - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 9e7d88666aa..f24bd922ea3 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -23,15 +23,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -39,7 +39,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -51,7 +51,7 @@ jobs: run: cd android && ./gradlew assembleRelease - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: orca-mobile-apk path: mobile/android/app/build/outputs/apk/release/*.apk diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 53b91e5b519..f65ca2f8006 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 72bd00aec0b..1887a494b63 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,18 +14,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 97b32b4895f..d115007a09d 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -64,13 +64,13 @@ jobs: should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} steps: - name: Checkout ref - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json @@ -424,7 +424,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: refs/tags/${{ needs.cut.outputs.tag }} @@ -492,18 +492,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: refs/tags/${{ needs.cut.outputs.tag }} # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json cache: pnpm @@ -526,7 +526,7 @@ jobs: # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - name: Cache electron-builder downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ matrix.eb_cache_path }} key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -538,7 +538,7 @@ jobs: # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. - name: Install dependencies - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 @@ -629,7 +629,7 @@ jobs: # don't require a manual re-run. - name: Publish release artifacts (macOS) if: matrix.platform == 'mac' - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 45 max_attempts: 3 @@ -645,7 +645,7 @@ jobs: - name: Publish release artifacts if: matrix.platform != 'mac' - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 30 max_attempts: 3 @@ -705,10 +705,10 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml index dedf48cb432..5a5dcb670c2 100644 --- a/.github/workflows/track-community-prs.yaml +++ b/.github/workflows/track-community-prs.yaml @@ -20,14 +20,14 @@ jobs: steps: - name: Generate bufo-bot token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: 2590194 private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} owner: stablyai - name: Add PR to project - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: PROJECT_OWNER: stablyai PROJECT_NUMBER: '13' diff --git a/README.md b/README.md index 778c0311654..3bedf42646d 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Control your agents from your phone.

- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9) +- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10) --- diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index f52544bcf1c..e100def9346 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -73,6 +73,43 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b var DEFAULT_FILE = 'sh'; var DEFAULT_NAME = 'xterm'; var DESTROY_SOCKET_TIMEOUT_MS = 200; +diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js +index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644 +--- a/lib/conpty_console_list_agent.js ++++ b/lib/conpty_console_list_agent.js +@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); + var utils_1 = require("./utils"); + var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; + var shellPid = parseInt(process.argv[2], 10); +-var consoleProcessList = getConsoleProcessList(shellPid); ++var consoleProcessList; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} ++catch (_a) { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send({ consoleProcessList: consoleProcessList }); + process.exit(0); + //# sourceMappingURL=conpty_console_list_agent.js.map +diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts +index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644 +--- a/src/conpty_console_list_agent.ts ++++ b/src/conpty_console_list_agent.ts +@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils'; + const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList; + const shellPid = parseInt(process.argv[2], 10); +-const consoleProcessList = getConsoleProcessList(shellPid); ++let consoleProcessList: number[]; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} catch { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send!({ consoleProcessList }); + process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644 --- a/src/unix/pty.cc diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index 8ab5eb5ef18..25662d10755 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -3,6 +3,7 @@ import { cpSync, existsSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, @@ -10,6 +11,7 @@ import { rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { platform as osPlatform, tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -21,7 +23,6 @@ const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) const { version: electronVersion } = electronRequire('./package.json') const { downloadArtifact } = electronRequire('@electron/get') -const extract = electronRequire('extract-zip') const platformPath = getElectronPlatformPath() try { @@ -116,7 +117,7 @@ async function installElectronPackageBinary() { // Why: CI has observed partial extracts directly under node_modules/electron // that leave only dist/locales. Verify in temp before replacing package dist. - await extract(zipPath, { dir: extractDir }) + extractElectronArchive(zipPath, extractDir) const extractedExecutable = resolve(extractDir, platformPath) if (!existsSync(extractedExecutable)) { console.error('[electron-package] Electron archive extract did not contain executable.') @@ -126,8 +127,7 @@ async function installElectronPackageBinary() { process.exit(1) } - rmSync(electronDistDir, { recursive: true, force: true }) - cpSync(extractDir, electronDistDir, { recursive: true }) + moveExtractedElectronDist(extractDir, electronDistDir) const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts') if (existsSync(srcTypeDefPath)) { @@ -138,6 +138,90 @@ async function installElectronPackageBinary() { } } +function extractElectronArchive(zipPath, extractDir) { + mkdirSync(extractDir, { recursive: true }) + // Why: extract-zip/Electron install.js can leave Node 24 with an unsettled + // promise and no active handles on CI. Host unzip tools fail synchronously. + const command = getExtractorCommand(zipPath, extractDir) + const result = spawnSync(command.file, command.args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error(formatExtractorFailure(command, result)) + } +} + +function moveExtractedElectronDist(extractDir, electronDistDir) { + rmSync(electronDistDir, { recursive: true, force: true }) + try { + // Why: macOS Electron archives rely on framework symlinks. Moving the + // verified tree preserves them exactly; copying has broken them in CI. + renameSync(extractDir, electronDistDir) + } catch (/** @type {any} */ err) { + if (err?.code !== 'EXDEV') { + throw err + } + cpSync(extractDir, electronDistDir, { + recursive: true, + dereference: false, + verbatimSymlinks: true + }) + } +} + +function getExtractorCommand(zipPath, extractDir) { + if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) { + return { + file: process.execPath, + args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir], + label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}` + } + } + + if (osPlatform() === 'win32') { + return { + file: process.env.ORCA_POWERSHELL_BIN || 'powershell', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + [ + "$ErrorActionPreference = 'Stop'", + `Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force` + ].join('; ') + ], + label: 'powershell Expand-Archive' + } + } + + return { + file: process.env.ORCA_UNZIP_BIN || 'unzip', + args: ['-q', zipPath, '-d', extractDir], + label: 'unzip' + } +} + +function quotePowerShellLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'` +} + +function formatExtractorFailure(command, result) { + return [ + `[electron-package] ${command.label} failed with status ${result.status}.`, + result.stdout ? `stdout:\n${result.stdout.trim()}` : '', + result.stderr ? `stderr:\n${result.stderr.trim()}` : '' + ] + .filter(Boolean) + .join('\n') +} + function shouldUseRemoteChecksums() { return Boolean( process.env.electron_use_remote_checksums || diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index 5950d8d9b9c..95c1057eb91 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -1,7 +1,7 @@ import { - chmodSync, copyFileSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -25,7 +25,7 @@ describe('install-electron-package-binary', () => { try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir) - writeFakeExtractZip(projectDir, { createExecutable: true }) + writeFakeExtractor(projectDir, { createExecutable: true }) const result = runInstallScript(projectDir) @@ -36,6 +36,13 @@ describe('install-electron-package-binary', () => { expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe( 'electron' ) + if (process.platform !== 'win32') { + expect( + lstatSync( + join(projectDir, 'node_modules', 'electron', 'dist', 'version-link') + ).isSymbolicLink() + ).toBe(true) + } expect(result.stdout).toContain('Repaired Electron path.txt -> electron') } finally { rmSync(projectDir, { recursive: true, force: true }) @@ -48,7 +55,7 @@ describe('install-electron-package-binary', () => { try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir) - writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeExtractor(projectDir, { createExecutable: false }) mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), { recursive: true }) @@ -64,13 +71,13 @@ describe('install-electron-package-binary', () => { } }) - it('does not exit successfully when Electron extract never settles', () => { + it('does not exit successfully when Electron download never settles', () => { const projectDir = mkTempProject() try { writeFakeElectronPackage(projectDir) - writeFakeElectronGet(projectDir) - writeFakeExtractZip(projectDir, { createExecutable: false, neverSettles: true }) + writeFakeElectronGet(projectDir, { downloadNeverSettles: true }) + writeFakeExtractor(projectDir, { createExecutable: false }) const result = runInstallScript(projectDir) @@ -100,7 +107,8 @@ function runInstallScript(projectDir) { env: { ...process.env, npm_config_platform: 'linux', - npm_config_arch: 'x64' + npm_config_arch: 'x64', + ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs') } }) } @@ -127,7 +135,7 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) ) } -function writeFakeElectronGet(projectDir) { +function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) { const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') mkdirSync(getDir, { recursive: true }) writeFileSync( @@ -137,6 +145,9 @@ const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs') const { join } = require('node:path') exports.downloadArtifact = async function downloadArtifact(details) { appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n') + if (${JSON.stringify(downloadNeverSettles)}) { + return new Promise(() => {}) + } mkdirSync(details.cacheRoot, { recursive: true }) const artifactPath = join(details.cacheRoot, 'electron.zip') writeFileSync(artifactPath, 'fake zip') @@ -146,25 +157,21 @@ exports.downloadArtifact = async function downloadArtifact(details) { ) } -function writeFakeExtractZip(projectDir, { createExecutable, neverSettles = false }) { - const extractDir = join(projectDir, 'node_modules', 'electron', 'node_modules', 'extract-zip') - mkdirSync(extractDir, { recursive: true }) +function writeFakeExtractor(projectDir, { createExecutable }) { writeFileSync( - join(extractDir, 'index.js'), + join(projectDir, 'fake-extractor.cjs'), ` -const { mkdirSync, writeFileSync } = require('node:fs') +const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs') const { join } = require('node:path') -module.exports = async function extract(_zipPath, options) { - mkdirSync(join(options.dir, 'locales'), { recursive: true }) - if (${JSON.stringify(neverSettles)}) { - return new Promise(() => {}) - } - if (${JSON.stringify(createExecutable)}) { - writeFileSync(join(options.dir, 'electron'), '') - writeFileSync(join(options.dir, 'version'), 'v41.5.0') +const extractDir = process.argv[3] +mkdirSync(join(extractDir, 'locales'), { recursive: true }) +if (${JSON.stringify(createExecutable)}) { + writeFileSync(join(extractDir, 'electron'), '') + writeFileSync(join(extractDir, 'version'), 'v41.5.0') + if (process.platform !== 'win32') { + symlinkSync('version', join(extractDir, 'version-link')) } } ` ) - chmodSync(join(extractDir, 'index.js'), 0o755) } diff --git a/docs/orchestration-reset-scope-validation.md b/docs/orchestration-reset-scope-validation.md new file mode 100644 index 00000000000..71c02abae0c --- /dev/null +++ b/docs/orchestration-reset-scope-validation.md @@ -0,0 +1,89 @@ +# Orchestration Reset Scope Validation + +## Problem + +`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024). + +## Root Cause + +`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch. + +## Non-goals + +- Redesign orchestration storage or reset semantics. +- Add confirmation prompts to the RPC protocol. +- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior. +- Change unrelated orchestration commands or provider-specific behavior. + +## Design + +1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`. +2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state. +3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls. +4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method. +5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain. +6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation. + +## Data Flow + +- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`. +- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects. +- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects. + +## Edge Cases + +- `{}` rejects and leaves messages and tasks unchanged. +- `{ all: false }` rejects and leaves messages and tasks unchanged. +- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged. +- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged. +- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes. +- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present. +- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work. +- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state. +- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix. + +## Test Plan + +- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions. +- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior. +- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly. +- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI. +- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files. +- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots. + +## UI Quality Bar + +Not UI-visible. + +## Review Screenshots + +No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior. + +## Rollout + +1. Tighten `ResetParams` validation. +2. Simplify the reset handler to trust validated single-scope params. +3. Make CLI no-flag reset pass `all: true` explicitly. +4. Add RPC regression tests for invalid params preserving state. +5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough. +6. Run targeted tests, typecheck, and lint. + +## Lightweight Eng Review + +- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes. +- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`. +- Failure modes covered: + - Empty reset params cannot mutate state. + - Contradictory scopes cannot partially apply the first truthy branch. + - Failed validation happens before any database reset method runs. + - CLI shorthand cannot rely on a dangerous RPC fallback. + - CLI multi-flag input fails through the same RPC validation as any other caller. +- Test coverage required: + - `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state. + - `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing. + - `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented. +- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked. +- UI quality bar: Not UI-visible. +- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior. +- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller. +- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change. diff --git a/docs/show-full-tab-title-tooltip.md b/docs/show-full-tab-title-tooltip.md new file mode 100644 index 00000000000..96ea7a0d39f --- /dev/null +++ b/docs/show-full-tab-title-tooltip.md @@ -0,0 +1,131 @@ +# Show Full Tab Title Tooltip + +## Problem + +Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus. + +- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip. +- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip. +- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip. +- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`. +- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one. + +## Root Cause + +The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it. + +## Non-Goals + +- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics. +- Do not add persistence, IPC, or main-process behavior. +- Do not replace the tab bar layout or introduce a new tooltip primitive. +- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful. + +## Design + +1. Add full-title tooltips to non-editing tabs in the existing tab components. + - Terminal: display `tab.customTitle ?? tab.title`. + - Browser: display `getBrowserTabLabel(tab)`. + - Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label. +2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`. + - Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide. + - In non-rename states, wrap the sortable tab root with `...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip. + - Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu. + - Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab. + - Render `full label`. + - `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed. + - Keep tooltip content non-interactive; it is only a label. +3. Keep edit states out of the tooltip path. + - Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip. + - Double-click handlers for rename/pin must keep their current event behavior. +4. Preserve drag, activation, and close controls. + - `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths. + - Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking. + - The outer tab remains the sortable/activation target. +5. Add focused regression coverage for the render contract. + - Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label. + - Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes. + +## Data Flow + +- Store/runtime tab state updates title fields. +- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`. +- Each tab component computes the displayed label. +- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path. +- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay. +- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path. + +## Edge Cases + +- Terminal custom title overrides the runtime title in both visible label and tooltip. +- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props. +- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label. +- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab. +- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges. +- Rename modes must not show stale title tooltips over the input. +- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior. +- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged. +- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes. +- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs. +- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use. +- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted. + +## Test Plan + +- Unit: add or update tab-bar component tests that mock tooltip primitives and assert: + - terminal tab label tooltip content uses custom title when present; + - browser tab label tooltip content uses `getBrowserTabLabel`; + - editor tab label tooltip content uses `getEditorDisplayLabel`; + - the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability; + - rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state. +- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works. +- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label. +- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed. +- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- ` if focused files are added. + +## UI Quality Bar + +- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily. +- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography. +- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport. +- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift. +- Light and dark theme surfaces remain legible. + +## Review Screenshots + +1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip. +2. Browser tab with a long page title or URL hovered, showing the full title tooltip. +3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged. +4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring. + +## Rollout + +1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`. +2. Add focused tests for tooltip label wiring. +3. Run relevant tests, typecheck, and lint. +4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls. + +## Lightweight Eng Review + +- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue. +- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior. +- Failure modes covered: + - Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes. + - Rename mode could show stale tooltip content over an input if the tooltip is not gated out. + - Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it. + - Long tooltip text could overflow the viewport if content width is unbounded. + - Browser fallback labels could drift if tooltip recomputes a different label than the visible span. + - Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row. + - Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy. +- Test coverage required: + - Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components. + - Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability. + - Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels. +- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement. +- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark. +- Required review screenshots: + 1. Long terminal tab tooltip. + 2. Long browser tab tooltip. + 3. Long editor tab tooltip with existing adornments intact. + 4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state. +- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display. diff --git a/docs/ssh-handler-reregistration-port-forwards.md b/docs/ssh-handler-reregistration-port-forwards.md new file mode 100644 index 00000000000..f45e0120151 --- /dev/null +++ b/docs/ssh-handler-reregistration-port-forwards.md @@ -0,0 +1,243 @@ +# SSH Handler Re-registration Port Forwards + +## Problem + +Issue #2932 reported that macOS window reactivation can re-run +`attachMainWindowServices`, which calls `registerSshHandlers` again +([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)). + +Before this change, `registerSshHandlers` removed and re-added IPC handlers but +also replaced the module-level `connectionManager` and `portForwardManager` +([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438), +[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)). +`activeSessions` remains module-global +([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)), +so live relay sessions kept references to the old port-forward manager while +new IPC handlers read a fresh empty one. + +The visible failure is: + +1. Connect an SSH target. +2. Add a local port forward. +3. Close all windows on macOS while the app process remains alive. +4. Reactivate Orca, causing SSH handlers to register again. +5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot + remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can + fail because the fresh connection manager has no live connection, and + `ssh:disconnect` does not close the old SSH connection or local listener. + Re-adding the same local port fails because the old server remains bound. + +## Root Cause + +SSH handler registration mixes two lifetimes: + +- Process-lifetime session state: active SSH connections, relay sessions, port + listeners, relay lost backoff, reset/connect in-flight maps. +- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers. + +The previous re-registration path preserved `activeSessions` but replaced the +managers that sessions and IPC handlers must share. Port-forward IPC operations +use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992), +[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047), +[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)), +and disconnect/terminate cleanup also uses that variable +([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750), +[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)). +After replacement, those operations no longer targeted the manager that owns the +live local servers. Replacing `connectionManager` also strands the live +`SshConnection` objects: existing relay sessions still hold their current +connection, but new IPC handlers and `getSshConnectionManager()` see an empty +manager. + +## Non-goals + +- Do not change relay protocol, remote deployment, or SSH transport behavior. +- Do not redesign port-forward persistence or enrichment. +- Do not change renderer UI. +- Do not introduce a second SSH service layer. +- Do not force-dispose live SSH sessions merely because a window was recreated. + +## Design + +1. Preserve process-lifetime managers across handler re-registration. + Instantiate `SshConnectionManager` and `SshPortForwardManager` only when + absent; later `registerSshHandlers` calls reuse the existing instances. + +2. Refresh every live callback owner on re-registration. This is required; a + plain `connectionManager ??= new SshConnectionManager(callbacks)` is not + enough. + - `SshConnectionManager` must update callbacks used by both future and + existing `SshConnection` objects, either via explicit `setCallbacks` methods + on manager/connection or via a stable callback proxy whose implementation + is mutable. + - Existing `SshRelaySession` objects must refresh `getMainWindow`, store, + runtime, and detected-port callback references. Event handlers must call + the current callback at event time; do not capture the old `getMainWindow` + in long-lived provider callbacks. + - The credential-request tracking set must not be per-registration if live + connections can switch callbacks during an in-flight `ssh:connect`. + +3. Re-register IPC handlers and dependent global listeners on every call. + `ipcMain` handlers, advertised URL refresh, credential IPC, browse handler, + and power-monitor listeners are window-registration concerns and should still + point at the latest window. + +4. Preserve existing explicit teardown behavior. + `ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and + double-connect cleanup must still remove forwards through the shared manager + before detaching or disposing sessions. + +5. Add regression tests in `src/main/ipc/ssh.test.ts`. + Connect a target, add a mocked port forward, call `registerSshHandlers` + again, then assert: + - `ssh:listPortForwards` still returns the original forward. + - `ssh:removePortForward` can remove the original id. + - `ssh:addPortForward`/`ssh:updatePortForward` still use the original live + connection. + - A second re-registration followed by `ssh:disconnect` still calls + `removeAllForwards` and `disconnect` on the original shared managers. + - State, credential, PTY, and detected-port callbacks from an existing live + session publish to the newest window after re-registration. + +## Data Flow + +- First registration: + - `registerSshHandlers(store, getWindowA)` creates store wrapper, connection + manager, port-forward manager, handlers, listeners, and current callback + environment. + - `ssh:connect` creates a relay session with the shared port-forward manager. + - `ssh:addPortForward` stores a local server in that same manager. + +- Window reactivation: + - `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers. + - Existing managers are reused. + - Existing connection and relay-session callback owners are refreshed to the + latest store/runtime/window environment. + - New handlers close over `getWindowB` and call the same managers. + +- Cleanup: + - `ssh:removePortForward` and `ssh:disconnect` operate on the same manager + that owns the live forward, then broadcast through the latest window. + +## Edge Cases + +- Re-registration while a target is connected and has active port forwards. +- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect, + or disconnect is in flight. The operation must not split credential tracking + or create a session that holds stale callbacks. +- Re-registration while no targets are connected. +- Re-registration after the store object changes. Either update existing relay + sessions to use the new store/runtime or document and test the stronger + invariant that production re-registration always passes the same process + store/runtime. +- Re-registration after the window changes. All broadcasts, credential prompts, + PTY events, detected-port events, advertised URL refreshes, relay-loss state + changes, and terminal relay errors must use the newest `getMainWindow`. +- Disconnect after re-registration must release old local ports. +- `ssh:connect` after window reactivation must be idempotent when the existing + session is already ready and healthy: return the connected state without + tearing down forwards. Explicit reset/reconnect or non-ready replacement paths + must still await old port teardown before restoring forwards. +- `getSshConnectionManager()` consumers must continue to see live connections + after re-registration. +- Test isolation must not depend on module-singleton state leaking between + tests. Add explicit reset/teardown support if preserving managers makes + `beforeEach(registerSshHandlers)` insufficient. +- SSH and relay paths must keep working for remote targets; the fix must not + assume local filesystem or local-only execution. +- Windows/Linux remain unaffected: re-registration can still happen during + development or future window lifecycles, and the fix must avoid path or + platform assumptions. + +## Test Plan + +- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts` + - Add regression coverage for list/remove/disconnect after handler + re-registration. + - Add coverage that add/update after re-registration uses the still-live + connection manager connection, not a fresh empty manager. + - Add coverage that existing connection/session callbacks publish to a second + mock window after re-registration. + - Add an in-flight connect or credential-request test if callback refresh uses + mutable callback objects. + - Existing connect, disconnect, reset, relay-loss, and terminate tests cover + adjacent lifecycle behavior. +- Typecheck: `pnpm typecheck`. +- Lint: `pnpm lint`. +- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if + available in the running app, add a disposable local port forward, trigger + window/service re-registration by closing and reopening the main window on + macOS, then verify the forward remains listed and removable. IPC/unit tests + are supporting evidence only; if the golden path cannot be exercised safely, + halt before PR and report the missing evidence. + +## UI Quality Bar + +Not UI-visible. No layout, copy, or visual styling changes are expected. The +only user-visible expectation is that existing SSH port-forward rows remain +present and actionable after window reactivation. + +## Review Screenshots + +1. SSH target connected with a port forward listed before re-registration. +2. Same SSH target after window reactivation, showing the same port forward + still listed. +3. Same SSH target after removing the port forward, showing it gone without an + error. + +## Rollout + +1. Add the focused regression test to prove the current lifecycle bug. +2. Change SSH handler registration to reuse process-lifetime managers. +3. Run the focused test, then typecheck and lint. +4. Validate in Electron against an SSH target if feasible; otherwise halt + before PR if the golden-path SSH UI cannot be exercised. + +## Lightweight Eng Review + +- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence + redesign is needed because the broken boundary is manager replacement during + handler re-registration. +- Architecture/data flow: process-lifetime managers stay module-level and are + reused; window-lifetime IPC handlers/listeners are refreshed; existing + connection and relay-session callback owners must also be refreshed or proxied + so live events target the current BrowserWindow. +- Failure modes covered: + - Active forwards becoming invisible after re-registration. + - `ssh:removePortForward` missing the old forward id. + - `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty + connection manager. + - `ssh:disconnect` failing to close old SSH connections and local listeners + after re-registration. + - Store/window/runtime callback refresh after re-registration. + - Re-registration during in-flight connect/reset/reconnect. + - No-session re-registration continuing to work. +- Test coverage required: + - Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across + `registerSshHandlers` calls. + - Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after + re-registration. + - Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the + newest window after re-registration. + - Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test + teardown/reset of module singletons. + - Existing lifecycle tests for reset, terminate, relay loss, and sleep remain + adjacent coverage. +- Performance/blast radius: no material startup or IPC cost. Reusing managers + avoids leaked runtime state and does not add polling, watchers, or + cross-process calls. Callback refresh is O(number of live SSH connections and + sessions) per registration, which should be tiny. +- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state + rather than changing layout or copy. +- Required review screenshots: + 1. Connected SSH target with active port forward before re-registration. + 2. Connected SSH target with same port forward after re-registration. + 3. Connected SSH target after removing that forward. +- Feasibility: one-time manager creation is feasible only with callback refresh + for existing `SshConnection` and `SshRelaySession` instances. If that refresh + proves larger than expected, prefer a stable callback proxy over recreating + managers; do not dispose live sessions just to make callback ownership easier. +- Residual risks: Electron validation may be constrained by availability of an + existing SSH target and by avoiding live-user port collisions. If the golden + path cannot be exercised safely, stop before opening a PR and report the + missing manual evidence. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8aca3e538f4..5bd76d65e03 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -31,6 +31,7 @@ import { FileText, GitBranch, Globe, + Keyboard as KeyboardIcon, Mic, Monitor, Plus, @@ -59,6 +60,10 @@ import { type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from '../../../../src/terminal/terminal-live-input' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url' @@ -335,7 +340,8 @@ function TerminalPaneView({ onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }: { handle: string active: boolean @@ -350,6 +356,7 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalTap: (handle: string) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -379,6 +386,7 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalTap={() => onTerminalTap(handle)} /> ) @@ -679,6 +687,10 @@ export default function SessionScreen() { const sessionTabsRef = useRef([]) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') + const [liveInputCapture, setLiveInputCapture] = useState('') + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -745,6 +757,7 @@ export default function SessionScreen() { const viewportRef = useRef<{ cols: number; rows: number } | null>(null) const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) + const liveInputRef = useRef(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -781,6 +794,7 @@ export default function SessionScreen() { activeSessionTab?.type !== 'markdown' && activeSessionTab?.type !== 'file' && activeSessionTab?.type !== 'browser' + const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) useEffect(() => { @@ -1818,6 +1832,8 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) + setLiveInputCapture('') + setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) }, [clearTerminalCache, worktreeId]) @@ -2146,6 +2162,117 @@ export default function SessionScreen() { } } + const sendLiveTerminalInput = useCallback( + (handle: string, bytes: string) => { + if (bytes.length === 0) return + if (!isTerminalLiveInputWithinByteLimit(bytes)) { + triggerError() + showToast('Input too large (max 256 KiB)', 1500) + return + } + const rpc = clientRef.current + if ( + !rpc || + connStateRef.current !== 'connected' || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + void rpc + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Transient failure + }) + }, + [showToast] + ) + + const focusLiveInput = useCallback(() => { + if (!canSend || !liveInputEnabled) return + liveInputRef.current?.focus() + }, [canSend, liveInputEnabled]) + + const handleTerminalTap = useCallback( + (handle: string) => { + if (handle !== activeHandleRef.current) return + focusLiveInput() + }, + [focusLiveInput] + ) + + const toggleLiveInput = useCallback(() => { + if (!activeHandle) return + const nextEnabled = !liveInputTerminalHandles.has(activeHandle) + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(activeHandle) + } else { + next.delete(activeHandle) + } + return next + }) + setLiveInputCapture('') + if (nextEnabled) { + setTimeout(() => liveInputRef.current?.focus(), 50) + } else { + liveInputRef.current?.blur() + } + }, [activeHandle, liveInputTerminalHandles]) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (!liveInputTerminalHandles.has(activeHandle)) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (text.length > 0) { + sendLiveTerminalInput(activeHandle, text) + } + setLiveInputCapture('') + // Why: the field is only a keyboard capture surface. Clearing the + // native value prevents subsequent phone-keyboard events from replaying + // already-sent characters when React state remains the empty string. + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputKeyPress = useCallback( + (event: { nativeEvent: { key: string } }) => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) + if (!bytes) return + sendLiveTerminalInput(activeHandle, bytes) + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + sendLiveTerminalInput(activeHandle, '\r') + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { const now = Date.now() @@ -3085,6 +3212,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalTap={handleTerminalTap} /> ))} {toastMessage && ( @@ -3136,6 +3264,31 @@ export default function SessionScreen() { /> )} + [ + styles.accessoryKey, + liveInputEnabled && styles.accessoryKeyActive, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={toggleLiveInput} + accessibilityLabel={ + liveInputEnabled + ? 'Switch to buffered command input' + : 'Switch to live terminal input' + } + > + + Live + + {canPaste && ( [ @@ -3223,72 +3376,107 @@ export default function SessionScreen() { {/* Input bar */} - - void handleSend()} - /> + {liveInputEnabled ? ( { - if (dictation.isProcessing) { - void dictation.cancel() - } else if (dictation.isStarting) { - return - } else if (dictation.isRecording) { - void dictation.stop() - } else { - void dictation.start().catch((err) => { - triggerError() - showToast(err instanceof Error ? err.message : String(err)) - }) - } - }} - onLongPress={() => { - if (dictation.isRecording || dictation.isProcessing) { - void dictation.cancel() - } - }} - accessibilityLabel={ - dictation.isRecording - ? 'Stop voice dictation' - : dictation.isProcessing - ? 'Cancel voice dictation' - : dictation.isStarting - ? 'Starting voice dictation' - : 'Start voice dictation' - } + onPress={focusLiveInput} + accessibilityLabel="Focus live terminal input" > - {dictation.isProcessing ? ( - - ) : dictation.isStarting || dictation.isRecording ? ( - - ) : ( - - )} + + + Live + + + Keyboard input goes to terminal + + - void handleSend()} - accessibilityLabel="Send command" - > - - - + ) : ( + + void handleSend()} + /> + { + if (dictation.isProcessing) { + void dictation.cancel() + } else if (dictation.isStarting) { + return + } else if (dictation.isRecording) { + void dictation.stop() + } else { + void dictation.start().catch((err) => { + triggerError() + showToast(err instanceof Error ? err.message : String(err)) + }) + } + }} + onLongPress={() => { + if (dictation.isRecording || dictation.isProcessing) { + void dictation.cancel() + } + }} + accessibilityLabel={ + dictation.isRecording + ? 'Stop voice dictation' + : dictation.isProcessing + ? 'Cancel voice dictation' + : dictation.isStarting + ? 'Starting voice dictation' + : 'Start voice dictation' + } + > + {dictation.isProcessing ? ( + + ) : dictation.isStarting || dictation.isRecording ? ( + + ) : ( + + )} + + void handleSend()} + accessibilityLabel="Send command" + > + + + + )} )} @@ -4005,6 +4193,9 @@ const styles = StyleSheet.create({ accessoryKeyPressed: { backgroundColor: colors.borderSubtle }, + accessoryKeyActive: { + backgroundColor: colors.accentBlue + }, customAccessoryKey: { borderWidth: 1, borderColor: colors.borderSubtle @@ -4017,6 +4208,10 @@ const styles = StyleSheet.create({ fontSize: 12, fontFamily: typography.monoFamily }, + accessoryKeyTextActive: { + color: colors.textPrimary, + fontWeight: '700' + }, accessoryKeyTextDisabled: { color: colors.textMuted }, @@ -4040,6 +4235,37 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, marginRight: spacing.sm }, + liveInputBar: { + gap: spacing.sm + }, + liveInputBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.accentBlue, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radii.button + }, + liveInputBadgeText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '700', + fontFamily: typography.monoFamily + }, + liveInputHint: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + liveInputCapture: { + position: 'absolute', + opacity: 0, + width: 1, + height: 1, + color: colors.textPrimary + }, sendButton: { backgroundColor: colors.bgRaised, width: 34, diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 164692fa2c0..97de19e656a 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -31,6 +31,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void } export type TerminalWebViewHandle = { @@ -1538,6 +1539,9 @@ const XTERM_HTML = ` return; } if (dispatch.mode === 'surface') { + if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') { + notify({ type: 'terminal-tap' }); + } clearLongPress(); if (e.touches.length === 0) { dispatch.mode = 'idle'; @@ -1797,7 +1801,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }, ref ) { @@ -1900,6 +1905,8 @@ export const TerminalWebView = forwardRef(function } else if (msg.type === 'terminal-input') { const bytes = typeof msg.bytes === 'string' ? msg.bytes : '' if (bytes.length > 0) onTerminalInput?.(bytes) + } else if (msg.type === 'terminal-tap') { + onTerminalTap?.() } else if (msg.type === 'keyboard-avoidance-metrics') { const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 const rows = typeof msg.rows === 'number' ? msg.rows : 0 @@ -1932,7 +1939,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap ] ) diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts new file mode 100644 index 00000000000..0bad02da6d0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_INPUT_MAX_BYTES, + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from './terminal-live-input' + +describe('terminal live input', () => { + it('maps phone keyboard special keys to PTY bytes', () => { + expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f') + expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull() + expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull() + }) + + it('enforces the paste-sized byte budget', () => { + expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES + 1))).toBe( + false + ) + expect( + isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1)) + ).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts new file mode 100644 index 00000000000..87b6544fc74 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.ts @@ -0,0 +1,19 @@ +const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024 + +const encoder = new TextEncoder() + +export function getTerminalLiveSpecialKeyBytes(key: string): string | null { + if (key === 'Backspace') { + return '\x7f' + } + return null +} + +export function isTerminalLiveInputWithinByteLimit( + text: string, + maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES +): boolean { + return encoder.encode(text).byteLength <= maxBytes +} + +export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/package.json b/package.json index 6945aeef592..bd626f360ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.31", + "version": "1.4.34", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8722cc51035..a38afa4facc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ patchedDependencies: hash: b7465b48cca8d596e3bf0e441f939100c95adaddef13e85df089ed0ad6f6b1ea path: config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch node-pty@1.1.0: - hash: 93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4 + hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 path: config/patches/node-pty@1.1.0.patch importers: @@ -162,7 +162,7 @@ importers: version: 0.55.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4) + version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -11367,7 +11367,7 @@ snapshots: undici: 6.25.0 which: 6.0.1 - node-pty@1.1.0(patch_hash=93c259888173175ad66ab303977deb17f2aee9e276cb35522dc61815c65358d4): + node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): dependencies: node-addon-api: 7.1.1 diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts new file mode 100644 index 00000000000..210e2d343a6 --- /dev/null +++ b/src/cli/handlers/orchestration.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +// Why: isolate the handler's flag-to-param mapping; printResult only writes output. +vi.mock('../format', () => ({ printResult: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration reset CLI handler', () => { + beforeEach(() => { + callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } }) + }) + + const invoke = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration reset']({ + flags, + client: { call: callMock }, + json: true + } as never) + + it('sends all: true for a bare `reset` (no scope flag)', async () => { + await invoke(new Map()) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) + + it('sends only the tasks scope for --tasks', async () => { + await invoke(new Map([['tasks', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: undefined, + tasks: true, + messages: undefined + }) + }) + + it('sends only the all scope for --all (no implicit extra scopes)', async () => { + await invoke(new Map([['all', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 5398c55aadf..baa72bba969 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -438,8 +438,9 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration reset': async ({ flags, client, json }) => { + const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages') const result = await client.call<{ reset: string }>('orchestration.reset', { - all: flags.has('all') ? true : undefined, + all: flags.has('all') || !hasScopeFlag ? true : undefined, tasks: flags.has('tasks') ? true : undefined, messages: flags.has('messages') ? true : undefined }) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6446680b142..b1efaa23cb8 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1311,6 +1311,54 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it('passes all reset scope explicitly for no-flag orchestration reset', async () => { + callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'reset'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) + + it.each([ + { + args: ['orchestration', 'reset', '--all'], + params: { all: true, tasks: undefined, messages: undefined }, + reset: 'all' + }, + { + args: ['orchestration', 'reset', '--tasks'], + params: { all: undefined, tasks: true, messages: undefined }, + reset: 'tasks' + }, + { + args: ['orchestration', 'reset', '--messages'], + params: { all: undefined, tasks: undefined, messages: true }, + reset: 'messages' + }, + { + args: ['orchestration', 'reset', '--tasks', '--messages'], + params: { all: undefined, tasks: true, messages: true }, + reset: 'tasks' + }, + { + args: ['orchestration', 'reset', '--all', '--tasks'], + params: { all: true, tasks: true, messages: undefined }, + reset: 'all' + } + ])('passes explicit reset flags through for $args', async ({ args, params, reset }) => { + callMock.mockResolvedValueOnce(okFixture('req_reset', { reset })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(args, '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.reset', params) + }) + it('rejects unknown task-update status with an enum-aware error', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_coord' const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index ac79fd00f92..4f23c76900c 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -136,8 +136,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'reset'], - summary: 'Reset orchestration state', - usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]', + summary: 'Reset orchestration state (one scope; bare command resets all)', + usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages'] } ] diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index d87b93b5884..86eb35a9f83 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -228,11 +228,12 @@ describe('remote hook service installers', () => { const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as { hooks: Record } - for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) { + for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']) { const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh') expect(command).toMatch(/^if \[ -x /) } + expect(geminiConfig.hooks.PreToolUse).toBeUndefined() const antigravityConfig = JSON.parse( antigravity.fs.files.get('/home/dev/.gemini/config/hooks.json')! @@ -361,6 +362,54 @@ describe('remote hook service installers', () => { expect(postToolCommands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true) }) + it('removes stale remote Gemini PreToolUse hooks while preserving user-authored hooks', async () => { + const { sftp, fs } = createFakeSftp() + fs.files.set( + '/home/dev/.gemini/settings.json', + `${JSON.stringify( + { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/tmp/old/agent-hooks/gemini-hook.sh' ]; then /bin/sh '/tmp/old/agent-hooks/gemini-hook.sh'; fi" + } + ] + }, + { + hooks: [ + { + type: 'command', + command: 'echo user-authored' + } + ] + } + ] + } + }, + null, + 2 + )}\n` + ) + + await new GeminiHookService().installRemote(sftp, '/home/dev') + + const config = JSON.parse(fs.files.get('/home/dev/.gemini/settings.json')!) as { + hooks: Record + } + const preToolCommands = config.hooks.PreToolUse.flatMap((definition) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + expect(preToolCommands).toEqual(['echo user-authored']) + const beforeToolCommands = config.hooks.BeforeTool.flatMap((definition) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + expect(beforeToolCommands.some((command) => command.includes('gemini-hook.sh'))).toBe(true) + }) + it('installs remote Copilot hooks under the user-level hooks directory', async () => { const { sftp, fs } = createFakeSftp() fs.dirs.add('/home/dev/.copilot') diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 5c265cdbf2e..3f4c2d6449f 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2655,11 +2655,11 @@ describe('Codex hook normalization', () => { }) describe('Gemini hook normalization', () => { - it('PreToolUse surfaces toolName + toolInput', () => { + it('BeforeTool surfaces toolName + toolInput', () => { const result = _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'read_file', tool_input: { path: '/src/index.ts' } }), @@ -2674,7 +2674,7 @@ describe('Gemini hook normalization', () => { const result = _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'run_shell_command', args: { command: 'git status' } }), @@ -2688,7 +2688,7 @@ describe('Gemini hook normalization', () => { _internals.normalizeHookPayload( 'gemini', buildBody({ - hook_event_name: 'PreToolUse', + hook_event_name: 'BeforeTool', tool_name: 'read_file', tool_input: { path: '/stale.ts' } }), diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index abe456b73a6..6e00232ce27 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -120,6 +120,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings mobileAutoRestoreFitMs: null, experimentalPet: false, experimentalActivity: true, + experimentalTerminalAttention: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 7f9ed79c3b7..e9424198127 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -107,6 +107,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings mobileAutoRestoreFitMs: null, experimentalPet: false, experimentalActivity: true, + experimentalTerminalAttention: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index f35eda22668..fb2374f688d 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -252,6 +252,58 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) }) + it('mirrors system user hook approvals when the system trust indices are stale', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'first-stop-hook' }] }, + { hooks: [{ type: 'command', command: 'second-stop-hook' }] } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'second-stop-hook' + }, + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: 'first-stop-hook' + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) + }) + it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => { const pluginCommands = [ 'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"', diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 33851cfd4ad..bda41ad1b22 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -312,6 +312,7 @@ function getTrustedSystemUserHookSignatures( console.warn('[codex-hook-service] failed to read system hook trust entries', error) return signatures } + const trustedHashesByEvent = getTrustedSystemHookHashesByEvent(systemConfigPath, trustEntries) for (const [eventName, definitions] of Object.entries(systemHooks)) { if (!Array.isArray(definitions)) { continue @@ -333,15 +334,20 @@ function getTrustedSystemUserHookSignatures( if (!entry) { return } + const expectedHash = computeTrustedHash(entry) const state = trustEntries.get(computeTrustKey(entry)) - if (state?.trustedHash === computeTrustedHash(entry)) { - const signature = getTrustSignature(entry) - const enabled = state.enabled !== false - // Why: runtime deduping collapses identical system hook definitions; - // if any duplicate remains enabled, keep the mirrored hook enabled. - if (enabled || !signatures.has(signature)) { - signatures.set(signature, enabled) - } + const enabled = + state?.trustedHash === expectedHash + ? state.enabled !== false + : trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash) + if (enabled === undefined) { + return + } + const signature = getTrustSignature(entry) + // Why: runtime deduping collapses identical system hook definitions; + // if any duplicate remains enabled, keep the mirrored hook enabled. + if (enabled || !signatures.has(signature)) { + signatures.set(signature, enabled) } }) }) @@ -349,6 +355,35 @@ function getTrustedSystemUserHookSignatures( return signatures } +function getTrustedSystemHookHashesByEvent( + systemConfigPath: string, + trustEntries: ReadonlyMap +): Map> { + const trustedHashesByEvent = new Map>() + const canonicalSystemConfigPath = getCodexCanonicalTrustPath(systemConfigPath) + for (const [key, state] of trustEntries) { + const parsed = parseTrustKey(key) + if (!parsed || !state.trustedHash) { + continue + } + if (getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalSystemConfigPath) { + continue + } + let hashes = trustedHashesByEvent.get(parsed.eventLabel) + if (!hashes) { + hashes = new Map() + trustedHashesByEvent.set(parsed.eventLabel, hashes) + } + const enabled = state.enabled !== false + // Why: Codex trust keys include hook indices. If a user reorders hooks, + // the hash still proves the same event+command identity was approved. + if (enabled || !hashes.has(state.trustedHash)) { + hashes.set(state.trustedHash, enabled) + } + } + return trustedHashesByEvent +} + function collectMirroredRuntimeUserHookTrustEntries( runtimeConfigPath: string, runtimeHooks: Record, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 5145152b043..ec91c7592e3 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -64,11 +64,13 @@ const { const killStaleDaemonMock = vi.fn(async () => true) const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) - const daemonClientMock = vi.fn().mockImplementation(() => ({ - ensureConnected: vi.fn(async () => {}), - request: vi.fn(async () => ({ sessions: [] })), - disconnect: vi.fn() - })) + const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + }) // Why: every DaemonSpawner constructed under test pushes into this array so // assertions can check "was the *same* spawner reused across restart?". @@ -435,6 +437,30 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(legacyAdapter.listProcesses).toHaveBeenCalled() }) + it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + destroy() {} + } + }) + + await mod.initDaemonPtyProvider() + + const { DaemonPtyRouter } = await import('./daemon-pty-router') + expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter) + expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) + }) + it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -734,6 +760,79 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('preserves a resolver-unhealthy daemon when it owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { + sessions: [ + { sessionId: 'wt-1@@live', isAlive: true }, + { sessionId: 'wt-1@@dead', isAlive: false } + ] + } + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + + await launcher('/fake/socket', '/fake/token') + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('preserves a resolver-unhealthy daemon when live session state cannot be verified', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + throw new Error('listSessions failed') + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + + await launcher('/fake/socket', '/fake/token') + + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + it('uses the direct daemon entry when Electron app path is already out/main', async () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 9fd10a7666f..fe741fe57e9 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -99,6 +99,34 @@ function probeSocket(socketPath: string): Promise { }) } +async function getAliveDaemonSessionCount( + socketPath: string, + tokenPath: string, + protocolVersion = PROTOCOL_VERSION +): Promise { + const client = new DaemonClient({ socketPath, tokenPath, protocolVersion }) + try { + await client.ensureConnected() + const result = await client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } finally { + client.disconnect() + } +} + +function createPreservedDaemonHandle( + runtimeDir: string, + protocolVersion = PROTOCOL_VERSION +): { shutdown(): Promise } { + return { + shutdown: async () => { + await cleanupDaemonForProtocol(runtimeDir, protocolVersion) + } + } +} + function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { const entryPath = getDaemonEntryPath() @@ -106,6 +134,15 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { if (healthy) { const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return createPreservedDaemonHandle(runtimeDir) + } console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { @@ -122,11 +159,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } else { // Why: daemon is already running from a previous app session and // responded to a protocol-level ping. Safe to reuse. - return { - shutdown: async () => { - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - } - } + return createPreservedDaemonHandle(runtimeDir) } } } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 57f8feb93f8..8f721c44553 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -852,7 +852,52 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { await respawnServer?.shutdown() }) - it('replaces an unhealthy macOS resolver daemon before creating a fresh session', async () => { + it('preserves an unhealthy macOS resolver daemon when it owns live sessions', async () => { + const respawnFn = vi.fn() + const exits: { id: string; code: number }[] = [] + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + respawnAdapter.onExit((payload) => exits.push(payload)) + const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 }) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith( + socketPath, + tokenPath, + respawnAdapter.protocolVersion + ) + expect(respawnFn).not.toHaveBeenCalled() + expect(exits).toEqual([]) + expect(next.id).toBeDefined() + expect(next.id).not.toBe(existing.id) + + respawnAdapter.dispose() + }) + + it('preserves an unhealthy macOS resolver daemon when live sessions have not been reconciled locally', async () => { + const respawnFn = vi.fn() + const background = await adapter.spawn({ cols: 80, rows: 24 }) + const backgroundSubprocess = lastSubprocess + const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) + getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') + + const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) + + expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith( + socketPath, + tokenPath, + respawnAdapter.protocolVersion + ) + expect(respawnFn).not.toHaveBeenCalled() + expect(next.id).toBeDefined() + expect(next.id).not.toBe(background.id) + expect(backgroundSubprocess.forceKill).not.toHaveBeenCalled() + + respawnAdapter.dispose() + }) + + it('replaces an unhealthy macOS resolver daemon before creating a fresh session when no sessions are active', async () => { let respawnServer: DaemonServer | undefined const respawnFn = vi.fn(async () => { await server.shutdown() @@ -867,7 +912,6 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const exits: { id: string; code: number }[] = [] const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn }) respawnAdapter.onExit((payload) => exits.push(payload)) - const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 }) getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy') const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true }) @@ -878,7 +922,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { respawnAdapter.protocolVersion ) expect(respawnFn).toHaveBeenCalledOnce() - expect(exits).toContainEqual({ id: existing.id, code: -1 }) + expect(exits).toEqual([]) expect(replacement.id).toBeDefined() respawnAdapter.dispose() diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ce7778d3a84..c956c3a63b2 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -670,6 +670,17 @@ export class DaemonPtyAdapter implements IPtyProvider { return } + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' + : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + // Why: replacing the daemon kills its sessions without daemon-side exit // fanout. Emit exits first so renderer panes do not write to dead PTYs. this.fanoutSyntheticExits(-1) @@ -683,6 +694,16 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.respawnPromise } + private async getDaemonLiveSessionCount(): Promise { + try { + await this.client.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } + } + private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise { console.warn(message) this.removeEventListener?.() diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 34a3153c403..a1b5501db18 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,10 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bumped from 8 -> 9 so new app launches do not reuse daemon processes -// that cannot report their own macOS system resolver health. -export const PROTOCOL_VERSION = 9 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8] as const +// Why: bumped from 9 -> 10 so affected v9 daemons are preserved as legacy +// instead of being resolver-health replaced while they own live PTY sessions. +export const PROTOCOL_VERSION = 10 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts new file mode 100644 index 00000000000..7490a1b8434 --- /dev/null +++ b/src/main/gemini/hook-service.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import type * as osModule from 'os' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { GeminiHookService } from './hook-service' + +describe('GeminiHookService', () => { + let homeDir: string + let userDataDir: string + + beforeAll(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-gemini-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-gemini-userdata-')) + homedirMock.mockReturnValue(homeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath(${name})`) + }) + }) + + afterAll(() => { + rmSync(homeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('removes stale PreToolUse hooks when reinstalling managed Gemini hooks', () => { + const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' + const staleManagedHookPath = + process.platform === 'win32' + ? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}` + : `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}` + const staleManagedCommand = + process.platform === 'win32' + ? staleManagedHookPath + : `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi` + const managedHookPath = join(homeDir, '.orca', 'agent-hooks', managedHookFileName) + const configDir = join(homeDir, '.gemini') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify( + { + hooks: { + BeforeAgent: [ + { + hooks: [{ type: 'command', command: 'echo user-before-agent' }] + } + ], + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: staleManagedCommand + } + ] + } + ] + } + }, + null, + 2 + ) + ) + + const service = new GeminiHookService() + const status = service.install() + const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) + + expect(status.state).toBe('installed') + expect(Object.keys(config.hooks).sort()).toEqual([ + 'AfterAgent', + 'AfterTool', + 'BeforeAgent', + 'BeforeTool' + ]) + expect(config.hooks.PreToolUse).toBeUndefined() + expect(config.hooks.BeforeAgent).toHaveLength(2) + expect(config.hooks.BeforeAgent[0].hooks[0].command).toBe('echo user-before-agent') + expect(config.hooks.BeforeAgent[1].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.AfterAgent[0].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.AfterTool[0].hooks[0].command).toContain(managedHookPath) + expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookPath) + }) + + it('preserves user-authored PreToolUse hooks while sweeping stale managed Gemini hooks', () => { + const managedHookFileName = process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh' + const staleManagedHookPath = + process.platform === 'win32' + ? `C:\\Users\\ramzi\\.orca\\agent-hooks\\${managedHookFileName}` + : `/Users/ramzi/.orca/agent-hooks/${managedHookFileName}` + const staleManagedCommand = + process.platform === 'win32' + ? staleManagedHookPath + : `if [ -x '${staleManagedHookPath}' ]; then /bin/sh '${staleManagedHookPath}'; fi` + const configDir = join(homeDir, '.gemini') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + hooks: [{ type: 'command', command: staleManagedCommand }] + }, + { + hooks: [{ type: 'command', command: 'echo user-authored' }] + } + ] + } + }, + null, + 2 + ) + ) + + const status = new GeminiHookService().install() + const config = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) + const preToolCommands = config.hooks.PreToolUse.flatMap( + (definition: { hooks?: { command: string }[] }) => + (definition.hooks ?? []).map((hook) => hook.command) + ) + + expect(status.state).toBe('installed') + expect(preToolCommands).toEqual(['echo user-authored']) + expect(config.hooks.BeforeTool[0].hooks[0].command).toContain(managedHookFileName) + }) +}) diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 8247ca43365..4ff71a10eec 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -25,12 +25,10 @@ import { // (approvals flow through inline UI), so Orca cannot surface a waiting state // for Gemini — that is an upstream limitation, not an Orca bug. // -// PreToolUse surfaces the current tool name + input preview (e.g. -// `read_file: src/foo.ts`) so long-running tool calls aren't a silent gap -// between BeforeAgent and AfterAgent. PostToolUse is intentionally omitted — -// AfterTool already signals "back to working" and the tool name from -// PreToolUse is what we show; PostToolUse would be a redundant fire. -const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse'] as const +// Gemini's native pre-tool event is BeforeTool, not Claude/Codex's PreToolUse. +// Keep installing the pre-tool status hook, but sweep stale PreToolUse entries +// below so current Gemini CLI no longer warns about an invalid event bucket. +const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool'] as const function getConfigPath(): string { return join(homedir(), '.gemini', 'settings.json') @@ -175,6 +173,27 @@ export class GeminiHookService { // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const managedEvents = new Set(GEMINI_EVENTS) + + // Why: when Orca stops subscribing to an event, install() must sweep the + // old managed entry out of any leftover event bucket. Otherwise a stale + // hook such as PreToolUse survives forever in ~/.gemini/settings.json and + // continues firing even though the current build no longer wants it. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName)) { + continue + } + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + for (const eventName of GEMINI_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -213,6 +232,24 @@ export class GeminiHookService { const command = wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh') + const managedEvents = new Set(GEMINI_EVENTS) + + // Why: remote installs must sweep legacy managed event buckets too. + // Otherwise stale PreToolUse entries keep warning in SSH Gemini sessions. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName)) { + continue + } + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } for (const eventName of GEMINI_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] diff --git a/src/main/git/repo-clone-path.test.ts b/src/main/git/repo-clone-path.test.ts new file mode 100644 index 00000000000..e651e1d965f --- /dev/null +++ b/src/main/git/repo-clone-path.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { deriveValidatedClonePath, getClonePathComparisonKey } from './repo-clone-path' + +describe('repo clone path helpers', () => { + it('allows safe repository names that start with two dots', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-clone-path-')) + try { + expect( + deriveValidatedClonePath({ + url: 'https://example.com/..repo.git', + destination + }) + ).toBe(join(destination, '..repo')) + } finally { + await rm(destination, { recursive: true, force: true }) + } + }) + + it('rejects Windows-looking destinations on non-Windows hosts', async () => { + if (process.platform === 'win32') { + return + } + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: 'C:\\Users\\me\\src' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '\\\\server\\share' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '//server/share' + }) + ).toThrow('Clone destination must be an absolute path') + expect(() => + deriveValidatedClonePath({ + url: 'https://example.com/orca.git', + destination: '//wsl.localhost/Ubuntu/home/me' + }) + ).toThrow('Clone destination must be an absolute path') + }) + + it('canonicalizes WSL UNC server aliases without folding Linux path casing', () => { + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo') + ) + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo\\')).toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\User\\repo') + ) + expect(getClonePathComparisonKey('\\\\wsl.localhost\\Ubuntu\\home\\User\\repo')).not.toBe( + getClonePathComparisonKey('\\\\wsl$\\ubuntu\\home\\user\\repo') + ) + }) +}) diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts new file mode 100644 index 00000000000..8c0c7df7576 --- /dev/null +++ b/src/main/git/repo-clone-path.ts @@ -0,0 +1,127 @@ +import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'path' +import type { Stats } from 'fs' +import { lstat, mkdir, rm } from 'fs/promises' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from '../../shared/cross-platform-path' + +export type ClaimedCloneTarget = { + canCleanup: boolean + ownedDirectoryIdentity: CloneDirectoryIdentity | null +} + +type CloneDirectoryIdentity = Pick + +export function deriveValidatedClonePath(args: { url: string; destination: string }): string { + if ( + !args.destination || + !isAbsolute(args.destination) || + (process.platform !== 'win32' && isWindowsAbsolutePathLike(args.destination)) + ) { + throw new Error('Clone destination must be an absolute path') + } + + // Why: direct callers can supply URLs whose default git clone folder would + // be "." or ".."; rejecting them prevents parent/destination deletion. + const source = args.url.replace(/\.git\/?$/, '') + const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + if (!repoName || repoName === '.' || repoName === '..') { + throw new Error('Invalid repository name derived from URL') + } + if (repoName.includes('/') || repoName.includes('\\')) { + throw new Error('Invalid repository name derived from URL') + } + + const clonePath = join(args.destination, repoName) + const resolvedDestination = resolve(args.destination) + const resolvedClonePath = resolve(clonePath) + const pathFromDestination = relative(resolvedDestination, resolvedClonePath) + if ( + pathFromDestination === '' || + pathFromDestination === '..' || + pathFromDestination.startsWith(`..${sep}`) || + isAbsolute(pathFromDestination) + ) { + throw new Error('Clone path must be inside the destination directory') + } + + return clonePath +} + +export function getClonePathComparisonKey(clonePath: string): string { + const resolvedClonePath = isWindowsAbsolutePathLike(clonePath) ? clonePath : resolve(clonePath) + const normalized = normalizeRuntimePathSeparators(resolvedClonePath) + const wslUncMatch = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i) + if (wslUncMatch) { + // Why: WSL UNC paths cross into a case-sensitive Linux filesystem, so only + // the Windows UNC server alias and distro segment should be case-folded. + const linuxPath = (wslUncMatch[2] ?? '').replace(/\/+$/, '') + return `//wsl/${wslUncMatch[1].toLowerCase()}${linuxPath}` + } + return normalizeRuntimePathForComparison(resolvedClonePath) +} + +export async function claimCloneTarget(clonePath: string): Promise { + try { + await mkdir(clonePath, { recursive: false }) + return { + canCleanup: true, + ownedDirectoryIdentity: cloneDirectoryIdentity(await lstat(clonePath)) + } + } catch (error) { + if (isErrnoCode(error, 'EEXIST')) { + return { canCleanup: false, ownedDirectoryIdentity: null } + } + throw error + } +} + +export async function cleanupClaimedCloneTarget( + clonePath: string, + claimedTarget: ClaimedCloneTarget +): Promise { + if (!claimedTarget.canCleanup || !claimedTarget.ownedDirectoryIdentity) { + return + } + + try { + const currentStats = await lstat(clonePath) + if (!currentStats.isDirectory()) { + return + } + if ( + !isSameCloneDirectoryIdentity( + claimedTarget.ownedDirectoryIdentity, + cloneDirectoryIdentity(currentStats) + ) + ) { + return + } + } catch { + return + } + + await rm(clonePath, { recursive: true, force: true }).catch(() => { + // Best-effort cleanup - do not mask the original clone failure. + }) +} + +function cloneDirectoryIdentity(stats: Stats): CloneDirectoryIdentity { + // Why: fast remove/recreate cycles can reuse an inode; birthtime keeps us + // from treating a replacement directory as the clone target we created. + return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs } +} + +function isSameCloneDirectoryIdentity( + a: CloneDirectoryIdentity, + b: CloneDirectoryIdentity +): boolean { + return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs +} + +function isErrnoCode(error: unknown, code: string): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === code +} diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts new file mode 100644 index 00000000000..0f0bfaad801 --- /dev/null +++ b/src/main/git/status-discard-symlink.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { execFileSync } from 'child_process' +import { mkdtemp, mkdir, rm, symlink, writeFile, access, readFile } from 'fs/promises' +import * as path from 'path' +import { tmpdir } from 'os' +import { bulkDiscardChanges, discardChanges } from './status' + +const tempRoots: string[] = [] + +async function createRepoWithOutsideDirectory(): Promise<{ + repo: string + outsideDir: string + outsideFile: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-discard-symlink-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + const outsideDir = path.join(root, 'outside') + const outsideFile = path.join(outsideDir, 'keep.txt') + + await mkdir(repo) + await mkdir(outsideDir) + execFileSync('git', ['init', '-q'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) + await writeFile(path.join(repo, '.gitkeep'), '') + execFileSync('git', ['add', '.gitkeep'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo }) + await writeFile(outsideFile, 'outside') + + return { repo, outsideDir, outsideFile } +} + +async function createDirectoryLink(target: string, linkPath: string): Promise { + await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir') +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('discardChanges symlink safety', () => { + it('rejects an untracked child path through a symlinked parent', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + await createDirectoryLink(outsideDir, path.join(repo, 'link')) + + await expect(discardChanges(repo, 'link/keep.txt')).rejects.toThrow( + 'resolves outside the worktree' + ) + await expect(access(outsideFile)).resolves.toBeUndefined() + }) + + it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + const untrackedFile = path.join(repo, 'new.txt') + const trackedFile = path.join(repo, '.gitkeep') + await writeFile(trackedFile, 'modified') + await writeFile(untrackedFile, 'untracked') + await createDirectoryLink(outsideDir, path.join(repo, 'link')) + + await expect( + bulkDiscardChanges(repo, ['.gitkeep', 'new.txt', 'link/keep.txt']) + ).rejects.toThrow('resolves outside the worktree') + await expect(access(outsideFile)).resolves.toBeUndefined() + await expect(access(untrackedFile)).resolves.toBeUndefined() + await expect(readFile(trackedFile, 'utf8')).resolves.toBe('modified') + }) + + it('removes an untracked symlink leaf without deleting its target', async () => { + const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() + const linkPath = path.join(repo, 'outside-link') + await createDirectoryLink(outsideDir, linkPath) + + await discardChanges(repo, 'outside-link') + + await expect(access(linkPath)).rejects.toThrow() + await expect(access(outsideFile)).resolves.toBeUndefined() + }) + + it('removes ignored paths selected for discard', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored/\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore ignored dir'], { cwd: repo }) + const ignoredFile = path.join(repo, 'ignored', 'file.txt') + await mkdir(path.dirname(ignoredFile)) + await writeFile(ignoredFile, 'ignored') + + await discardChanges(repo, 'ignored') + + await expect(access(path.join(repo, 'ignored'))).rejects.toThrow() + }) + + it('removes untracked nested git repos selected for discard', async () => { + const { repo } = await createRepoWithOutsideDirectory() + const nestedRepo = path.join(repo, 'nested') + await mkdir(nestedRepo) + execFileSync('git', ['init', '-q'], { cwd: nestedRepo }) + await writeFile(path.join(nestedRepo, 'file.txt'), 'nested') + + await discardChanges(repo, 'nested') + + await expect(access(nestedRepo)).rejects.toThrow() + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 17200d09783..079f1d7e625 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -6,6 +6,7 @@ const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, lstatMock, + realpathMock, readFileMock, rmMock, existsSyncMock @@ -13,6 +14,7 @@ const { gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), lstatMock: vi.fn(), + realpathMock: vi.fn(), readFileMock: vi.fn(), rmMock: vi.fn(), existsSyncMock: vi.fn() @@ -29,6 +31,7 @@ vi.mock('./runner', () => ({ vi.mock('fs/promises', () => ({ lstat: lstatMock, + realpath: realpathMock, readFile: readFileMock, rm: rmMock })) @@ -55,8 +58,12 @@ describe('discardChanges', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() readFileMock.mockReset() rmMock.mockReset() + lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('restores tracked files from HEAD', async () => { @@ -87,13 +94,15 @@ describe('discardChanges', () => { await discardChanges('/repo', 'src/new-file.ts') - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - // Why: discardChanges uses path.resolve(worktreePath, filePath) to build - // the absolute rm target, which on Windows prepends a drive letter. - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), { - force: true, - recursive: true - }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['clean', '-ffdx', '--', 'src/new-file.ts'], + { + cwd: '/repo' + } + ) + expect(rmMock).not.toHaveBeenCalled() }) it('rejects paths that traverse outside the worktree', async () => { @@ -113,7 +122,11 @@ describe('discardChanges', () => { describe('bulk git helpers', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() rmMock.mockReset() + lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) it('chunks bulk stage requests to avoid oversized argv payloads', async () => { @@ -178,14 +191,14 @@ describe('bulk git helpers', () => { cwd: '/repo' } ) - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), { - force: true, - recursive: true - }) - expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'scratch'), { - force: true, - recursive: true - }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['clean', '-ffdx', '--', 'src/new-file.ts', 'scratch'], + { + cwd: '/repo' + } + ) + expect(rmMock).not.toHaveBeenCalled() }) it('rejects bulk discard paths that traverse outside the worktree', async () => { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 5f6bbd0f4f9..02c2ea08063 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { existsSync } from 'fs' -import { readFile, rm } from 'fs/promises' +import { readFile } from 'fs/promises' import * as path from 'path' import type { GitBranchChangeEntry, @@ -29,6 +29,10 @@ import { type GitLineStats } from '../../shared/git-uncommitted-line-stats' import { gitExecFileAsync, gitExecFileAsyncBuffer, gitOptionalLocksDisabledEnv } from './runner' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../../shared/git-discard-path-safety' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -1105,11 +1109,16 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr // File is not tracked by git } - await (tracked - ? gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { - cwd: worktreePath - }) - : rm(resolvedTarget, { force: true, recursive: true })) + if (tracked) { + await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { + cwd: worktreePath + }) + return + } + + await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => + cleanUntrackedPaths(worktreePath, [targetPath]) + ) } function normalizeGitPathForCompare(filePath: string): string { @@ -1139,6 +1148,19 @@ async function listTrackedPathSpecs( return trackedPaths } +async function cleanUntrackedPaths( + worktreePath: string, + filePaths: readonly string[] +): Promise { + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + if (chunk.length > 0) { + // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. + await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk], { cwd: worktreePath }) + } + } +} + /** * Discard working tree changes for many paths in a small number of subprocesses. */ @@ -1160,18 +1182,18 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string const untrackedPaths = filePaths.filter( (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) ) - - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { - cwd: worktreePath - }) - } - - await Promise.all( - untrackedPaths.map((filePath) => - rm(path.resolve(worktreePath, filePath), { force: true, recursive: true }) - ) + await removeSafeUntrackedDiscardTargets( + worktreePath, + untrackedPaths, + (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths), + async () => { + for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { + const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) + await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { + cwd: worktreePath + }) + } + } ) } diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index f8304642e48..1abd90b560e 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appRelaunchMock } = vi.hoisted(() => ({ +const { handlers, appExitMock, appQuitMock, appRelaunchMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), + appQuitMock: vi.fn(), appRelaunchMock: vi.fn() })) @@ -11,6 +12,7 @@ vi.mock('electron', () => ({ exit: appExitMock, getAppPath: vi.fn(() => '/test/app'), isPackaged: false, + quit: appQuitMock, relaunch: appRelaunchMock }, BrowserWindow: { @@ -37,6 +39,7 @@ describe('registerAppHandlers', () => { vi.useFakeTimers() handlers.clear() appExitMock.mockReset() + appQuitMock.mockReset() appRelaunchMock.mockReset() }) @@ -59,4 +62,22 @@ describe('registerAppHandlers', () => { expect(appRelaunchMock).toHaveBeenCalledTimes(1) expect(appExitMock).toHaveBeenCalledWith(0) }) + + it('marks restart as expected shutdown before quitting through the normal pipeline', () => { + const onBeforeRelaunch = vi.fn() + registerAppHandlers({} as never, { onBeforeRelaunch }) + + handlers.get('app:restart')?.(null) + + expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) + expect(appRelaunchMock).not.toHaveBeenCalled() + expect(appQuitMock).not.toHaveBeenCalled() + expect(appExitMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + + expect(appRelaunchMock).toHaveBeenCalledTimes(1) + expect(appQuitMock).toHaveBeenCalledTimes(1) + expect(appExitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1b3e96c9447..2fb289e3ece 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -178,6 +178,17 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }, 150) }) + ipcMain.handle('app:restart', () => { + // Why: the hidden admin restart should mirror the update relaunch path: + // schedule a new Orca process, then use the normal quit pipeline so daemon + // checkpoints, runtime metadata, and telemetry flush before exit. + options.onBeforeRelaunch?.() + setTimeout(() => { + app.relaunch() + app.quit() + }, 150) + }) + ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => { setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0) }) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 5ef07164e9b..df3fa559554 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -273,6 +273,43 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + + try { + await handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts') + expect(renameMock).not.toHaveBeenCalled() + }) + + it('propagates SSH no-clobber rename failures', async () => { + const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + + try { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('destination exists') + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameMock).not.toHaveBeenCalled() + }) + // ── fs:copy ──────────────────────────────────────────────────── it('copies a file without overwriting an existing destination', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index f1f7a30e196..38c9d798105 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -20,7 +20,7 @@ import type { Store } from '../persistence' import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { importExternalPathsSsh } from './filesystem-import-ssh' -import { assertFileExplorerRenameDestinationAvailable } from '../file-explorer-rename-collision' +import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' /** * Re-throw filesystem errors with user-friendly messages. @@ -109,7 +109,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return provider.rename(args.oldPath, args.newPath) + return provider.renameNoClobber(args.oldPath, args.newPath) } // Why: rename() operates on directory entries, not file contents. If // oldPath is a symlink, we must rename the link itself rather than @@ -119,7 +119,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // accidentally write into a symlinked destination name. const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await assertFileExplorerRenameDestinationAvailable(oldPath, newPath) + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) await rename(oldPath, newPath) } ) diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index a2272a2adfd..3c959c60155 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -652,7 +652,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('uses rich formatter output for mobile notifications before desktop guards', () => { + it('uses rich formatter output for mobile notifications before the native support guard', () => { notificationIsSupportedMock.mockReturnValue(false) const dispatchMobileNotification = vi.fn() registerNotificationHandlers( @@ -694,6 +694,145 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) + it('does not dispatch mobile notifications when notifications are disabled', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: false, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'disabled' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications when the source is disabled', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'source-disabled' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications for focused active-worktree notifications', () => { + getAllWindowsMock.mockReturnValue([ + { + isDestroyed: () => false, + isFocused: () => true + } as never + ]) + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect( + handler( + {}, + { source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true } + ) + ).toEqual({ + delivered: false, + reason: 'suppressed-focus' + }) + + expect(dispatchMobileNotification).not.toHaveBeenCalled() + }) + + it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + delivered: true + }) + expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + delivered: false, + reason: 'cooldown' + }) + + expect(dispatchMobileNotification).toHaveBeenCalledTimes(1) + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' }) + ) + }) + + it('does not forward explicit desktop test notifications to mobile clients', () => { + const dispatchMobileNotification = vi.fn() + registerNotificationHandlers( + { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never, + { dispatchMobileNotification } as never + ) + + const handler = getDispatchHandler() + expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + + expect(dispatchMobileNotification).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 0159d046759..2cbbe63504c 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -157,25 +157,6 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime _event, args: NotificationDispatchRequest ): NotificationDispatchResult | Promise => { - // Why: mobile push is independent of desktop notification guards. - // The user's phone should receive the notification even when the desktop - // window is focused (suppressWhenFocused), Electron notifications aren't - // supported, or the desktop is in cooldown. The mobile client decides - // independently whether to show based on its own app state. - if (runtime) { - const opts = buildNotificationOptions(args) - runtime.dispatchMobileNotification({ - source: args.source, - title: opts.title, - body: opts.body, - worktreeId: args.worktreeId - }) - } - - if (!Notification.isSupported()) { - return { delivered: false, reason: 'not-supported' } - } - const settings = store.getSettings().notifications if (!settings.enabled) { return { delivered: false, reason: 'disabled' } @@ -223,6 +204,23 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } const notificationOptions = buildNotificationOptions(args) + + // Why: paired mobile clients should follow the same user-facing + // notification gates as desktop delivery, while still working on hosts + // where Electron native notifications are unavailable. + if (runtime && args.source !== 'test') { + runtime.dispatchMobileNotification({ + source: args.source, + title: notificationOptions.title, + body: notificationOptions.body, + worktreeId: args.worktreeId + }) + } + + if (!Notification.isSupported()) { + return { delivered: false, reason: 'not-supported' } + } + if (getEffectiveNotificationSoundId(settings) !== 'system') { notificationOptions.silent = true } else if (process.platform === 'darwin') { diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 4a240f9afa4..db1ece856c4 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -3,8 +3,12 @@ tests (addRemote, getBaseRefDefault envelope, searchBaseRefs SSH relay) so 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 { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { EventEmitter } from 'events' +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' import type * as RepoModule from '../git/repo' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' @@ -564,12 +568,46 @@ describe('repos:addRemote', () => { }) }) +type MockCloneProcess = EventEmitter & { + stderr: EventEmitter + kill: ReturnType +} + +function createMockCloneProcess(): MockCloneProcess { + const proc = new EventEmitter() as MockCloneProcess + proc.stderr = new EventEmitter() + proc.kill = vi.fn().mockReturnValue(true) + return proc +} + +async function waitForAssertion(assertion: () => void): Promise { + const deadline = Date.now() + 2_000 + let lastError: unknown + while (Date.now() < deadline) { + try { + assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + throw lastError +} + describe('repos:add + repos:clone', () => { const handlers = new Map unknown>() const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + const tempRoots: string[] = [] + + const createTempRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'orca-repos-clone-')) + tempRoots.push(root) + return root + } beforeEach(() => { handlers.clear() @@ -583,8 +621,7 @@ describe('repos:add + repos:clone', () => { mockWindow.webContents.send.mockReset() gitSpawnMock.mockReset() gitSpawnMock.mockImplementation(() => { - const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } - proc.stderr = new EventEmitter() + const proc = createMockCloneProcess() queueMicrotask(() => proc.emit('close', 0, null)) return proc }) @@ -592,6 +629,10 @@ describe('repos:add + repos:clone', () => { registerRepoHandlers(mockWindow as never, mockStore as never) }) + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + 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' }) @@ -638,14 +679,16 @@ describe('repos:add + repos:clone', () => { }) it('defaults repos:clone badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const destination = await createTempRoot() + const result = await handlers.get('repos:clone')!(null, { url: 'https://example.com/orca.git', - destination: '/tmp' + destination }) expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ - path: '/tmp/orca', + path: join(destination, 'orca'), badgeColor: DEFAULT_REPO_BADGE_COLOR, kind: 'git', externalWorktreeVisibility: 'hide', @@ -657,9 +700,11 @@ describe('repos:add + repos:clone', () => { }) it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') const existing = { id: 'folder-repo', - path: '/tmp/orca', + path: clonePath, displayName: 'orca', badgeColor: '#8b5cf6', addedAt: 1, @@ -671,7 +716,7 @@ describe('repos:add + repos:clone', () => { const result = await handlers.get('repos:clone')!(null, { url: 'https://example.com/orca.git', - destination: '/tmp' + destination }) expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) @@ -679,6 +724,330 @@ describe('repos:add + repos:clone', () => { expect(result).toHaveProperty('badgeColor', '#8b5cf6') expect(mockStore.addRepo).not.toHaveBeenCalled() }) + + it('rejects a dot-segment URL before creating the destination or spawning git', async () => { + const tempRoot = await createTempRoot() + const destination = join(tempRoot, 'destination') + + await expect( + handlers.get('repos:clone')!(null, { + url: 'file:///tmp/source/.', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects a parent-segment URL before creating the destination or spawning git', async () => { + const tempRoot = await createTempRoot() + const destination = join(tempRoot, 'destination') + + await expect( + handlers.get('repos:clone')!(null, { + url: 'file:///tmp/source/..', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects a relative destination before creating directories or spawning git', async () => { + const destination = `relative-clone-destination-${Date.now()}` + + await expect( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + ).rejects.toThrow('Clone destination must be an absolute path') + + expect(existsSync(destination)).toBe(false) + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('rejects URL-derived names containing Windows separators before spawning git', async () => { + const destination = await createTempRoot() + + await expect( + handlers.get('repos:clone')!(null, { + url: 'https://example.com/team\\orca.git', + destination + }) + ).rejects.toThrow('Invalid repository name derived from URL') + + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it('accepts Windows local-path clone sources while validating the final segment', async () => { + const destination = await createTempRoot() + + const result = await handlers.get('repos:clone')!(null, { + url: 'C:\\src\\orca.git', + destination + }) + + expect(gitSpawnMock).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'C:\\src\\orca.git', join(destination, 'orca')], + expect.objectContaining({ cwd: destination }) + ) + expect(result).toHaveProperty('path', join(destination, 'orca')) + }) + + it('treats cloneAbort with no active clone as a no-op', async () => { + await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined() + }) + + it('does not remove an existing target directory when aborting a pending clone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + await mkdir(clonePath) + await writeFile(join(clonePath, 'user-file.txt'), 'keep me') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(clonePath)).toBe(true) + expect(existsSync(join(clonePath, 'user-file.txt'))).toBe(true) + }) + + it('does not remove an existing target file when aborting a pending clone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + await writeFile(clonePath, 'existing file') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(clonePath)).toBe(true) + }) + + it('removes a fresh clone target only after the aborted process closes unsuccessfully', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + expect(existsSync(clonePath)).toBe(true) + + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + expect(existsSync(clonePath)).toBe(false) + }) + + it('removes an owned fresh clone target when git exits unsuccessfully', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'git wrote this before failing') + + proc.stderr.emit('data', Buffer.from('fatal: repository not found\n')) + proc.emit('close', 128, null) + await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found') + + expect(existsSync(clonePath)).toBe(false) + }) + + it('removes an owned fresh clone target when git spawn emits an error', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'git wrote this before spawn failure') + + proc.emit('error', new Error('spawn failed')) + await expect(clonePromise).rejects.toThrow('Clone failed: spawn failed') + + expect(existsSync(clonePath)).toBe(false) + }) + + it('keeps a fresh clone target when abort races with a successful close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + proc.emit('close', 0, null) + await expect(clonePromise).resolves.toMatchObject({ + path: clonePath, + kind: 'git' + }) + + expect(existsSync(clonePath)).toBe(true) + }) + + it('dedupes retry when abort races with a successful clone close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const repos: unknown[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: unknown) => { + repos.push(repo) + }) + const firstProc = createMockCloneProcess() + const secondProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(secondProc.kill).not.toHaveBeenCalled() + }) + + it('serializes concurrent clones for the same target', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const repos: unknown[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: unknown) => { + repos.push(repo) + }) + const firstProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + await expect(secondClonePromise).resolves.toMatchObject({ path: clonePath, kind: 'git' }) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + }) + + it('waits for pending abort cleanup before retrying the same clone target', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const partialFile = join(clonePath, 'partial.txt') + const firstProc = createMockCloneProcess() + const secondProc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(firstProc).mockReturnValueOnce(secondProc) + + const firstClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + await writeFile(partialFile, 'first clone wrote this before abort') + await handlers.get('repos:cloneAbort')!(null, undefined) + + const secondClonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(existsSync(partialFile)).toBe(true) + + firstProc.emit('close', null, 'SIGTERM') + await expect(firstClonePromise).rejects.toThrow('Clone aborted') + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) + expect(existsSync(partialFile)).toBe(false) + + secondProc.emit('close', 0, null) + await expect(secondClonePromise).resolves.toMatchObject({ + path: clonePath, + kind: 'git' + }) + expect(existsSync(clonePath)).toBe(true) + }) + + it('skips abort cleanup when the claimed target is replaced before close', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const replacementFile = join(clonePath, 'replacement.txt') + const proc = createMockCloneProcess() + gitSpawnMock.mockReturnValueOnce(proc) + + const clonePromise = handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination + }) + await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) + + await handlers.get('repos:cloneAbort')!(null, undefined) + await rm(clonePath, { recursive: true, force: true }) + await mkdir(clonePath) + await writeFile(replacementFile, 'new owner') + + proc.emit('close', null, 'SIGTERM') + await expect(clonePromise).rejects.toThrow('Clone aborted') + + expect(existsSync(replacementFile)).toBe(true) + }) }) describe('repos:getBaseRefDefault envelope', () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 2ffb0fe2caf..f51de2d2535 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -23,8 +23,15 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' import { gitExecFileAsync, gitSpawn } from '../git/runner' -import { basename, isAbsolute, join, posix } from 'path' +import { isAbsolute, join, posix } from 'path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + cleanupClaimedCloneTarget, + claimCloneTarget, + deriveValidatedClonePath, + getClonePathComparisonKey +} from '../git/repo-clone-path' +import type { ClaimedCloneTarget } from '../git/repo-clone-path' import { getNextProjectGroupOrder } from '../../shared/project-groups' import { scanNestedRepos } from '../project-groups/nested-repo-discovery' import { @@ -75,11 +82,25 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { track('repo_added', { method, ...getCohortAtEmit() }) } +type ActiveCloneMetadata = { + path: string + pathKey: string + claimedTarget: ClaimedCloneTarget + process: ChildProcess + abortRequested: boolean + generation: number + pendingAbortCleanup: Promise | null + resolvePendingAbortCleanup: (() => void) | null +} + // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, // and a function-scoped variable would lose the reference to an in-flight clone. -let activeCloneProc: ChildProcess | null = null -let activeClonePath: string | null = null +let activeClone: ActiveCloneMetadata | null = null +let nextCloneGeneration = 1 +const latestCloneGenerationByPath = new Map() +const pendingAbortCleanupByPath = new Map>() +const cloneInFlightByPath = new Map>() const ProjectGroupCreateArgs = z.object({ name: z.string().min(1), @@ -148,6 +169,71 @@ function validateNestedRepoScanRoot(path: string, connectionId?: string): void { } } +async function cleanupOwnedCloneTarget(metadata: ActiveCloneMetadata): Promise { + if (!metadata.claimedTarget.canCleanup || !metadata.claimedTarget.ownedDirectoryIdentity) { + return + } + if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) { + return + } + // Why: an immediate retry can attach a newer process to the same target + // before the aborted process closes; the old close handler must not delete it. + if ( + activeClone && + activeClone.process !== metadata.process && + activeClone.pathKey === metadata.pathKey + ) { + return + } + + if (latestCloneGenerationByPath.get(metadata.pathKey) !== metadata.generation) { + return + } + await cleanupClaimedCloneTarget(metadata.path, metadata.claimedTarget) +} + +function markCloneAbortCleanupPending(metadata: ActiveCloneMetadata): void { + if (metadata.resolvePendingAbortCleanup) { + return + } + metadata.pendingAbortCleanup = new Promise((resolve) => { + metadata.resolvePendingAbortCleanup = resolve + }) + pendingAbortCleanupByPath.set(metadata.pathKey, metadata.pendingAbortCleanup) +} + +function settleCloneAbortCleanup(metadata: ActiveCloneMetadata): void { + if (pendingAbortCleanupByPath.get(metadata.pathKey) === metadata.pendingAbortCleanup) { + pendingAbortCleanupByPath.delete(metadata.pathKey) + } + metadata.resolvePendingAbortCleanup?.() + metadata.pendingAbortCleanup = null + metadata.resolvePendingAbortCleanup = null +} + +async function runWithClonePathLock(clonePathKey: string, task: () => Promise): Promise { + const previous = cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + cloneInFlightByPath.set(clonePathKey, tail) + + try { + await previous + return await task() + } finally { + release() + if (cloneInFlightByPath.get(clonePathKey) === tail) { + cloneInFlightByPath.delete(clonePathKey) + } + } +} + function sanitizeNestedRepoImportError(context: string, error: unknown): string { console.warn(`[project-groups] ${context}`, error) return 'Repository could not be imported' @@ -976,19 +1062,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) ipcMain.handle('repos:cloneAbort', async () => { - if (activeCloneProc) { - const pathToClean = activeClonePath - activeCloneProc.kill() - activeCloneProc = null - activeClonePath = null - // Why: git clone creates the target directory before it finishes. - // Without cleanup, retrying the same URL/destination fails with - // "destination path already exists and is not an empty directory". - if (pathToClean) { - await rm(pathToClean, { recursive: true, force: true }).catch(() => { - // Best-effort cleanup — don't fail the abort if removal fails - }) - } + if (activeClone) { + const clone = activeClone + clone.abortRequested = true + markCloneAbortCleanupPending(clone) + clone.process.kill() + activeClone = null } }) @@ -999,114 +1078,183 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // the repo folder name from the URL (e.g. "orca" from .../orca.git). // This matches the default git clone behavior where the last path segment // of the URL becomes the directory name. - const repoName = basename(args.url.replace(/\.git\/?$/, '')) - if (!repoName) { - throw new Error('Could not determine repository name from URL') - } - // Why: gitSpawn uses args.destination as cwd, so it must exist before - // spawn — fresh installs may have a defaulted parent dir that does not - // exist yet (e.g. ~/orca). recursive: true is a no-op when present. - await mkdir(args.destination, { recursive: true }) - const clonePath = join(args.destination, repoName) + const clonePath = deriveValidatedClonePath(args) + const clonePathKey = getClonePathComparisonKey(clonePath) + return runWithClonePathLock(clonePathKey, async () => { + await pendingAbortCleanupByPath.get(clonePathKey) + const existingAfterPendingClone = store + .getRepos() + .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) + if (existingAfterPendingClone && !isFolderRepo(existingAfterPendingClone)) { + emitRepoAdded('clone_url', true) + return existingAfterPendingClone + } + // Why: gitSpawn uses args.destination as cwd, so it must exist before + // spawn — fresh installs may have a defaulted parent dir that does not + // exist yet (e.g. ~/orca). recursive: true is a no-op when present. + await mkdir(args.destination, { recursive: true }) + const claimedTarget = await claimCloneTarget(clonePath) - // Why: use spawn instead of execFile so there is no maxBuffer limit. - // git clone writes progress to stderr which can exceed Node's default - // 1 MB buffer on large or submodule-heavy repos. We only keep the tail - // of stderr for error reporting and discard stdout entirely. - // Why: use --progress to force git to emit progress even when stderr - // is not a TTY. Without it, git suppresses progress output when piped. - await new Promise((resolve, reject) => { - // Why: clone destination may be a WSL path (e.g. user picks a WSL - // directory). Use the parent destination as the cwd so the runner - // detects WSL and routes through wsl.exe. - // Why: use the '--' separator to isolate the URL argument and prevent - // malicious URLs from being interpreted as git flags (command injection). - const proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { - cwd: args.destination, - stdio: ['ignore', 'ignore', 'pipe'] - }) - activeCloneProc = proc - activeClonePath = clonePath + // Why: use spawn instead of execFile so there is no maxBuffer limit. + // git clone writes progress to stderr which can exceed Node's default + // 1 MB buffer on large or submodule-heavy repos. We only keep the tail + // of stderr for error reporting and discard stdout entirely. + // Why: use --progress to force git to emit progress even when stderr + // is not a TTY. Without it, git suppresses progress output when piped. + const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null } + await new Promise((resolve, reject) => { + // Why: clone destination may be a WSL path (e.g. user picks a WSL + // directory). Use the parent destination as the cwd so the runner + // detects WSL and routes through wsl.exe. + // Why: use the '--' separator to isolate the URL argument and prevent + // malicious URLs from being interpreted as git flags (command injection). + let proc: ReturnType + try { + proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], { + cwd: args.destination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + } catch (err) { + void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { + const message = err instanceof Error ? err.message : String(err) + reject(new Error(`Clone failed: ${message}`)) + }) + return + } + const generation = nextCloneGeneration++ + latestCloneGenerationByPath.set(clonePathKey, generation) + const metadata: ActiveCloneMetadata = { + path: clonePath, + pathKey: clonePathKey, + claimedTarget, + process: proc, + abortRequested: false, + generation, + pendingAbortCleanup: null, + resolvePendingAbortCleanup: null + } + cloneMetadataRef.current = metadata + activeClone = metadata - let stderrTail = '' - proc.stderr!.on('data', (chunk: Buffer) => { - const text = chunk.toString() - stderrTail = (stderrTail + text).slice(-4096) + let stderrTail = '' + let settled = false + proc.stderr!.on('data', (chunk: Buffer) => { + const text = chunk.toString() + stderrTail = (stderrTail + text).slice(-4096) - // Why: git progress lines use \r to overwrite in-place. Split on - // both \r and \n to find the latest progress fragment, then extract - // the phase name and percentage for the renderer. - const lines = text.split(/[\r\n]+/) - for (const line of lines) { - const match = line.match(/^([\w\s]+):\s+(\d+)%/) - if (match && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:clone-progress', { - phase: match[1].trim(), - percent: parseInt(match[2], 10) - }) + // Why: git progress lines use \r to overwrite in-place. Split on + // both \r and \n to find the latest progress fragment, then extract + // the phase name and percentage for the renderer. + const lines = text.split(/[\r\n]+/) + for (const line of lines) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('repos:clone-progress', { + phase: match[1].trim(), + percent: parseInt(match[2], 10) + }) + } + } + }) + + const finishClone = async ( + code: number | null, + signal: NodeJS.Signals | null, + err?: Error + ) => { + if (settled) { + return + } + settled = true + // Why: only clear the ref if it still points to this process. + // A quick abort-and-retry can reassign activeClone to a new + // spawn before this handler fires, and nulling it would make the + // new clone unabortable. + if (activeClone?.process === proc) { + activeClone = null + } + + const cloneSucceeded = !err && code === 0 && !signal + if (!cloneSucceeded) { + // Why: only the process that created this target may remove it, + // and only after git reports the clone did not complete. + await cleanupOwnedCloneTarget(metadata) + } + if (metadata.abortRequested && !cloneSucceeded) { + settleCloneAbortCleanup(metadata) + } + if (latestCloneGenerationByPath.get(metadata.pathKey) === metadata.generation) { + latestCloneGenerationByPath.delete(metadata.pathKey) + } + + if (err) { + reject(new Error(`Clone failed: ${err.message}`)) + } else if (signal === 'SIGTERM') { + reject(new Error('Clone aborted')) + } else if (code === 0) { + resolve() + } else { + const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' + reject(new Error(`Clone failed: ${lastLine}`)) } } + + proc.on('error', (err) => { + void finishClone(null, null, err) + }) + + proc.on('close', (code, signal) => { + void finishClone(code, signal) + }) }) - proc.on('error', (err) => reject(new Error(`Clone failed: ${err.message}`))) - - proc.on('close', (code, signal) => { - // Why: only clear the ref if it still points to this process. - // A quick abort-and-retry can reassign activeCloneProc to a new - // spawn before this handler fires, and nulling it would make the - // new clone unabortable. - if (activeCloneProc === proc) { - activeCloneProc = null - activeClonePath = null + try { + // Why: check after clone (not before) because the path didn't exist + // before cloning. But if the user somehow had a folder repo at this path + // that git clone succeeded into (empty dir), reuse that entry and upgrade + // its kind to 'git' instead of creating a duplicate. + const existing = store + .getRepos() + .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) + if (existing) { + if (isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { kind: 'git' }) + if (updated) { + notifyReposChanged(mainWindow) + // Why: folder→git upgrade is a real new git repo provisioning event. + emitRepoAdded('clone_url', false) + return updated + } + } + emitRepoAdded('clone_url', true) + return existing } - if (signal === 'SIGTERM') { - reject(new Error('Clone aborted')) - } else if (code === 0) { - resolve() - } else { - const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' - reject(new Error(`Clone failed: ${lastLine}`)) - } - }) - }) - // Why: check after clone (not before) because the path didn't exist - // before cloning. But if the user somehow had a folder repo at this path - // that git clone succeeded into (empty dir), reuse that entry and upgrade - // its kind to 'git' instead of creating a duplicate. - const existing = store.getRepos().find((r) => r.path === clonePath) - if (existing) { - if (isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { kind: 'git' }) - if (updated) { - notifyReposChanged(mainWindow) - // Why: folder→git upgrade is a real new git repo provisioning event. - emitRepoAdded('clone_url', false) - return updated + const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) + const repo: Repo = { + id: randomUUID(), + path: clonePath, + displayName: getRepoName(clonePath), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(repoIcon ? { repoIcon } : {}), + addedAt: Date.now(), + kind: 'git', + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: false + } + + store.addRepo(repo) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + emitRepoAdded('clone_url', false) + return repo + } finally { + const metadata = cloneMetadataRef.current + if (metadata?.abortRequested) { + settleCloneAbortCleanup(metadata) } } - emitRepoAdded('clone_url', true) - return existing - } - - const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) - const repo: Repo = { - id: randomUUID(), - path: clonePath, - displayName: getRepoName(clonePath), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), - addedAt: Date.now(), - kind: 'git', - externalWorktreeVisibility: 'hide', - externalWorktreeVisibilityLegacy: false - } - - store.addRepo(repo) - invalidateAuthorizedRootsCache() - notifyReposChanged(mainWindow) - emitRepoAdded('clone_url', false) - return repo + }) } ) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 570ff42f143..b3ac052224f 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -15,7 +15,10 @@ const { mockPtyProvider, mockFsProvider, mockGitProvider, - mockPortForwardManager + mockPortForwardManager, + mockPortScannerCallbacks, + mockNextConnectionManagers, + mockNextPortForwardManagers } = vi.hoisted(() => ({ handleMock: vi.fn(), powerMonitorOffMock: vi.fn(), @@ -34,7 +37,9 @@ const { reconnect: vi.fn(), getConnection: vi.fn(), getState: vi.fn(), - disconnectAll: vi.fn() + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } }, mockDeployAndLaunchRelay: vi.fn(), mockForceStopRelayForTarget: vi.fn(), @@ -57,11 +62,15 @@ const { mockGitProvider: {}, mockPortForwardManager: { addForward: vi.fn(), + updateForward: vi.fn(), removeForward: vi.fn(), listForwards: vi.fn().mockReturnValue([]), removeAllForwards: vi.fn(), dispose: vi.fn() - } + }, + mockPortScannerCallbacks: new Map(), + mockNextConnectionManagers: [] as unknown[], + mockNextPortForwardManagers: [] as unknown[] })) vi.mock('electron', () => ({ @@ -88,8 +97,14 @@ vi.mock('../ssh/ssh-connection-store', () => ({ vi.mock('../ssh/ssh-connection', () => ({ SshConnectionManager: class MockSshConnectionManager { - constructor() { - return mockConnectionManager + constructor(callbacks: unknown) { + const manager = (mockNextConnectionManagers.shift() ?? + mockConnectionManager) as typeof mockConnectionManager + manager.callbacksRef.current = callbacks + manager.setCallbacks.mockImplementation((nextCallbacks: unknown) => { + manager.callbacksRef.current = nextCallbacks + }) + return manager } } })) @@ -161,12 +176,26 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../ssh/ssh-port-forward', () => ({ SshPortForwardManager: class MockPortForwardManager { constructor() { - return mockPortForwardManager + return mockNextPortForwardManagers.shift() ?? mockPortForwardManager } } })) -import { registerSshHandlers } from './ssh' +vi.mock('../ssh/ssh-port-scanner', () => ({ + PortScanner: class MockPortScanner { + startScanning(targetId: string, _mux: unknown, onChanged: unknown) { + mockPortScannerCallbacks.set(targetId, onChanged) + } + getDetectedPorts() { + return [] + } + stopScanning(targetId: string) { + mockPortScannerCallbacks.delete(targetId) + } + } +})) + +import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshTarget } from '../../shared/ssh-types' import { clearProviderPtyState, @@ -188,9 +217,35 @@ describe('SSH IPC handlers', () => { isDestroyed: () => false, webContents: { send: vi.fn() } } + const createMockWindow = () => ({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + const createConnectionManagerMock = () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + reconnect: vi.fn(), + getConnection: vi.fn(), + getState: vi.fn(), + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } + }) + const createPortForwardManagerMock = () => ({ + addForward: vi.fn(), + updateForward: vi.fn(), + removeForward: vi.fn(), + listForwards: vi.fn().mockReturnValue([]), + removeAllForwards: vi.fn(), + dispose: vi.fn() + }) - beforeEach(() => { + beforeEach(async () => { + await resetSshHandlerStateForTests() handlers.clear() + mockNextConnectionManagers.length = 0 + mockNextPortForwardManagers.length = 0 + mockPortScannerCallbacks.clear() handleMock.mockReset() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) @@ -213,6 +268,8 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getConnection.mockReset() mockConnectionManager.getState.mockReset() mockConnectionManager.disconnectAll.mockReset() + mockConnectionManager.setCallbacks.mockReset() + mockConnectionManager.callbacksRef.current = null mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ @@ -228,6 +285,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.shutdown.mockReset() mockPortForwardManager.addForward.mockReset() + mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() mockPortForwardManager.listForwards.mockReset().mockReturnValue([]) mockPortForwardManager.removeAllForwards.mockReset() @@ -437,6 +495,64 @@ describe('SSH IPC handlers', () => { } }) + it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { + vi.useFakeTimers() + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + try { + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as + | ((reason: 'shutdown' | 'connection_lost') => void) + | undefined + + onDispose?.('connection_lost') + + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel lost. Reconnecting...', + reconnectAttempt: 1 + }) + + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockDeployAndLaunchRelay).toHaveBeenCalled() + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } finally { + vi.useRealTimers() + } + }) + it('forwards remote PTY events into the runtime', async () => { const runtime = { onPtyData: vi.fn(), @@ -474,6 +590,258 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('preserves active port forwards and live connections across handler re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + const forward = { + id: 'pf-1', + connectionId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + } + const updatedForward = { ...forward, remotePort: 3001 } + const newForward = { ...forward, id: 'pf-2', localPort: 4101 } + const connectedState = { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue(connectedState) + mockPortForwardManager.addForward + .mockResolvedValueOnce(forward) + .mockResolvedValueOnce(newForward) + mockPortForwardManager.updateForward.mockResolvedValue(updatedForward) + mockPortForwardManager.removeForward.mockReturnValue(updatedForward) + mockPortForwardManager.listForwards.mockReturnValue([forward]) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(getSshConnectionManager()).toBe(mockConnectionManager) + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual( + connectedState + ) + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + + await handlers.get('ssh:updatePortForward')!(null, { + id: 'pf-1', + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3001, + label: 'app' + }) + expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith( + 'pf-1', + conn, + 4100, + '127.0.0.1', + 3001, + 'app' + ) + + expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual( + updatedForward + ) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4101, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith( + 'ssh-1', + conn, + 4101, + '127.0.0.1', + 3000, + 'app' + ) + expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled() + expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled() + }) + + it('disconnects the original session and releases original forwards after re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockClear() + mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(replacementPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled() + }) + + it('refreshes live session callbacks to the newest window, store, and runtime', async () => { + const firstWindow = createMockWindow() + const secondWindow = createMockWindow() + const firstRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + const secondRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as + | ((payload: { id: string; data: string }) => void) + | undefined + const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as + | ((payload: { id: string; code: number }) => void) + | undefined + const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as + | ((targetId: string, ports: unknown[], platform: string) => void) + | undefined + firstWindow.webContents.send.mockClear() + secondWindow.webContents.send.mockClear() + + registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never) + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: unknown) => void + } + + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + }) + onData?.({ id: 'remote-pty', data: 'hello' }) + onExit?.({ id: 'remote-pty', code: 9 }) + onDetectedPorts?.( + 'ssh-1', + [{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }], + 'linux-x64' + ) + + expect(firstWindow.webContents.send).not.toHaveBeenCalled() + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + } + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'remote-pty', data: 'hello' }) + ) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'remote-pty', + code: 9 + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', { + targetId: 'ssh-1', + ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) + }) + expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9) + expect(firstRuntime.onPtyData).not.toHaveBeenCalled() + expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + 'terminated' + ) + }) + + it('re-registers without replacing managers when no targets are connected', () => { + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + const result = registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(result.connectionManager).toBe(mockConnectionManager) + expect(replacementConnectionManager.setCallbacks).not.toHaveBeenCalled() + expect(replacementPortForwardManager.dispose).not.toHaveBeenCalled() + expect(mockNextConnectionManagers).toHaveLength(1) + expect(mockNextPortForwardManagers).toHaveLength(1) + }) + it('ssh:disconnect calls connection manager', async () => { mockConnectionManager.disconnect.mockResolvedValue(undefined) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ecf88092d8e..3497fd9f99f 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -45,6 +45,37 @@ let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined let persistedStore: Store | null = null let advertisedUrlWatcherUnsubscribe: (() => void) | null = null let powerMonitorUnsubscribe: (() => void) | null = null +let currentGetMainWindow: () => BrowserWindow | null = () => null +let currentRuntime: OrcaRuntimeService | undefined + +const SSH_IPC_CHANNELS = [ + 'ssh:listTargets', + 'ssh:addTarget', + 'ssh:updateTarget', + 'ssh:removeTarget', + 'ssh:importConfig', + 'ssh:connect', + 'ssh:disconnect', + 'ssh:terminateSessions', + 'ssh:resetRelay', + 'ssh:getState', + 'ssh:needsPassphrasePrompt', + 'ssh:testConnection', + 'ssh:addPortForward', + 'ssh:updatePortForward', + 'ssh:removePortForward', + 'ssh:listPortForwards', + 'ssh:listDetectedPorts' +] as const + +// Why: connection callbacks are process-lifetime; keeping this set outside +// registerSshHandlers prevents in-flight connects from splitting credential +// tracking when a BrowserWindow is recreated. +const credentialRequestedForTarget = new Set() + +function getCurrentMainWindow(): BrowserWindow | null { + return currentGetMainWindow() +} export async function connectRegisteredSshTarget(targetId: string): Promise { if (!registeredConnectSshTarget) { @@ -340,51 +371,11 @@ function registerPowerMonitorReconnect(): void { } } -export function registerSshHandlers( - store: Store, - getMainWindow: () => BrowserWindow | null, - runtime?: OrcaRuntimeService -): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { - // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls - // this function. ipcMain.handle() throws if a handler is already registered, - // so we must remove any prior handlers before re-registering. - for (const ch of [ - 'ssh:listTargets', - 'ssh:addTarget', - 'ssh:updateTarget', - 'ssh:removeTarget', - 'ssh:importConfig', - 'ssh:connect', - 'ssh:disconnect', - 'ssh:terminateSessions', - 'ssh:resetRelay', - 'ssh:getState', - 'ssh:needsPassphrasePrompt', - 'ssh:testConnection', - 'ssh:addPortForward', - 'ssh:updatePortForward', - 'ssh:removePortForward', - 'ssh:listPortForwards', - 'ssh:listDetectedPorts' - ]) { - ipcMain.removeHandler(ch) - } - - sshStore = new SshConnectionStore(store) - persistedStore = store - registerAdvertisedUrlRefresh(getMainWindow) - - registerCredentialHandler(getMainWindow) - - // Why: tracks whether a credential prompt was triggered during the current - // ssh:connect call. Used to set lastRequiredPassphrase on the target so - // startup reconnect can defer passphrase-protected targets to tab focus. - const credentialRequestedForTarget = new Set() - - const callbacks: SshConnectionCallbacks = { +function createSshConnectionCallbacks(): SshConnectionCallbacks { + return { onCredentialRequest: (targetId, kind, detail) => { credentialRequestedForTarget.add(targetId) - return requestCredential(getMainWindow, targetId, kind, detail) + return requestCredential(getCurrentMainWindow, targetId, kind, detail) }, onStateChange: (targetId: string, state: SshConnectionState) => { if (testingTargets.has(targetId)) { @@ -407,7 +398,7 @@ export function registerSshHandlers( // Why: SSH is connected before the relay providers are rebuilt. Keep // renderer actions gated until SshRelaySession reaches ready again. publishRelayOverride( - getMainWindow, + getCurrentMainWindow, targetId, 'reconnecting', 'Relay channel reconnecting...', @@ -415,7 +406,7 @@ export function registerSshHandlers( ) } else { clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, state) + broadcastSshState(getCurrentMainWindow, targetId, state) } if (!session) { @@ -434,9 +425,159 @@ export function registerSshHandlers( } } } +} - connectionManager = new SshConnectionManager(callbacks) - portForwardManager = new SshPortForwardManager() +function broadcastDetectedPortsFromCurrentWindow( + targetId: string, + ports: DetectedPort[], + _platform: string +): void { + broadcastDetectedPorts(getCurrentMainWindow, targetId, ports) +} + +function configureRelaySessionCallbacks(session: SshRelaySession): void { + session.setOnTerminalRelayError((tid, err) => { + clearRelayLostBackoff(tid) + console.warn( + `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` + ) + publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0) + }) + + session.setOnRelayLost((tid) => { + const s = activeSessions.get(tid) + if (!s) { + return + } + const c = connectionManager?.getConnection(tid) + if (!c) { + return + } + const t = sshStore?.getTarget(tid) + + // Why: bounded exponential backoff. Without this, a remote-side bug + // that closes every fresh --connect channel turns into an infinite + // tight loop spawning relay deploys until the user force-quits. + const state = relayLostBackoff.get(tid) ?? { + attempts: 0, + lastAttemptStartedAt: 0, + pendingTimer: null + } + if (state.pendingTimer) { + return + } + if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + console.warn( + `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` + ) + relayLostBackoff.delete(tid) + // Why: surface the failure so the renderer can prompt the user. + // A still-live SSH connection with a dead relay is otherwise an + // invisible failure — typing in remote terminals just stops working. + publishRelayOverride( + getCurrentMainWindow, + tid, + 'error', + 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', + 0 + ) + return + } + const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) + state.attempts += 1 + publishRelayOverride( + getCurrentMainWindow, + tid, + 'reconnecting', + 'Relay channel lost. Reconnecting...', + state.attempts + ) + state.pendingTimer = setTimeout(() => { + state.pendingTimer = null + state.lastAttemptStartedAt = Date.now() + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + return + } + void s.reconnect(liveConn, relayGracePeriodForTarget(t)) + }, delay) + relayLostBackoff.set(tid, state) + console.warn( + `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` + ) + }) + + // Why: fires after both establish() and reconnect() reach 'ready'. + // Re-creates persisted port forwards so they survive app restarts + // and network blips without manual re-configuration. + session.setOnReady((tid) => { + const state = relayLostBackoff.get(tid) + if (state) { + const stabilized = + state.lastAttemptStartedAt === 0 || + Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS + if (stabilized) { + relayLostBackoff.delete(tid) + } + } + clearRelayStateOverride(tid) + if (!testingTargets.has(tid)) { + broadcastSshState(getCurrentMainWindow, tid, { + targetId: tid, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } + void restorePortForwards(tid, getCurrentMainWindow) + }) +} + +function refreshActiveRelaySessions(): void { + if (!persistedStore || !portForwardManager) { + return + } + for (const session of activeSessions.values()) { + session.refreshEnvironment( + getCurrentMainWindow, + persistedStore, + portForwardManager, + currentRuntime, + broadcastDetectedPortsFromCurrentWindow + ) + configureRelaySessionCallbacks(session) + } +} + +export function registerSshHandlers( + store: Store, + getMainWindow: () => BrowserWindow | null, + runtime?: OrcaRuntimeService +): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { + // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls + // this function. ipcMain.handle() throws if a handler is already registered, + // so we must remove any prior handlers before re-registering. + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + + currentGetMainWindow = getMainWindow + currentRuntime = runtime + sshStore = new SshConnectionStore(store) + persistedStore = store + registerAdvertisedUrlRefresh(getCurrentMainWindow) + + registerCredentialHandler(getCurrentMainWindow) + + const callbacks = createSshConnectionCallbacks() + if (connectionManager) { + connectionManager.setCallbacks(callbacks) + } else { + connectionManager = new SshConnectionManager(callbacks) + } + portForwardManager ??= new SshPortForwardManager() + refreshActiveRelaySessions() registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) @@ -475,7 +616,7 @@ export function registerSshHandlers( `[ssh] Failed to disconnect removed target ${args.id}: ${err instanceof Error ? err.message : String(err)}` ) } - store.removeSshRemotePtyLeases(args.id) + persistedStore!.removeSshRemotePtyLeases(args.id) sshStore!.removeTarget(args.id) }) @@ -516,17 +657,35 @@ export function registerSshHandlers( }) async function doConnect(targetId: string): Promise { - clearRelayStateOverride(targetId) const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) } + const existingSession = activeSessions.get(targetId) + const existingState = connectionManager!.getState(targetId) + const existingMux = existingSession?.getMux() + if ( + existingSession?.getState() === 'ready' && + existingState?.status === 'connected' && + connectionManager!.getConnection(targetId) && + existingMux && + !existingMux.isDisposed() && + !relayStateOverrides.has(targetId) && + !relayLostBackoff.has(targetId) + ) { + // Why: BrowserWindow reactivation reruns renderer startup, which calls + // ssh:connect for already-live targets. Treat that as a refresh instead + // of tearing down the relay and stranding active port forwards. + broadcastSshState(getCurrentMainWindow, targetId, existingState) + return existingState + } + + clearRelayStateOverride(targetId) let conn // Why: dispose any existing session to avoid leaking the old multiplexer, // providers, and timers. This handles double-connect (user clicks connect // while already connected) and reconnect-after-error. - const existingSession = activeSessions.get(targetId) if (existingSession) { // Why: await port teardown before disposing so the OS fully releases // local ports. Without this, restorePortForwards in the new session @@ -542,14 +701,13 @@ export function registerSshHandlers( // state and knows not to trigger reconnect logic. const session = new SshRelaySession( targetId, - getMainWindow, - store, + getCurrentMainWindow, + persistedStore!, portForwardManager!, - runtime, - (tid, ports, _platform) => { - broadcastDetectedPorts(getMainWindow, tid, ports) - } + currentRuntime, + broadcastDetectedPortsFromCurrentWindow ) + configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) try { @@ -569,7 +727,7 @@ export function registerSshHandlers( activeSessions.delete(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, { + broadcastSshState(getCurrentMainWindow, targetId, { targetId, status, error: errObj.message, @@ -587,129 +745,13 @@ export function registerSshHandlers( reconnectAttempt: 0 }) - // Why: the relay exec channel can close independently of the SSH - // connection (e.g. --connect bridge exits, relay process crashes). - // When that happens, the mux is disposed but onStateChange never - // fires because the SSH connection is still alive. This callback - // triggers session.reconnect() using the live SSH connection. - // Set before establish() so the callback is in place if the relay - // dies during the deploy/connect sequence. - // Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means - // the local client and remote daemon are at different code versions — - // no amount of backoff will reconcile them. Skip the relay-lost loop - // entirely and surface a terminal "please reconnect manually" error. - session.setOnTerminalRelayError((tid, err) => { - clearRelayLostBackoff(tid) - console.warn( - `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` - ) - publishRelayOverride(getMainWindow, tid, 'error', err.message, 0) - }) - - session.setOnRelayLost((tid) => { - const s = activeSessions.get(tid) - if (!s) { - return - } - const c = connectionManager?.getConnection(tid) - if (!c) { - return - } - const t = sshStore?.getTarget(tid) - - // Why: bounded exponential backoff. Without this, a remote-side bug - // that closes every fresh --connect channel turns into an infinite - // tight loop spawning relay deploys until the user force-quits. - const state = relayLostBackoff.get(tid) ?? { - attempts: 0, - lastAttemptStartedAt: 0, - pendingTimer: null - } - if (state.pendingTimer) { - // A retry is already scheduled — coalesce this burst. - return - } - if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { - console.warn( - `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` - ) - relayLostBackoff.delete(tid) - // Why: surface the failure so the renderer can prompt the user. - // A still-live SSH connection with a dead relay is otherwise an - // invisible failure — typing in remote terminals just stops working. - publishRelayOverride( - getMainWindow, - tid, - 'error', - 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - 0 - ) - return - } - const delay = Math.min( - RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, - RELAY_LOST_MAX_DELAY_MS - ) - state.attempts += 1 - publishRelayOverride( - getMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - state.pendingTimer = setTimeout(() => { - state.pendingTimer = null - state.lastAttemptStartedAt = Date.now() - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - return - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - }, delay) - relayLostBackoff.set(tid, state) - console.warn( - `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` - ) - }) - - // Why: fires after both establish() and reconnect() reach 'ready'. - // Re-creates persisted port forwards so they survive app restarts - // and network blips without manual re-configuration. We also clear - // the relay-lost backoff state so a subsequent genuine drop starts - // from a fresh attempt counter — but only if the session had a chance - // to stabilize, otherwise rapid `ready → lost → ready → lost` flaps - // would silently keep retrying forever. - session.setOnReady((tid) => { - const state = relayLostBackoff.get(tid) - if (state) { - const stabilized = - state.lastAttemptStartedAt === 0 || - Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS - if (stabilized) { - relayLostBackoff.delete(tid) - } - } - clearRelayStateOverride(tid) - if (!testingTargets.has(tid)) { - broadcastSshState(getMainWindow, tid, { - targetId: tid, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - } - void restorePortForwards(tid, getMainWindow) - }) - await session.establish(conn, relayGracePeriodForTarget(target)) // Why: we manually pushed `deploying-relay` above, so the renderer's // state is stuck there. Send `connected` directly to the renderer // instead of going through callbacks.onStateChange, which would // trigger the reconnection logic. - const win = getMainWindow() + const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { clearRelayStateOverride(targetId) win.webContents.send('ssh:state-changed', { @@ -759,7 +801,7 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { const session = activeSessions.get(args.targetId) const provider = getSshPtyProvider(args.targetId) - const leasedIds = store + const leasedIds = persistedStore! .getSshRemotePtyLeases(args.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') .map((lease) => lease.ptyId) @@ -803,7 +845,7 @@ export function registerSshHandlers( } clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) - store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the @@ -848,10 +890,10 @@ export function registerSshHandlers( await forceStopRelayForTarget(conn, targetId) } finally { const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of store.getSshRemotePtyLeases(targetId)) { + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && lease.state !== 'expired') { ptyIds.add(lease.ptyId) - store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') } } // Why: reset force-kills the remote relay daemon, so every local PTY @@ -998,7 +1040,7 @@ export function registerSshHandlers( args.label ) persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) return entry } ) @@ -1030,14 +1072,14 @@ export function registerSshHandlers( args.label ) persistPortForwards(entry.connectionId) - broadcastPortForwards(getMainWindow, entry.connectionId) + broadcastPortForwards(getCurrentMainWindow, entry.connectionId) return entry } catch (err) { // Why: if the edit failed (and rollback may also have failed), // sync the renderer with the actual runtime state so it doesn't // show a forward that no longer exists. persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) throw err } } @@ -1047,7 +1089,7 @@ export function registerSshHandlers( const removed = portForwardManager!.removeForward(args.id) if (removed) { persistPortForwards(removed.connectionId) - broadcastPortForwards(getMainWindow, removed.connectionId) + broadcastPortForwards(getCurrentMainWindow, removed.connectionId) } return removed }) @@ -1076,6 +1118,41 @@ export function getSshConnectionManager(): SshConnectionManager | null { return connectionManager } +export async function resetSshHandlerStateForTests(): Promise { + advertisedUrlWatcherUnsubscribe?.() + advertisedUrlWatcherUnsubscribe = null + powerMonitorUnsubscribe?.() + powerMonitorUnsubscribe = null + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + ipcMain.removeHandler('ssh:submitCredential') + + for (const session of activeSessions.values()) { + session.dispose() + } + activeSessions.clear() + for (const targetId of relayLostBackoff.keys()) { + clearRelayLostBackoff(targetId) + } + relayStateOverrides.clear() + connectInFlight.clear() + resetRelayInFlight.clear() + testingTargets.clear() + credentialRequestedForTarget.clear() + + await connectionManager?.disconnectAll() + portForwardManager?.dispose() + connectionManager = null + portForwardManager = null + sshStore = null + persistedStore = null + registeredConnectSshTarget = null + registeredGetSshState = null + currentGetMainWindow = () => null + currentRuntime = undefined +} + export function getSshConnectionStore(): SshConnectionStore | null { return sshStore } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ac6a7f57775..d5bf1b61543 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -253,6 +253,7 @@ describe('Store', () => { expect(settings.openInApplications).toEqual([]) expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) + expect(settings.experimentalTerminalAttention).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -785,6 +786,7 @@ describe('Store', () => { expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear']) expect(store.getSettings().experimentalActivity).toBe(false) expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true) + expect(store.getSettings().experimentalTerminalAttention).toBe(false) expect(store.getSettings().notifications.customSoundPath).toBeNull() // repos should be loaded expect(store.getRepos()).toHaveLength(1) diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 1932af4ce5d..29daf5af532 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -179,4 +179,61 @@ describe('SshFilesystemProvider readFile streaming', () => { }) await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) }) + + it('rejects a short final chunk instead of zero-filling the buffer', async () => { + // Two declared chunks, but the final one delivers a single byte. The chunk + // count matches (2), so the old code resolved with a zero-filled tail. The + // exact-length check must reject this. + const totalSize = 256 * 1024 * 2 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 1, + data: Buffer.alloc(1).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) + }) + + it('rejects a short non-final chunk before later chunks arrive', async () => { + const totalSize = 256 * 1024 * 2 + mux.request.mockImplementation(async () => { + setImmediate(() => { + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 0, + data: Buffer.alloc(1).toString('base64') + }) + mux._emitMethod('fs.streamChunk', { + streamId: 1, + seq: 1, + data: Buffer.alloc(256 * 1024).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 1 }) + }) + return { + streamId: 1, + totalSize, + isBinary: true, + chunkEncoding: 'base64', + resultEncoding: 'base64' + } + }) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) + }) }) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index c8b46925932..07b64914be5 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -3,6 +3,7 @@ SFTP binary writes, watch fan-out, and provider lifecycle tests together so transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' type MockMultiplexer = { request: ReturnType @@ -259,6 +260,29 @@ describe('SshFilesystemProvider', () => { }) }) + it('renameNoClobber sends fs.renameNoClobber request', async () => { + await provider.renameNoClobber('/home/old.txt', '/home/new.txt') + expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { + oldPath: '/home/old.txt', + newPath: '/home/new.txt' + }) + }) + + it('renameNoClobber fails closed when the relay lacks safe rename support', async () => { + mux.request.mockRejectedValueOnce( + Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + + await expect(provider.renameNoClobber('/home/old.txt', '/home/new.txt')).rejects.toThrow( + 'Remote safe rename is unavailable' + ) + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { + oldPath: '/home/old.txt', + newPath: '/home/new.txt' + }) + }) + it('copy sends fs.copy request', async () => { await provider.copy('/home/src.txt', '/home/dst.txt') expect(mux.request).toHaveBeenCalledWith('fs.copy', { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index b50a256e76e..6268a22aa6b 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -214,6 +214,19 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.rename', { oldPath, newPath }) } + async renameNoClobber(oldPath: string, newPath: string): Promise { + try { + await this.mux.request('fs.renameNoClobber', { oldPath, newPath }) + } catch (err) { + if (isMethodNotFoundError(err)) { + // Why: falling back to raw fs.rename can silently clobber the target on + // older relays. Fail closed and let reconnect deploy the safe relay. + throw new Error('Remote safe rename is unavailable. Reconnect the SSH target and retry.') + } + throw err + } + } + async copy(source: string, destination: string): Promise { await this.mux.request('fs.copy', { source, destination }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 69f80e57cb7..fdd50bcb9d9 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -140,6 +140,7 @@ export type IFilesystemProvider = { createDir(dirPath: string): Promise createDirNoClobber(dirPath: string): Promise rename(oldPath: string, newPath: string): Promise + renameNoClobber(oldPath: string, newPath: string): Promise copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index ed4d4d9c82f..3e6b7a06c0e 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -56,6 +56,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -67,7 +68,7 @@ function mockStats(dev: number, ino: number) { function createRuntimeFileCommands() { const store = { - getRepo: vi.fn(() => undefined) + getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) } const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', @@ -215,6 +216,30 @@ describe('RuntimeFileCommands', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('routes runtime remote rename through the SSH no-clobber provider method', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts') + + expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts') + expect(renameMock).not.toHaveBeenCalled() + }) + + it('propagates runtime remote no-clobber rename failures', async () => { + const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never) + const { commands, store } = createRuntimeFileCommands() + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow( + 'destination exists' + ) + expect(renameMock).not.toHaveBeenCalled() + }) + it('uses a conservative Node watcher for Windows runtime file watches', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index d1bc9483649..3d850e5069e 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -53,7 +53,7 @@ import { getSshFilesystemProvider, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' -import { assertFileExplorerRenameDestinationAvailable } from '../file-explorer-rename-collision' +import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -543,14 +543,14 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.rename(oldTarget.path, newTarget.path) + await provider.renameNoClobber(oldTarget.path, newTarget.path) return { ok: true } } const store = this.host.requireStore() const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await assertFileExplorerRenameDestinationAvailable(oldPath, newPath) + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) await rename(oldPath, newPath) return { ok: true } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e1db5885814..55b45ce2f16 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2152,6 +2152,167 @@ describe('OrcaRuntimeService', () => { } }) + it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const runtime = createRuntime() + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const destination = join(tempRoot, 'destination') + + try { + await expect(runtime.cloneRepo('file:///tmp/source/.', destination)).rejects.toThrow( + 'Invalid repository name derived from URL' + ) + expect(spawnSpy).not.toHaveBeenCalled() + } finally { + spawnSpy.mockRestore() + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const runtime = createRuntime() + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const destination = join(tempRoot, 'destination') + + try { + await expect(runtime.cloneRepo('file:///tmp/source/..', destination)).rejects.toThrow( + 'Invalid repository name derived from URL' + ) + expect(spawnSpy).not.toHaveBeenCalled() + } finally { + spawnSpy.mockRestore() + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('removes an owned runtime clone target when git exits unsuccessfully', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + + try { + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await writeFile(join(clonePath, 'partial.txt'), 'git wrote this before failing') + proc.stderr.emit('data', Buffer.from('fatal: repository not found\n')) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed: fatal: repository not found') + await expect(lstat(clonePath)).rejects.toThrow() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('preserves an existing runtime clone target when git exits unsuccessfully', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + + try { + await mkdir(clonePath) + await writeFile(join(clonePath, 'user-file.txt'), 'keep me') + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed') + await expect(lstat(join(clonePath, 'user-file.txt'))).resolves.toBeTruthy() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('skips runtime clone failure cleanup when the owned target is replaced', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + spawnSpy.mockReturnValue(proc as never) + const runtime = createRuntime() + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const clonePath = join(destination, 'repo-badge-color') + const replacementFile = join(clonePath, 'replacement.txt') + + try { + const clonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await rm(clonePath, { recursive: true, force: true }) + await mkdir(clonePath) + await writeFile(replacementFile, 'new owner') + proc.emit('close', 128, null) + + await expect(clonePromise).rejects.toThrow('Clone failed') + await expect(lstat(replacementFile)).resolves.toBeTruthy() + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('serializes concurrent runtime clones for the same target', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + firstProc.stderr = new EventEmitter() + spawnSpy.mockReturnValueOnce(firstProc as never) + 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 destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + + try { + const firstClonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + const secondClonePromise = runtime.cloneRepo( + 'https://example.com/repo-badge-color.git', + destination + ) + await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnSpy).toHaveBeenCalledTimes(1) + + firstProc.emit('close', 0, null) + await expect(firstClonePromise).resolves.toMatchObject({ + path: join(destination, 'repo-badge-color') + }) + await expect(secondClonePromise).resolves.toMatchObject({ + path: join(destination, 'repo-badge-color') + }) + expect(spawnSpy).toHaveBeenCalledTimes(1) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { vi.mocked(listWorktrees).mockResolvedValue([ { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 106aa9c9707..cb01bdb1189 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,9 +9,15 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' +import { + cleanupClaimedCloneTarget, + claimCloneTarget, + deriveValidatedClonePath, + getClonePathComparisonKey +} from '../git/repo-clone-path' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { createHash, randomUUID } from 'crypto' -import { basename, isAbsolute, join } from 'path' +import { isAbsolute, join } from 'path' import { mkdir, readFile, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' @@ -1029,6 +1035,7 @@ export class OrcaRuntimeService { private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null private resolvedWorktreeGeneration = 0 + private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() @@ -5460,30 +5467,92 @@ export class OrcaRuntimeService { } const trimmedUrl = url.trim() const trimmedDestination = destination.trim() - const repoName = basename(trimmedUrl.replace(/\.git\/?$/, '')) - if (!repoName) { - throw new Error('Could not determine repository name from URL') - } if (!trimmedDestination) { throw new Error('Clone destination is required') } - if (!isAbsolute(trimmedDestination)) { - throw new Error('Clone destination must be an absolute path') + const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination }) + const clonePathKey = getClonePathComparisonKey(clonePath) + const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.cloneInFlightByPath.set(clonePathKey, tail) + + try { + await previous + return await this.cloneRepoAfterPathLock( + trimmedUrl, + trimmedDestination, + clonePath, + clonePathKey + ) + } finally { + release() + if (this.cloneInFlightByPath.get(clonePathKey) === tail) { + this.cloneInFlightByPath.delete(clonePathKey) + } } + } + + private async cloneRepoAfterPathLock( + trimmedUrl: string, + trimmedDestination: string, + clonePath: string, + clonePathKey: string + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const existingBeforeClone = this.store + .getRepos() + .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { + return existingBeforeClone + } + await mkdir(trimmedDestination, { recursive: true }) - const clonePath = join(trimmedDestination, repoName) + const claimedTarget = await claimCloneTarget(clonePath) await new Promise((resolve, reject) => { - const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { - cwd: trimmedDestination, - stdio: ['ignore', 'ignore', 'pipe'] - }) + let proc: ReturnType + try { + proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { + cwd: trimmedDestination, + stdio: ['ignore', 'ignore', 'pipe'] + }) + } catch (err) { + void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { + const message = err instanceof Error ? err.message : String(err) + reject(new Error(`Clone failed: ${message}`)) + }) + return + } let stderrTail = '' + let settled = false proc.stderr?.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString()).slice(-4096) }) - proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`))) - proc.on('close', (code, signal) => { - if (signal === 'SIGTERM') { + const finishClone = async ( + code: number | null, + signal: NodeJS.Signals | null, + error?: Error + ) => { + if (settled) { + return + } + settled = true + const cloneSucceeded = !error && code === 0 && !signal + if (!cloneSucceeded) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + } + + if (error) { + reject(new Error(`Clone failed: ${error.message}`)) + } else if (signal === 'SIGTERM') { reject(new Error('Clone aborted')) } else if (code === 0) { resolve() @@ -5491,10 +5560,18 @@ export class OrcaRuntimeService { const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error' reject(new Error(`Clone failed: ${lastLine}`)) } + } + proc.on('error', (error) => { + void finishClone(null, null, error) + }) + proc.on('close', (code, signal) => { + void finishClone(code, signal) }) }) - const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath)) + const existing = this.store + .getRepos() + .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) if (existing) { if (isFolderRepo(existing)) { const updated = this.store.updateRepo(existing.id, { kind: 'git' }) diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index 054328a9653..d07cac5872c 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -16,7 +16,7 @@ // `pnpm run build:cli`. The verification gate explicitly builds the CLI // before running this file. import { spawn } from 'child_process' -import { existsSync, mkdtempSync } from 'fs' +import { existsSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' @@ -31,6 +31,40 @@ const CLI_PATH = join(process.cwd(), 'out', 'cli', 'index.js') const describeIfBuilt = existsSync(CLI_PATH) ? describe : describe.skip +async function runBuiltCli( + userDataPath: string, + args: string[], + extraEnv: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = spawn(process.execPath, [CLI_PATH, ...args], { + env: { + ...process.env, + ORCA_USER_DATA_PATH: userDataPath, + ORCA_TERMINAL_HANDLE: 'term_cli', + ...extraEnv + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (d) => stdoutChunks.push(d)) + child.stderr.on('data', (d) => stderrChunks.push(d)) + + const exitCode = await new Promise((resolveExit, rejectExit) => { + child.once('exit', (code) => resolveExit(code ?? 1)) + child.once('error', rejectExit) + }) + + return { + exitCode, + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join('') + } +} + describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { it('emits newline-flushed JSON heartbeats to stderr while waiting', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-sub-')) @@ -156,3 +190,88 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { } }, 30_000) }) + +describeIfBuilt('orca orchestration reset subprocess', () => { + it('validates reset scopes against an isolated runtime through the built CLI', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-reset-')) + const runtime = new OrcaRuntimeService() + const db = new OrchestrationDb(':memory:') + runtime.setOrchestrationDb(db) + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + + try { + const send = await runBuiltCli(userDataPath, [ + 'orchestration', + 'send', + '--to', + 'term_target', + '--subject', + 'hello', + '--json' + ]) + expect(send.exitCode, send.stderr).toBe(0) + + const create = await runBuiltCli(userDataPath, [ + 'orchestration', + 'task-create', + '--spec', + 'throwaway task', + '--json' + ]) + expect(create.exitCode, create.stderr).toBe(0) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const invalid = await runBuiltCli(userDataPath, [ + 'orchestration', + 'reset', + '--tasks', + '--messages', + '--json' + ]) + expect(invalid.exitCode).toBe(1) + const invalidPayload = JSON.parse(invalid.stdout) as { + ok: boolean + error: { code: string; message: string } + } + expect(invalidPayload.ok).toBe(false) + expect(invalidPayload.error.code).toBe('invalid_argument') + expect(invalidPayload.error.message).toContain('Choose exactly one reset scope') + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const resetTasks = await runBuiltCli(userDataPath, [ + 'orchestration', + 'reset', + '--tasks', + '--json' + ]) + expect(resetTasks.exitCode, resetTasks.stderr).toBe(0) + expect(JSON.parse(resetTasks.stdout)).toMatchObject({ ok: true, result: { reset: 'tasks' } }) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + + const recreate = await runBuiltCli(userDataPath, [ + 'orchestration', + 'task-create', + '--spec', + 'throwaway task after partial reset', + '--json' + ]) + expect(recreate.exitCode, recreate.stderr).toBe(0) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const resetAll = await runBuiltCli(userDataPath, ['orchestration', 'reset', '--json']) + expect(resetAll.exitCode, resetAll.stderr).toBe(0) + expect(JSON.parse(resetAll.stdout)).toMatchObject({ ok: true, result: { reset: 'all' } }) + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(0) + } finally { + db.close() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 4d7322ecff2..d3c4edaea5a 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -330,6 +330,146 @@ describe('file RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it.each([ + ['missing content', { worktree: 'id:wt-1', relativePath: 'src/index.ts' }], + ['null content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: null }], + ['non-string content', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: 0 }] + ])('rejects a write with %s instead of truncating the file', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.write', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFile).not.toHaveBeenCalled() + }) + + it('still allows writing an explicit empty string (empty file)', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.write', { worktree: 'id:wt-1', relativePath: 'src/index.ts', content: '' }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith('id:wt-1', 'src/index.ts', '') + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('allows writing explicit empty base64 content', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + contentBase64: '' + }) + ) + + expect(runtime.writeFileExplorerFileBase64).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/logo.png', + '' + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it.each([ + ['missing content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png' }], + ['null content', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: null }], + [ + 'non-string content', + { worktree: 'id:wt-1', relativePath: 'assets/logo.png', contentBase64: 0 } + ] + ])('rejects a base64 write with %s', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.writeBase64', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFileBase64).not.toHaveBeenCalled() + }) + + it('allows writing an explicit empty base64 chunk', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeBase64Chunk', { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: '', + append: true + }) + ) + + expect(runtime.writeFileExplorerFileBase64Chunk).toHaveBeenCalledWith( + 'id:wt-1', + 'assets/video.mov', + '', + true + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it.each([ + [ + 'missing content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + append: true + } + ], + [ + 'null content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: null, + append: true + } + ], + [ + 'non-string content', + { + worktree: 'id:wt-1', + relativePath: 'assets/video.mov', + contentBase64: 0, + append: true + } + ] + ])('rejects a base64 chunk write with %s (inherits the schema)', async (_name, params) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFileBase64Chunk: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('files.writeBase64Chunk', params)) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.writeFileExplorerFileBase64Chunk).not.toHaveBeenCalled() + }) + it('commits staged runtime uploads without clobbering the final destination', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 3c5c65f9a74..fbcf31936de 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -30,18 +30,19 @@ const FileTreePath = WorktreeSelector.extend({ .pipe(z.string()) }) +// Why: write content must be a real string. Coercing a missing/non-string value +// to '' silently truncated the target file to empty instead of erroring. An +// explicit '' is still accepted (writing an empty file is legitimate). const FileWrite = FileOpen.extend({ content: z .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) const FileWriteBase64 = FileOpen.extend({ contentBase64: z .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) const FileWriteBase64Chunk = FileWriteBase64.extend({ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 27cc27cfff7..aa5121f5f93 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1022,10 +1022,14 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.reset', () => { - it('resets all state', async () => { - setup() + function seedResetState(): void { db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) db.createTask({ spec: 'work' }) + } + + it('resets all state', async () => { + setup() + seedResetState() const result = (await call('orchestration.reset', { all: true })) as { reset: string } expect(result.reset).toBe('all') @@ -1035,8 +1039,7 @@ describe('orchestration RPC methods', () => { it('resets tasks only', async () => { setup() - db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) - db.createTask({ spec: 'work' }) + seedResetState() await call('orchestration.reset', { tasks: true }) expect(db.getInbox()).toHaveLength(1) @@ -1045,12 +1048,52 @@ describe('orchestration RPC methods', () => { it('resets messages only', async () => { setup() - db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) - db.createTask({ spec: 'work' }) + seedResetState() await call('orchestration.reset', { messages: true }) expect(db.getInbox()).toHaveLength(0) expect(db.listTasks()).toHaveLength(1) }) + + it.each([ + ['empty params', {}], + ['false-only params', { all: false }], + ['multi-scope task and messages params', { tasks: true, messages: true }], + ['multi-scope all and tasks params', { all: true, tasks: true }], + ['non-boolean params', { all: 'true' }] + ])('rejects %s without mutating state', async (_name, params) => { + setup() + seedResetState() + + await expect(call('orchestration.reset', params)).rejects.toThrow() + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + }) + + it('ignores false scopes when exactly one scope is true', async () => { + setup() + seedResetState() + + const result = (await call('orchestration.reset', { all: false, tasks: true })) as { + reset: string + } + + expect(result.reset).toBe('tasks') + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + }) + + it('ignores non-boolean scopes when exactly one real boolean scope is true', async () => { + setup() + seedResetState() + + const result = (await call('orchestration.reset', { all: 'true', messages: true })) as { + reset: string + } + + expect(result.reset).toBe('messages') + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(1) + }) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index b2d31e98902..7584ef6aafa 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -136,11 +136,23 @@ const AskParams = z.object({ from: OptionalString }) -const ResetParams = z.object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean -}) +const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ @@ -582,8 +594,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ db.resetMessages() return { reset: 'messages' } } - db.resetAll() - return { reset: 'all' } + throw new Error('Invalid reset scope') } }) ] diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index b5eb04bf28d..d568d439409 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -18,6 +18,13 @@ export class SshConnectionManager { this.callbacks = callbacks } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + for (const connection of this.connections.values()) { + connection.setCallbacks(callbacks) + } + } + async connect(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 259e094bc9f..989010712fe 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -70,6 +70,10 @@ export class SshConnection { return { ...this.target } } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + } + // Why: exposes whether a passphrase/password is already cached in-memory for // this connection. Used by ssh:needsPassphrasePrompt so callers can decide // whether a manual-reconnect will prompt or go through silently. Without this, diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index b06ab52ed4e..cda490c8580 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -64,6 +64,7 @@ export async function readFileViaStream( let expectedSeq = 0 let receivedChunks = 0 let totalChunks = 0 + let bytesReceived = 0 let settled = false // Why: chunk/end/error frames may arrive in the same dispatch tick as the @@ -128,10 +129,13 @@ export async function readFileViaStream( } const offset = seq * STREAM_CHUNK_SIZE const decoded = Buffer.from(data, 'base64') - if (offset + decoded.length > totalSize) { + // Why: a short chunk would leave the pre-allocated buffer zero-filled and + // resolve as silently-corrupt data; validate each chunk's exact length. + const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) + if (decoded.length !== expectedLength) { fail( new StreamProtocolError( - `Chunk overflows declared totalSize: offset=${offset} len=${decoded.length} total=${totalSize}` + `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` ) ) return @@ -143,6 +147,7 @@ export async function readFileViaStream( decoded.copy(buffer, offset) expectedSeq += 1 receivedChunks += 1 + bytesReceived += decoded.length } const handleEnd = (params: Record): void => { @@ -161,6 +166,16 @@ export async function readFileViaStream( ) return } + // Why: redundant given the per-chunk length + count checks, but kept as a + // last-line invariant guard; never resolve with fewer bytes than declared. + if (bytesReceived !== totalSize) { + fail( + new StreamProtocolError( + `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` + ) + ) + return + } if (!buffer) { fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) return diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 9da2918e88a..73b72b8647b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -110,6 +110,20 @@ export class SshRelaySession { ) => void ) {} + refreshEnvironment( + getMainWindow: () => BrowserWindow | null, + store: Store, + portForwardManager: SshPortForwardManager, + runtime?: OrcaRuntimeService, + onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void + ): void { + this.getMainWindow = getMainWindow + this.store = store + this.portForwardManager = portForwardManager + this.runtime = runtime + this.onDetectedPortsChanged = onDetectedPortsChanged + } + setOnRelayLost(cb: (targetId: string) => void): void { this._onRelayLost = cb } @@ -783,10 +797,9 @@ export class SshRelaySession { } private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { - const getWin = this.getMainWindow ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:data', { ...payload, @@ -795,7 +808,7 @@ export class SshRelaySession { } }) ptyProvider.onReplay((payload) => { - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', payload) } @@ -806,7 +819,7 @@ export class SshRelaySession { deletePtyOwnership(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b380ea65507..d0a63a30cc3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -582,6 +582,9 @@ export type AppApi = { * by settings panes that need a full restart to apply changes (e.g. the * terminal-window blur setting in TerminalWindowSection). */ relaunch: () => Promise + /** Restarts Orca through the normal quit pipeline so daemon-backed terminal + * sessions survive and can reattach after the new process starts. */ + restart: () => Promise /** Reloads the current app renderer through main so expected renderer * teardown can be classified before Electron emits process-gone events. */ reload: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d4fe7af50ea..d40637e19f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -140,6 +140,8 @@ import { type EditorSaveDirtyFilesDetail } from '../shared/editor-save-events' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -169,6 +171,62 @@ type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] let nativeFileDropListenerRegistered = false +type AppRestartPrepOptions = { + startedEventName: string + abortedEventName: string + continueOnSaveFailure: boolean + saveFailureLogPrefix: string +} + +function requestDirtyEditorFileSave(): Promise { + return new Promise((resolve, reject) => { + let claimed = false + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { + detail: { + claim: () => { + claimed = true + }, + resolve, + reject: (message) => { + reject(new Error(message)) + } + } + }) + ) + + // Why: restart paths can run when no editor surface is mounted. When + // nothing claims the request there are no in-memory editor buffers to + // flush, so proceed with the normal shutdown path immediately. + if (!claimed) { + resolve() + } + }) +} + +async function prepareRendererForAppRestart({ + startedEventName, + abortedEventName, + continueOnSaveFailure, + saveFailureLogPrefix +}: AppRestartPrepOptions): Promise { + window.dispatchEvent(new Event(startedEventName)) + + try { + await requestDirtyEditorFileSave() + } catch (error) { + if (!continueOnSaveFailure) { + window.dispatchEvent(new Event(abortedEventName)) + throw error + } + console.warn(saveFailureLogPrefix, error) + } + + // Dispatch beforeunload now so terminal buffers are captured while panes are + // still mounted; update installs later bypass the ordinary close sequence. + window.dispatchEvent(new Event('beforeunload')) +} + const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { for (const callback of Array.from(nativeFileDropCallbacks)) { callback(data) @@ -358,6 +416,20 @@ const api = { getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), + restart: async (): Promise => { + await prepareRendererForAppRestart({ + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + continueOnSaveFailure: false, + saveFailureLogPrefix: '[app-restart] Saving dirty files before restart failed:' + }) + try { + return await ipcRenderer.invoke('app:restart') + } catch (error) { + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + throw error + } + }, reload: (): Promise => ipcRenderer.invoke('app:reload'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -1933,53 +2005,16 @@ const api = { download: (): Promise => ipcRenderer.invoke('updater:download'), dismissNudge: (): Promise => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { - // Why: quitAndInstall closes the BrowserWindow directly from the main - // process. Renderer beforeunload guards treat that like a normal window - // close unless we mark the updater path explicitly, and #300 introduced - // longer-lived editor dirty/autosave state that can otherwise veto the - // restart even after the update payload has been downloaded. - window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) - - // Why: we wrap the save attempt in try/catch so that a save failure - // (e.g., unsupported dirty files or a write error) never silently - // prevents the update from installing. The user already clicked - // "install update" — proceeding with the restart is better than - // leaving them stuck with no feedback. - try { - await new Promise((resolve, reject) => { - let claimed = false - window.dispatchEvent( - new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { - detail: { - claim: () => { - claimed = true - }, - resolve, - reject: (message) => { - reject(new Error(message)) - } - } - }) - ) - - // Why: updater installs can run when no editor surface is mounted. - // When nothing claims the request there are no in-memory editor buffers - // to flush, so proceed with the normal shutdown path immediately. - if (!claimed) { - resolve() - } - }) - } catch (error) { - console.warn( - '[updater] Saving dirty files before quit failed; proceeding with install anyway:', - error - ) - } - - // Dispatch beforeunload to trigger terminal buffer capture before the - // update process bypasses the normal window close sequence (quitAndInstall - // removes close listeners, preventing beforeunload from firing naturally). - window.dispatchEvent(new Event('beforeunload')) + // Why: update installs must proceed even when a dirty-file auto-save + // fails; otherwise a downloaded update can get stuck behind hidden editor + // state. Manual app restart uses the same prep but aborts on save failure. + await prepareRendererForAppRestart({ + startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + continueOnSaveFailure: true, + saveFailureLogPrefix: + '[updater] Saving dirty files before quit failed; proceeding with install anyway:' + }) try { return await ipcRenderer.invoke('updater:quitAndInstall') } catch (error) { diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index d8f2f9077ef..ceb3432e4a4 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -52,6 +52,15 @@ export type StreamMetadata = { empty?: boolean } +type StreamChunkReader = { + read( + buffer: Buffer, + offset: number, + length: number, + position: number + ): Promise<{ bytesRead: number }> +} + export async function readRelayFileStreamMetadata( filePath: string, dispatcher: RelayDispatcher, @@ -142,11 +151,11 @@ async function pumpChunks( break } const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - const { bytesRead } = await entry.handle.read(buffer, 0, want, offset) - if (bytesRead === 0) { + const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) + if (bytesRead !== want) { endReason = 'error' errorCode = 'ESTREAMTRUNCATED' - errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset}` + errorMessage = `File truncated mid-stream: expected ${totalSize}, got ${offset + bytesRead}` break } if (context.isStale()) { @@ -202,6 +211,30 @@ async function pumpChunks( } } +// Why: fs.read() may return fewer bytes than requested before EOF. Fill each +// protocol chunk so strict clients reject corruption, not valid short reads. +async function readFullStreamChunk( + handle: StreamChunkReader, + buffer: Buffer, + length: number, + offset: number +): Promise { + let totalRead = 0 + while (totalRead < length) { + const { bytesRead } = await handle.read( + buffer, + totalRead, + length - totalRead, + offset + totalRead + ) + if (bytesRead === 0) { + break + } + totalRead += bytesRead + } + return totalRead +} + export function isTooManyStreamsError(err: unknown): err is TooManyStreamsError { return err instanceof Error && (err as { code?: number }).code === RelayErrorCode.TooManyStreams } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index f9d784905f7..d1f815cf77c 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' import type { RelayDispatcher } from './dispatcher' +import { STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'fs/promises' import * as path from 'path' import { mkdtempSync, writeFileSync } from 'fs' @@ -134,6 +135,50 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) + it('fills protocol chunks when fs.read returns short before EOF', async () => { + const filePath = path.join(tmpDir, 'short-read.png') + const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) + writeFileSync(filePath, content) + + const sampleHandle = await fs.open(filePath, 'r') + const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as { + read: typeof sampleHandle.read + } + await sampleHandle.close() + + type PositionedRead = ( + this: typeof sampleHandle, + buffer: Buffer, + offset: number | null, + length: number | null, + position: number | null + ) => Promise<{ bytesRead: number; buffer: Buffer }> + const originalRead = fileHandlePrototype.read as unknown as PositionedRead + const readSpy = vi.spyOn(fileHandlePrototype, 'read').mockImplementation(async function ( + this: typeof sampleHandle, + buffer: Buffer, + offset: number | null = 0, + length: number | null = buffer.byteLength, + position: number | null = null + ) { + const readLength = position === 0 && length !== null ? Math.min(length, 5) : length + return originalRead.call(this, buffer, offset, readLength, position) + } as typeof sampleHandle.read) + try { + await dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) + await waitFor(() => collectStream(dispatcher).end !== null) + } finally { + readSpy.mockRestore() + } + + const { chunks, end, err } = collectStream(dispatcher) + expect(err).toBeNull() + expect(end).not.toBeNull() + expect(chunks.map((c) => Buffer.from(c.data, 'base64').length)).toEqual([STREAM_CHUNK_SIZE, 17]) + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + it('returns empty:true for 0-byte files without opening a handle', async () => { const filePath = path.join(tmpDir, 'empty.txt') writeFileSync(filePath, '') diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 4e9c48a7b0c..2670569cd6c 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -132,6 +132,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.createDir') expect(methods).toContain('fs.createDirNoClobber') expect(methods).toContain('fs.rename') + expect(methods).toContain('fs.renameNoClobber') expect(methods).toContain('fs.copy') expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') @@ -362,6 +363,43 @@ describe('FsHandler', () => { expect(content).toBe('content') }) + it('rename preserves raw fs.rename overwrite semantics', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'existing.txt') + writeFileSync(oldPath, 'new') + writeFileSync(newPath, 'keep') + + await dispatcher.callRequest('fs.rename', { oldPath, newPath }) + + expect(await fs.readFile(newPath, 'utf-8')).toBe('new') + await expect(fs.access(oldPath)).rejects.toThrow() + }) + + it('renameNoClobber moves files when destination is available', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'new.txt') + writeFileSync(oldPath, 'content') + + await dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) + + await expect(fs.access(oldPath)).rejects.toThrow() + expect(await fs.readFile(newPath, 'utf-8')).toBe('content') + }) + + it('renameNoClobber does not overwrite an existing destination', async () => { + const oldPath = path.join(tmpDir, 'old.txt') + const newPath = path.join(tmpDir, 'existing.txt') + writeFileSync(oldPath, 'new') + writeFileSync(newPath, 'keep') + + await expect( + dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath }) + ).rejects.toThrow() + + expect(await fs.readFile(newPath, 'utf-8')).toBe('keep') + expect(await fs.readFile(oldPath, 'utf-8')).toBe('new') + }) + it('copy duplicates files', async () => { const src = path.join(tmpDir, 'src.txt') const dst = path.join(tmpDir, 'dst.txt') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 5248ec6b4cb..3760a6b5a87 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -23,6 +23,7 @@ import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler- import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' +import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' type WatchState = { rootPath: string @@ -84,6 +85,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p)) this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p)) this.dispatcher.onRequest('fs.rename', (p) => this.rename(p)) + this.dispatcher.onRequest('fs.renameNoClobber', (p) => this.renameNoClobber(p)) this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) @@ -208,6 +210,15 @@ export class FsHandler { await rename(oldPath, newPath) } + private async renameNoClobber(params: Record) { + const oldPath = expandTilde(params.oldPath as string) + const newPath = expandTilde(params.newPath as string) + // Why: user-facing file renames must not inherit fs.rename's overwrite + // behavior; keep the guard inside the relay so SSH checks the remote FS. + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) + await rename(oldPath, newPath) + } + private async copy(params: Record) { const source = expandTilde(params.source as string) const destination = expandTilde(params.destination as string) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 319acba8322..e46f62aa899 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -7,7 +7,7 @@ import { GitHandler } from './git-handler' import { RelayContext } from './context' import * as fs from 'fs/promises' import * as path from 'path' -import { mkdtempSync, mkdirSync, writeFileSync } from 'fs' +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { execFileSync } from 'child_process' import { @@ -453,6 +453,59 @@ describe('GitHandler', () => { }) ).rejects.toThrow('outside the worktree') }) + + it('rejects untracked child paths through symlinked parents', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) + const outsideFile = path.join(outsideDir, 'keep.txt') + writeFileSync(outsideFile, 'outside') + symlinkSync( + outsideDir, + path.join(tmpDir, 'link'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + try { + await expect( + dispatcher.callRequest('git.discard', { + worktreePath: tmpDir, + filePath: 'link/keep.txt' + }) + ).rejects.toThrow('outside the worktree') + await expect(fs.access(outsideFile)).resolves.toBeUndefined() + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { + gitInit(tmpDir) + gitCommit(tmpDir, 'initial') + const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) + const outsideFile = path.join(outsideDir, 'keep.txt') + const untrackedFile = path.join(tmpDir, 'new.txt') + writeFileSync(outsideFile, 'outside') + writeFileSync(untrackedFile, 'untracked') + symlinkSync( + outsideDir, + path.join(tmpDir, 'link'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + try { + await expect( + dispatcher.callRequest('git.bulkDiscard', { + worktreePath: tmpDir, + filePaths: ['new.txt', 'link/keep.txt'] + }) + ).rejects.toThrow('outside the worktree') + await expect(fs.access(outsideFile)).resolves.toBeUndefined() + await expect(fs.access(untrackedFile)).resolves.toBeUndefined() + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }) + } + }) }) describe('conflictOperation', () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 65e7c424d97..9e65119465b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -2,7 +2,6 @@ protocol surface so local and SSH git behavior stay in one dispatch table. */ import { execFile } from 'child_process' import { promisify } from 'util' -import { rm } from 'fs/promises' import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' @@ -36,6 +35,10 @@ import { } from '../shared/git-effective-upstream' import { loadGitHistoryFromExecutor } from '../shared/git-history' import { buildRelayCommandEnv } from './relay-command-env' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../shared/git-discard-path-safety' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -222,7 +225,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string - const resolved = this.assertInWorktree(worktreePath, filePath) + this.assertInWorktree(worktreePath, filePath) let tracked = false try { @@ -232,9 +235,14 @@ export class GitHandler { // untracked } - await (tracked - ? this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) - : rm(resolved, { force: true, recursive: true })) + if (tracked) { + await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + return + } + + await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => + this.cleanUntrackedPaths(worktreePath, [targetPath]) + ) } private async bulkDiscard(params: Record) { @@ -261,19 +269,29 @@ export class GitHandler { const untrackedPaths = filePaths.filter( (filePath) => !this.isTrackedPathSpec(filePath, trackedPathSpecs) ) - - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) - } - - await Promise.all( - untrackedPaths.map((filePath) => - rm(path.resolve(worktreePath, filePath), { force: true, recursive: true }) - ) + await removeSafeUntrackedDiscardTargets( + worktreePath, + untrackedPaths, + (targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths), + async () => { + for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { + const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) + await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) + } + } ) } + private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + if (chunk.length > 0) { + // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. + await this.git(['clean', '-ffdx', '--', ...chunk], worktreePath) + } + } + } + private async conflictOperation(params: Record) { const worktreePath = params.worktreePath as string return detectConflictOperation(worktreePath) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 149c236f515..80389a9952a 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -155,6 +155,22 @@ animation: pane-focus-rim-flash 1.5s ease-out forwards; } +.pane[data-terminal-attention] .xterm-container { + box-shadow: none; +} + +.pane[data-terminal-attention] .xterm-container::after { + content: ''; + position: absolute; + inset: 0 4px 4px 0; + z-index: 5; + pointer-events: none; + border-radius: 2px; + box-shadow: + inset 0 0 0 1.5px color-mix(in srgb, var(--terminal-pane-locate) 64%, transparent), + inset 0 0 0 3px color-mix(in srgb, var(--terminal-pane-locate) 18%, transparent); +} + @keyframes pane-focus-rim-flash { 0% { opacity: 1; diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 192232dca89..08f454c4f0a 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -25,6 +25,7 @@ import { Copy, ExternalLink, FileText, + FolderKanban, GitMerge, GitPullRequest, GitPullRequestClosed, @@ -34,6 +35,7 @@ import { MessageSquarePlus, PanelLeftOpen, Pencil, + Plus, RefreshCw, Send, UndoDot, @@ -98,6 +100,7 @@ import { type PRCommentGroup } from '@/lib/pr-comment-groups' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' @@ -108,6 +111,10 @@ import { import { AGENT_CATALOG } from '@/lib/agent-catalog' import { getConnectionId } from '@/lib/connection-context' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + findGithubPrWorkspaceAttachment, + getGithubPrWorkspaceAttachmentLabel +} from '@/lib/github-pr-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -125,8 +132,7 @@ import type { PRCheckDetail, PRCheckRunDetails, PRComment, - TuiAgent, - Worktree + TuiAgent } from '../../../shared/types' // Why: the GH item dialog can be opened from any work-item list surface and @@ -3355,19 +3361,6 @@ function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[ ].join('\n') } -function findWorkspaceAttachedToPR( - worktrees: Worktree[], - repoId: string, - prNumber: number -): Worktree | null { - return ( - worktrees.find( - (worktree) => - worktree.repoId === repoId && worktree.linkedPR === prNumber && !worktree.isArchived - ) ?? null - ) -} - function pickDefaultAgent( defaultAgent: TuiAgent | 'blank' | null | undefined, detectedAgents: TuiAgent[] @@ -3533,7 +3526,7 @@ function ChecksTab({ try { const prompt = buildFixBrokenChecksPrompt(item, list) const store = useAppStore.getState() - const attachedWorkspace = findWorkspaceAttachedToPR( + const attachedWorkspace = findGithubPrWorkspaceAttachment( store.allWorktrees(), targetRepoId, item.number @@ -4955,6 +4948,17 @@ export default function PullRequestPage({ const workItemLabels = workItem?.labels const workItemType = workItem?.type const effectiveRepoId = repoId ?? workItem?.repoId ?? null + const allWorktrees = useAllWorktrees() + const attachedWorkspace = useMemo( + () => + workItem?.type === 'pr' + ? findGithubPrWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number) + : null, + [allWorktrees, effectiveRepoId, workItem] + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace) + : null // Why: the cache key has to include the issue source preference so a user // toggling between origin/upstream for the same issue number doesn't read @@ -4995,6 +4999,39 @@ export default function PullRequestPage({ setTab(nextTab) }, [workItemId, workItemType, initialTab]) + const handleUseWorkItem = useCallback((): void => { + if (!workItem) { + return + } + const targetRepoId = effectiveRepoId + onUse( + targetRepoId && targetRepoId !== workItem.repoId + ? { ...workItem, repoId: targetRepoId } + : workItem + ) + }, [effectiveRepoId, onUse, workItem]) + + const handleOpenOrUsePR = useCallback((): void => { + if (!workItem) { + return + } + const targetRepoId = effectiveRepoId + const currentAttached = findGithubPrWorkspaceAttachment( + useAppStore.getState().allWorktrees(), + targetRepoId, + workItem.number + ) + if (!currentAttached) { + handleUseWorkItem() + return + } + + const result = activateAndRevealWorktree(currentAttached.id) + if (result === false) { + toast.error('Unable to open the workspace attached to this pull request.') + } + }, [effectiveRepoId, handleUseWorkItem, workItem]) + // Why: track comments added optimistically before the detail fetch resolves // so they can be merged into the fetch result instead of being overwritten. const optimisticCommentsRef = useRef([]) @@ -5413,16 +5450,39 @@ export default function PullRequestPage({
{/* Why: Orca's signature affordance — keep this primary so it stands out against GitHub's familiar surface. */} - + + + + + + + + + {attachedWorkspace ? ( + + + Start new workspace + + ) : null} + window.api.shell.openUrl(workItem.url)}> + + Open on GitHub + + +
@@ -5457,6 +5517,12 @@ export default function PullRequestPage({ · updated {formatRelativeTime(workItem.updatedAt)} + {attachedWorkspaceLabel ? ( + + + {attachedWorkspaceLabel} + + ) : null}
diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index be5719b9a27..b892df507ea 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -15,46 +15,7 @@ import { CommandEmpty, CommandItem } from '@/components/ui/command' - -/** - * Simple fuzzy match: checks if all characters in the query appear in order - * within the target string (case-insensitive). Returns a score (lower = better) - * or -1 if no match. - */ -function fuzzyMatch(query: string, target: string): number { - const q = query.toLowerCase() - const t = target.toLowerCase() - let qi = 0 - let score = 0 - let lastMatchIdx = -1 - - for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) { - // Bonus for consecutive matches - const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 - score += gap - // Bonus for matching after separator (/ or .) - if (ti > 0 && (t[ti - 1] === '/' || t[ti - 1] === '.' || t[ti - 1] === '-')) { - score -= 5 // reward - } - lastMatchIdx = ti - qi++ - } - } - - if (qi < q.length) { - return -1 // not all chars matched - } - - // Prefer matches where query appears in the filename (last segment) - const lastSlash = target.lastIndexOf('/') - const filename = target.slice(lastSlash + 1).toLowerCase() - if (filename.includes(q)) { - score -= 100 // strong reward for filename match - } - - return score -} +import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search' /** * Parses the install-ripgrep guidance message produced by the relay's @@ -296,23 +257,11 @@ export default function QuickOpen(): React.JSX.Element | null { } }, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey]) - // Filter files by fuzzy match - const filtered = useMemo(() => { - const normalizedQuery = deferredQuery.trim() - if (!normalizedQuery) { - // Show first 50 files when no query - return files.slice(0, 50).map((f) => ({ path: f, score: 0 })) - } - const results: { path: string; score: number }[] = [] - for (const f of files) { - const score = fuzzyMatch(normalizedQuery, f) - if (score !== -1) { - results.push({ path: f, score }) - } - } - results.sort((a, b) => a.score - b.score) - return results.slice(0, 50) - }, [deferredQuery, files]) + const indexedFiles = useMemo(() => prepareQuickOpenFiles(files), [files]) + const filtered = useMemo( + () => rankQuickOpenFiles(deferredQuery, indexedFiles), + [deferredQuery, indexedFiles] + ) const handleSelect = useCallback( (relativePath: string) => { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index ce180722810..f0b16493c4f 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -42,9 +42,10 @@ import { import { toast } from 'sonner' import { useAppStore } from '@/store' -import { useRepoMap } from '@/store/selectors' +import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' import { Select, @@ -98,6 +99,11 @@ import { } from '../../../shared/linear-links' import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links' +import { + findGithubPrWorkspaceAttachment, + getGithubPrWorkspaceAttachmentLabel +} from '@/lib/github-pr-workspace-attachment' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog' import PullRequestPage from '@/components/PullRequestPage' @@ -1957,6 +1963,7 @@ export default function TaskPage(): React.JSX.Element { const activeModal = useAppStore((s) => s.activeModal) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() + const allWorktrees = useAllWorktrees() const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos) @@ -3797,6 +3804,26 @@ export default function TaskPage(): React.JSX.Element { [openComposerForItem] ) + const handleOpenOrUseGitHubPR = useCallback( + (item: GitHubWorkItem): void => { + const currentAttached = findGithubPrWorkspaceAttachment( + useAppStore.getState().allWorktrees(), + item.repoId, + item.number + ) + if (!currentAttached) { + handleUseWorkItem(item) + return + } + + const result = activateAndRevealWorktree(currentAttached.id) + if (result === false) { + toast.error('Unable to open the workspace attached to this pull request.') + } + }, + [handleUseWorkItem] + ) + const openComposerForGitLabItem = useCallback( (item: GitLabWorkItem): void => { const linkedWorkItem: LinkedWorkItemSummary = { @@ -5144,6 +5171,13 @@ export default function TaskPage(): React.JSX.Element { {!showGitHubTaskSkeletons && filteredWorkItems.map((item) => { const itemRepo = repoMap.get(item.repoId) ?? null + const attachedWorkspace = + item.type === 'pr' + ? findGithubPrWorkspaceAttachment(allWorktrees, item.repoId, item.number) + : null + const attachedWorkspaceLabel = attachedWorkspace + ? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace) + : null return ( // Why: the row is a clickable container rather than a // - - - + + + + + event.stopPropagation()} + > + {attachedWorkspace ? ( + handleUseWorkItem(item)}> + + Start new workspace + + ) : null} + window.api.shell.openUrl(item.url)} + > + + Open in browser + + + + ) : ( + + )} + {item.type !== 'pr' ? ( + + + + + e.stopPropagation()} - className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground" - aria-label="More actions" > - - - - e.stopPropagation()}> - window.api.shell.openUrl(item.url)} - > - - Open in browser - - - + window.api.shell.openUrl(item.url)} + > + + Open in browser + + + + ) : null} ) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f10e94a9e5a..c891fdd9549 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -31,7 +31,7 @@ import { type EditorRequestFileCloseDetail, requestEditorSaveQuiesce } from './editor/editor-autosave' -import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' +import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' import type { TabGroupLayoutNode } from '../../../shared/types' import BrowserPane from './browser-pane/BrowserPane' @@ -1330,10 +1330,9 @@ function Terminal(): React.JSX.Element | null { // Warn on window close if there are unsaved editor files useEffect(() => { const handler = (e: BeforeUnloadEvent): void => { - // Why: updater restarts intentionally close the app even if a hidden - // editor tab still reports dirty. Let ShipIt replace the bundle instead - // of vetoing quitAndInstall and leaving the old version running. - if (isUpdaterQuitAndInstallInProgress()) { + // Why: update/manual restarts pre-save dirty tabs and then intentionally + // close the app. Do not let stale dirty flags veto the relaunch path. + if (isIntentionalAppRestartInProgress()) { return } const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty) @@ -1350,7 +1349,7 @@ function Terminal(): React.JSX.Element | null { // close here. Explicit destructive terminal actions keep their own confirms. useEffect(() => { return window.api.ui.onWindowCloseRequested(({ isQuitting }) => { - if (isUpdaterQuitAndInstallInProgress()) { + if (isIntentionalAppRestartInProgress()) { window.api.ui.confirmWindowClose() return } diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 1e553ed4eb5..1df03df78c9 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Columns2, Copy, @@ -7,6 +7,7 @@ import { FileText, ListTree, MoreHorizontal, + Pencil, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' @@ -19,6 +20,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab' import { useShortcutLabel } from '@/hooks/useShortcutLabel' @@ -30,6 +32,7 @@ import type { EditorToggleValue } from './EditorViewToggle' import type { EditorHeaderOpenFileState } from './editor-header' import { getEditorHeaderCopyState } from './editor-header' import { DiffNotesSendMenu } from './DiffNotesSendMenu' +import { useEditorHeaderFileRename } from './editor-header-file-rename' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -104,7 +107,17 @@ export function EditorPanelHeader({ }: EditorPanelHeaderProps): React.JSX.Element { const [pathMenuOpen, setPathMenuOpen] = useState(false) const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 }) + const skipMenuFocusRestoreRef = useRef(false) const headerCopyState = getEditorHeaderCopyState(activeFile) + const { + canRename, + currentFileName, + isRenaming, + renameInputRef, + openRenameInput, + commitRename, + cancelRename + } = useEditorHeaderFileRename(activeFile) const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId)) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId]) const fileDiffComments = useMemo( @@ -131,14 +144,43 @@ export function EditorPanelHeader({ setPathMenuOpen(true) }} > - + {isRenaming ? ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + commitRename() + } else if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + cancelRename() + } + }} + onBlur={commitRename} + /> + ) : ( + + )} - + { + if (!skipMenuFocusRestoreRef.current) { + return + } + skipMenuFocusRestoreRef.current = false + event.preventDefault() + }} + > + { + skipMenuFocusRestoreRef.current = true + openRenameInput() + }} + > + + Rename + + { void window.api.ui.writeClipboardText(activeFile.filePath) diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts new file mode 100644 index 00000000000..51af400c258 --- /dev/null +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from 'react' +import type { OpenFile } from '@/store/slices/editor' +import { useWorktreeById } from '@/store/selectors' +import { basename } from '@/lib/path' +import { renameFileOnDisk } from '@/lib/rename-file' +import { getUntitledFileRoot } from './untitled-file-rename-path' + +type EditorHeaderFileRenameState = { + canRename: boolean + currentFileName: string + isRenaming: boolean + renameInputRef: React.RefObject + openRenameInput: () => void + commitRename: () => void + cancelRename: () => void +} + +export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFileRenameState { + const worktree = useWorktreeById(activeFile.worktreeId) + const [isRenaming, setIsRenaming] = useState(false) + const renameInputRef = useRef(null) + const renameCancelledRef = useRef(false) + const currentFileName = basename(activeFile.filePath) + const canRename = + activeFile.mode === 'edit' && !activeFile.diffSource && !activeFile.conflict && !isRenaming + + const openRenameInput = (): void => { + if (!canRename) { + return + } + renameCancelledRef.current = false + setIsRenaming(true) + } + + const commitRename = (): void => { + if (renameCancelledRef.current) { + renameCancelledRef.current = false + setIsRenaming(false) + return + } + const input = renameInputRef.current + if (!input) { + setIsRenaming(false) + return + } + const newName = input.value.trim() + setIsRenaming(false) + if (!newName || newName === currentFileName) { + return + } + const worktreePath = getUntitledFileRoot(activeFile, worktree?.path ?? null) + void renameFileOnDisk({ + oldPath: activeFile.filePath, + newName, + worktreeId: activeFile.worktreeId, + worktreePath + }) + } + + const cancelRename = (): void => { + renameCancelledRef.current = true + setIsRenaming(false) + } + + useEffect(() => { + if (!isRenaming) { + return + } + const raf = requestAnimationFrame(() => { + const el = renameInputRef.current + if (!el) { + return + } + el.focus() + const dotIndex = currentFileName.lastIndexOf('.') + if (dotIndex > 0) { + el.setSelectionRange(0, dotIndex) + } else { + el.select() + } + }) + return () => cancelAnimationFrame(raf) + }, [currentFileName, isRenaming]) + + return { + canRename, + currentFileName, + isRenaming, + renameInputRef, + openRenameInput, + commitRename, + cancelRename + } +} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx index 196a5094fb1..31d62a23813 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { PanelsTopLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -8,22 +8,40 @@ import { clampFloatingTerminalTriggerPosition, getDefaultFloatingTerminalTriggerPosition, parseFloatingTerminalTriggerPosition, - type FloatingTerminalTriggerPosition + resolveFloatingTerminalTriggerPosition, + shouldReconcileFloatingTerminalTriggerPosition, + type FloatingTerminalTriggerPosition, + type FloatingTerminalTriggerPositionSource } from './floating-terminal-trigger-position' // Why: v2 resets older parked positions that sat too low over bottom bars. const FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY = 'orca-floating-terminal-trigger-position-v2' const FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD = 4 -function readInitialTriggerPosition(): FloatingTerminalTriggerPosition { +type FloatingTerminalTriggerPositionState = { + position: FloatingTerminalTriggerPosition + source: FloatingTerminalTriggerPositionSource +} + +function readInitialTriggerPosition(): FloatingTerminalTriggerPositionState { if (typeof window === 'undefined') { - return getDefaultFloatingTerminalTriggerPosition() + return { + position: getDefaultFloatingTerminalTriggerPosition(), + source: 'default' + } } - return ( - parseFloatingTerminalTriggerPosition( - window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY) - ) ?? getDefaultFloatingTerminalTriggerPosition() + const persistedPosition = parseFloatingTerminalTriggerPosition( + window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY) ) + return persistedPosition + ? { + position: persistedPosition, + source: 'user' + } + : { + position: getDefaultFloatingTerminalTriggerPosition(), + source: 'default' + } } function persistTriggerPosition(position: FloatingTerminalTriggerPosition): void { @@ -41,7 +59,14 @@ export function FloatingTerminalToggleButton({ onToggle: () => void }): React.JSX.Element { const shortcutLabel = useShortcutLabel('floatingTerminal.toggle') - const [position, setPosition] = useState(readInitialTriggerPosition) + const initialPositionState = useRef(null) + if (initialPositionState.current === null) { + initialPositionState.current = readInitialTriggerPosition() + } + const positionSourceRef = useRef( + initialPositionState.current.source + ) + const [position, setPosition] = useState(initialPositionState.current.position) const dragRef = useRef<{ pointerId: number startX: number @@ -53,22 +78,38 @@ export function FloatingTerminalToggleButton({ const suppressClickRef = useRef(false) const updatePosition = useCallback((nextPosition: FloatingTerminalTriggerPosition): void => { + positionSourceRef.current = 'user' const clamped = clampFloatingTerminalTriggerPosition(nextPosition) setPosition(clamped) persistTriggerPosition(clamped) }, []) + const reconcilePosition = useCallback((): void => { + setPosition((current) => { + if (!shouldReconcileFloatingTerminalTriggerPosition(positionSourceRef.current)) { + // Why: a startup-size viewport must not overwrite an intentional saved + // drag position with the safety clamp before the renderer finishes sizing. + return current + } + const next = resolveFloatingTerminalTriggerPosition(current, positionSourceRef.current) + if (positionSourceRef.current === 'user') { + persistTriggerPosition(next) + } + return next + }) + }, []) + + useLayoutEffect(() => { + // Why: Electron can mount before the renderer has final viewport dimensions; + // default positions should re-anchor to bottom-right before first paint. + reconcilePosition() + }, [reconcilePosition]) + useEffect(() => { - const handleResize = (): void => { - setPosition((current) => { - const clamped = clampFloatingTerminalTriggerPosition(current) - persistTriggerPosition(clamped) - return clamped - }) - } + const handleResize = (): void => reconcilePosition() window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) - }, []) + }, [reconcilePosition]) const handlePointerDown = (event: React.PointerEvent): void => { if (event.button !== 0) { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts index 6917a2323c9..609912ded19 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { clampFloatingTerminalTriggerPosition, getDefaultFloatingTerminalTriggerPosition, - parseFloatingTerminalTriggerPosition + hasUsableFloatingTerminalTriggerViewport, + parseFloatingTerminalTriggerPosition, + resolveFloatingTerminalTriggerPosition, + shouldReconcileFloatingTerminalTriggerPosition } from './floating-terminal-trigger-position' function stubViewport(width: number, height: number): void { @@ -32,10 +35,58 @@ describe('floating terminal trigger position', () => { }) }) + it('re-anchors default positions when startup viewport dimensions change', () => { + stubViewport(0, 0) + const initialPosition = getDefaultFloatingTerminalTriggerPosition() + + stubViewport(1200, 800) + + expect(resolveFloatingTerminalTriggerPosition(initialPosition, 'default')).toEqual({ + left: 1144, + top: 696 + }) + }) + + it('clamps user-placed positions instead of re-anchoring them on resize', () => { + stubViewport(1200, 800) + + expect(resolveFloatingTerminalTriggerPosition({ left: 900, top: 500 }, 'user')).toEqual({ + left: 900, + top: 500 + }) + }) + it('ignores malformed persisted positions', () => { stubViewport(640, 480) expect(parseFloatingTerminalTriggerPosition('not-json')).toBeNull() expect(parseFloatingTerminalTriggerPosition('{"left":"1","top":2}')).toBeNull() }) + + it('does not clamp parsed positions before the viewport is settled', () => { + stubViewport(0, 0) + + expect(parseFloatingTerminalTriggerPosition('{"left":900,"top":500}')).toEqual({ + left: 900, + top: 500 + }) + }) + + it('detects whether the viewport is usable for persisted user-position clamps', () => { + stubViewport(0, 0) + expect(hasUsableFloatingTerminalTriggerViewport()).toBe(false) + + stubViewport(1200, 800) + expect(hasUsableFloatingTerminalTriggerViewport()).toBe(true) + }) + + it('defers user-position reconciliation until the viewport is usable', () => { + stubViewport(0, 0) + + expect(shouldReconcileFloatingTerminalTriggerPosition('default')).toBe(true) + expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(false) + + stubViewport(1200, 800) + expect(shouldReconcileFloatingTerminalTriggerPosition('user')).toBe(true) + }) }) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts index 6f6c70e80dc..79113f37dd5 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts @@ -9,6 +9,8 @@ export type FloatingTerminalTriggerPosition = { top: number } +export type FloatingTerminalTriggerPositionSource = 'default' | 'user' + function getViewport(): { width: number; height: number } { return { width: typeof window === 'undefined' ? 1200 : window.innerWidth, @@ -40,6 +42,30 @@ export function clampFloatingTerminalTriggerPosition( } } +export function hasUsableFloatingTerminalTriggerViewport(): boolean { + const viewport = getViewport() + return ( + viewport.width >= TRIGGER_SIZE + DRAG_MARGIN * 2 && + viewport.height >= TRIGGER_SIZE + TITLEBAR_SAFE_TOP + DRAG_MARGIN + ) +} + +export function shouldReconcileFloatingTerminalTriggerPosition( + source: FloatingTerminalTriggerPositionSource +): boolean { + return source === 'default' || hasUsableFloatingTerminalTriggerViewport() +} + +export function resolveFloatingTerminalTriggerPosition( + position: FloatingTerminalTriggerPosition, + source: FloatingTerminalTriggerPositionSource +): FloatingTerminalTriggerPosition { + if (source === 'default') { + return getDefaultFloatingTerminalTriggerPosition() + } + return clampFloatingTerminalTriggerPosition(position) +} + export function parseFloatingTerminalTriggerPosition( serialized: string | null ): FloatingTerminalTriggerPosition | null { @@ -51,7 +77,7 @@ export function parseFloatingTerminalTriggerPosition( if (!isFiniteCoordinate(parsed.left) || !isFiniteCoordinate(parsed.top)) { return null } - return clampFloatingTerminalTriggerPosition({ left: parsed.left, top: parsed.top }) + return { left: parsed.left, top: parsed.top } } catch { return null } diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 0815877b9fa..71144e3b12d 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -42,7 +42,7 @@ export const PLATFORM_COPY: Record< android: { description: 'Scan with your Android camera to download the latest APK from GitHub Releases.', ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9' + url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10' } } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 96b9a9f6f73..f3356ce7ab1 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -199,6 +199,7 @@ export default function SmartWorkspaceNameField({ const [linearLoading, setLinearLoading] = useState(false) const [commandValue, setCommandValue] = useState('') const localInputRef = useRef(null) + const selectedSourceRef = useRef(null) const tabsListRef = useRef(null) const repoSlugCacheRef = useRef>(new Map()) const handledCrossRepoUrlRef = useRef(null) @@ -309,6 +310,9 @@ export default function SmartWorkspaceNameField({ useEffect(() => { if (selectedSource) { setOpen(false) + // Why: after Enter accepts a PR/issue row, the input unmounts. Keep the + // keyboard flow on the source field so the next Enter advances to Agent. + requestAnimationFrame(() => selectedSourceRef.current?.focus({ preventScroll: true })) } }, [selectedSource]) @@ -974,7 +978,25 @@ export default function SmartWorkspaceNameField({ // parent; without them the inner truncate's intrinsic // min-content (long PR title) propagates up and pushes the // dialog wider than its max-w. -
+
{ + if ( + event.currentTarget !== event.target || + event.key !== 'Enter' || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + event.preventDefault() + onPlainEnter?.() + }} + className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs outline-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50" + > {selectedSource.label} diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 3522bb113b7..09d963530b3 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -365,7 +365,7 @@ export default function OnboardingFlow({ busyLabel={busyLabel} onSkipToRepo={() => void flow.skipToRepo()} stepIndex={stepIndex} - onBack={flow.back} + onBack={flow.nestedScan ? flow.cancelNested : flow.back} showPrimary={currentStep.id !== 'repo' || flow.hasExistingProject} primaryBusy={shouldShowFooterBusy} primaryLabel={footerPrimaryLabel} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx index 45e07f1e6d3..f06c179c064 100644 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.test.tsx @@ -40,4 +40,23 @@ describe('RepoStep', () => { expect(html).toContain('Open a folder') expect(html).toContain('Clone a repo') }) + + it('disables nested import actions when no repositories are selected', () => { + const html = renderRepoStep({ + nestedScan: { + selectedPath: '/workspace/platform', + selectedPathKind: 'non_git_folder', + repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], + truncated: false, + timedOut: false, + durationMs: 4, + maxDepth: 3 + }, + nestedGroupName: 'platform' + }) + + expect(html).toContain('Import separately') + expect(html).toContain('Import as project group') + expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(2) + }) }) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 9b97c0268f7..9220b45cc55 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -10,6 +10,14 @@ import { buildAgentPickedPayload } from './agent-picked-payload' import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants' import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry, + type NestedRepoTelemetryRuntimeKind +} from '../../../../shared/nested-repo-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import type { GlobalSettings, @@ -135,6 +143,10 @@ export function useOnboardingFlow( const [nestedScan, setNestedScan] = useState(null) const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) const [nestedGroupName, setNestedGroupName] = useState('') + const [nestedAttemptId, setNestedAttemptId] = useState(null) + const [nestedRuntimeKind, setNestedRuntimeKind] = useState( + null + ) const [tourStarted, setTourStarted] = useState(false) const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(null) @@ -554,11 +566,24 @@ export function useOnboardingFlow( ] ) - const showNestedRepoReview = useCallback((scan: NestedRepoScanResult, selectedPath: string) => { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedGroupName(defaultProjectGroupNameForPath(selectedPath)) - }, []) + const showNestedRepoReview = useCallback( + ( + scan: NestedRepoScanResult, + selectedPath: string, + attemptId: string, + runtimeKind: NestedRepoTelemetryRuntimeKind + ) => { + setNestedScan(scan) + setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) + setNestedGroupName(defaultProjectGroupNameForPath(selectedPath)) + setNestedAttemptId(attemptId) + setNestedRuntimeKind(runtimeKind) + }, + [] + ) + + const onboardingNestedRepoRuntimeKind: NestedRepoTelemetryRuntimeKind = + settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' const startFeatureSetup = useCallback(async () => { if ( @@ -614,9 +639,19 @@ export function useOnboardingFlow( setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…') try { if (kind === 'git') { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'runtime', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, path) + showNestedRepoReview(scan, path, attemptId, 'runtime') return } } @@ -645,9 +680,19 @@ export function useOnboardingFlow( try { let result = await window.api.repos.add({ path }) if ('error' in result && result.error.includes('Not a valid git repository')) { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'local', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, path) + showNestedRepoReview(scan, path, attemptId, 'local') return } result = await window.api.repos.add({ path, kind: 'folder' }) @@ -676,11 +721,35 @@ export function useOnboardingFlow( const importNested = useCallback( async (mode: 'group' | 'separate') => { - if (!nestedScan || nestedSelectedPaths.size === 0 || busyLabel !== null) { + const attemptId = nestedAttemptId + if ( + !nestedScan || + !attemptId || + !shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: nestedSelectedPaths.size, + isBusy: busyLabel !== null + }) + ) { return } + const foundCount = nestedScan.repos.length + const selectedCount = nestedSelectedPaths.size + const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind setError(null) setBusyLabel('Importing repositories…') + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + action: mode === 'group' ? 'import_group' : 'import_separate', + foundCount, + selectedCount + }) + ) + let resultTracked = false try { const result = await importNestedRepos({ parentPath: nestedScan.selectedPath, @@ -688,6 +757,19 @@ export function useOnboardingFlow( projectPaths: [...nestedSelectedPaths], mode }) + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + mode, + foundCount, + selectedCount, + result + }) + ) + resultTracked = true const importedRepoIds = result?.projects .map((entry) => entry.projectId) @@ -701,6 +783,20 @@ export function useOnboardingFlow( } await completeRepo(projectId, true, 'open_folder') } catch (err) { + if (!resultTracked) { + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + mode, + foundCount, + selectedCount, + result: null + }) + ) + } setError(err instanceof Error ? err.message : String(err)) track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) } finally { @@ -713,22 +809,54 @@ export function useOnboardingFlow( fetchWorktrees, importNestedRepos, nestedGroupName, + nestedAttemptId, nestedScan, - nestedSelectedPaths + nestedSelectedPaths, + nestedRuntimeKind, + onboardingNestedRepoRuntimeKind ] ) + const trackNestedBackAndClear = useCallback(() => { + if (nestedScan && nestedAttemptId) { + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId: nestedAttemptId, + surface: 'onboarding', + runtimeKind: nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind, + action: 'back', + foundCount: nestedScan.repos.length, + selectedCount: nestedSelectedPaths.size + }) + ) + } + setNestedScan(null) + setNestedSelectedPaths(new Set()) + setNestedGroupName('') + setNestedAttemptId(null) + setNestedRuntimeKind(null) + setError(null) + }, [ + nestedAttemptId, + nestedRuntimeKind, + nestedScan, + nestedSelectedPaths.size, + onboardingNestedRepoRuntimeKind + ]) + // Why: lets the user back out of the nested-repo step in onboarding to // re-pick a folder/clone target. Mirrors the dialog's left-aligned Back. const cancelNested = useCallback(() => { if (busyLabel !== null) { return } - setNestedScan(null) - setNestedSelectedPaths(new Set()) - setNestedGroupName('') - setError(null) - }, [busyLabel]) + trackNestedBackAndClear() + }, [busyLabel, trackNestedBackAndClear]) + + const canImportNestedForTelemetry = useCallback((): boolean => { + return Boolean(nestedScan && nestedAttemptId && nestedSelectedPaths.size > 0) + }, [nestedAttemptId, nestedScan, nestedSelectedPaths.size]) const clone = useCallback(async () => { // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. @@ -885,11 +1013,22 @@ export function useOnboardingFlow( advancedVia }) if (closed) { + if (nestedScan) { + trackNestedBackAndClear() + } emitPendingTourOutcome() } return closed }, - [busyLabel, closeWith, consumeStepDurationMs, currentStep.stepNumber, emitPendingTourOutcome] + [ + busyLabel, + closeWith, + consumeStepDurationMs, + currentStep.stepNumber, + emitPendingTourOutcome, + nestedScan, + trackNestedBackAndClear + ] ) const startTour = useCallback(() => { @@ -1058,9 +1197,13 @@ export function useOnboardingFlow( ]) const back = useCallback(() => { + if (nestedScan) { + trackNestedBackAndClear() + return + } setTourStarted(false) setStepIndex((idx) => Math.max(idx - 1, 0)) - }, []) + }, [nestedScan, trackNestedBackAndClear]) // Why: returns the user to the "Take the tour" intro without leaving the // tour step. Don't emit the tour outcome here — re-entry must still let @@ -1070,10 +1213,16 @@ export function useOnboardingFlow( setTourStarted(false) }, []) - const jumpToStep = useCallback((idx: number) => { - setTourStarted(false) - setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1)) - }, []) + const jumpToStep = useCallback( + (idx: number) => { + if (nestedScan && idx !== stepIndex) { + trackNestedBackAndClear() + } + setTourStarted(false) + setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1)) + }, + [nestedScan, stepIndex, trackNestedBackAndClear] + ) return { settings, @@ -1098,6 +1247,7 @@ export function useOnboardingFlow( setNestedGroupName, importNested, cancelNested, + canImportNestedForTelemetry, hasExistingProject, serverPath, setServerPath, diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts new file mode 100644 index 00000000000..81c8ac65ea0 --- /dev/null +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + QUICK_OPEN_RESULT_LIMIT, + prepareQuickOpenFiles, + rankQuickOpenFiles +} from './quick-open-search' + +describe('quick-open-search', () => { + it('returns the first 50 paths with score 0 for an empty query', () => { + const files = Array.from({ length: 75 }, (_, index) => `src/file-${index}.ts`) + + expect(rankQuickOpenFiles('', prepareQuickOpenFiles(files))).toEqual( + files.slice(0, QUICK_OPEN_RESULT_LIMIT).map((path) => ({ path, score: 0 })) + ) + }) + + it('treats a whitespace-only query as empty', () => { + const files = ['src/a.ts', 'src/b.ts', 'src/c.ts'] + + expect(rankQuickOpenFiles(' ', prepareQuickOpenFiles(files))).toEqual([ + { path: 'src/a.ts', score: 0 }, + { path: 'src/b.ts', score: 0 }, + { path: 'src/c.ts', score: 0 } + ]) + }) + + it('prefers filename substring matches over path-only matches', () => { + const files = ['button-area/deep/path/file.tsx', 'src/components/Button.tsx'] + + expect( + rankQuickOpenFiles('button', prepareQuickOpenFiles(files)).map((item) => item.path) + ).toEqual(['src/components/Button.tsx', 'button-area/deep/path/file.tsx']) + }) + + it('keeps first-seen order for tie-heavy results at the limit boundary', () => { + const files = Array.from({ length: 10 }, (_, index) => `src/path-${index}.bin`) + + expect(rankQuickOpenFiles('s', prepareQuickOpenFiles(files), 4)).toEqual([ + { path: 'src/path-0.bin', score: 0 }, + { path: 'src/path-1.bin', score: 0 }, + { path: 'src/path-2.bin', score: 0 }, + { path: 'src/path-3.bin', score: 0 } + ]) + }) + + it('returns 50 top-ranked results from a 100k synthetic list', () => { + const fillerCount = 99_940 + const topCandidateCount = 60 + const files = [ + ...Array.from( + { length: fillerCount }, + (_, index) => `n-x-e-x-e-x-d-x-l-x-e/group-${index}/file.ts` + ), + ...Array.from({ length: topCandidateCount }, (_, index) => `bulk/special-${index}/needle.ts`) + ] + + const results = rankQuickOpenFiles('needle', prepareQuickOpenFiles(files)) + + expect(results).toHaveLength(QUICK_OPEN_RESULT_LIMIT) + expect(results.map((item) => item.path)).toEqual( + Array.from( + { length: QUICK_OPEN_RESULT_LIMIT }, + (_, index) => `bulk/special-${index}/needle.ts` + ) + ) + }) + + it('returns scores sorted ascending', () => { + const files = [ + 'src/components/QuickOpen.tsx', + 'quick/open/deep/path/file.tsx', + 'src/q-u-i-c-k-open.ts' + ] + + const scores = rankQuickOpenFiles('quick', prepareQuickOpenFiles(files)).map( + (item) => item.score + ) + + expect(scores).toEqual([...scores].sort((a, b) => a - b)) + }) + + it('indexes normalized relative paths without changing path semantics', () => { + const files = [ + 'src/renderer/src/components/QuickOpen.tsx', + 'packages/windows-origin/src/App.tsx', + 'single-file.ts', + 'legacy\\provider\\raw-path.ts' + ] + + expect(prepareQuickOpenFiles(files)).toEqual([ + { + path: 'src/renderer/src/components/QuickOpen.tsx', + lowerPath: 'src/renderer/src/components/quickopen.tsx', + lowerFilename: 'quickopen.tsx', + inputIndex: 0 + }, + { + path: 'packages/windows-origin/src/App.tsx', + lowerPath: 'packages/windows-origin/src/app.tsx', + lowerFilename: 'app.tsx', + inputIndex: 1 + }, + { + path: 'single-file.ts', + lowerPath: 'single-file.ts', + lowerFilename: 'single-file.ts', + inputIndex: 2 + }, + { + path: 'legacy\\provider\\raw-path.ts', + lowerPath: 'legacy\\provider\\raw-path.ts', + lowerFilename: 'legacy\\provider\\raw-path.ts', + inputIndex: 3 + } + ]) + }) + + it('returns no results for non-positive limits', () => { + const files = prepareQuickOpenFiles(['src/a.ts']) + + expect(rankQuickOpenFiles('a', files, 0)).toEqual([]) + expect(rankQuickOpenFiles('a', files, -1)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts new file mode 100644 index 00000000000..1405006ca6f --- /dev/null +++ b/src/renderer/src/components/quick-open-search.ts @@ -0,0 +1,131 @@ +export const QUICK_OPEN_RESULT_LIMIT = 50 + +export type QuickOpenIndexedFile = { + path: string + lowerPath: string + lowerFilename: string + inputIndex: number +} + +export type QuickOpenSearchResult = { + path: string + score: number +} + +export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] { + return files.map((path, inputIndex) => { + const lastSlash = path.lastIndexOf('/') + return { + path, + lowerPath: path.toLowerCase(), + lowerFilename: path.slice(lastSlash + 1).toLowerCase(), + inputIndex + } + }) +} + +export function rankQuickOpenFiles( + query: string, + files: readonly QuickOpenIndexedFile[], + limit = QUICK_OPEN_RESULT_LIMIT +): QuickOpenSearchResult[] { + if (limit <= 0) { + return [] + } + + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) + } + + const results: QuickOpenRankedResult[] = [] + for (const file of files) { + const score = fuzzyMatchIndexedFile(normalizedQuery, file) + if (score === -1) { + continue + } + + insertTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) + } + + return results.map(({ path, score }) => ({ path, score })) +} + +function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { + let qi = 0 + let score = 0 + let lastMatchIdx = -1 + + for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { + if (file.lowerPath[ti] === query[qi]) { + // Preserve the existing Quick Open score semantics while avoiding + // repeated lowercase work for each candidate on every keystroke. + const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 + score += gap + if ( + ti > 0 && + (file.lowerPath[ti - 1] === '/' || + file.lowerPath[ti - 1] === '.' || + file.lowerPath[ti - 1] === '-') + ) { + score -= 5 + } + lastMatchIdx = ti + qi++ + } + } + + if (qi < query.length) { + return -1 + } + + if (file.lowerFilename.includes(query)) { + score -= 100 + } + + return score +} + +type QuickOpenRankedResult = QuickOpenSearchResult & { + inputIndex: number +} + +function insertTopResult( + results: QuickOpenRankedResult[], + candidate: QuickOpenRankedResult, + limit: number +): void { + const worst = results.at(-1) + if (results.length === limit && worst && compareRankedResult(candidate, worst) >= 0) { + return + } + + const insertAt = findInsertionIndex(results, candidate) + results.splice(insertAt, 0, candidate) + if (results.length > limit) { + results.pop() + } +} + +function findInsertionIndex( + results: readonly QuickOpenRankedResult[], + candidate: QuickOpenRankedResult +): number { + let low = 0 + let high = results.length + + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (compareRankedResult(candidate, results[mid]) < 0) { + high = mid + } else { + low = mid + 1 + } + } + + return low +} + +function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number { + return a.score - b.score || a.inputIndex - b.inputIndex +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index e80d8424e1d..9115b9182ef 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -24,6 +24,9 @@ export function ExperimentalPane({ const searchQuery = useAppStore((s) => s.settingsSearchQuery) const showPet = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.pet]) const showAgentsView = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.activity]) + const showTerminalAttention = matchesSettingsSearch(searchQuery, [ + EXPERIMENTAL_SEARCH_ENTRY.terminalAttention + ]) const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_SEARCH_ENTRY.symlinks ]) @@ -108,6 +111,44 @@ export function ExperimentalPane({ ) : null} + {showTerminalAttention ? ( + +
+
+ +

+ Keeps a pane-level highlight visible after terminal bell or agent-completion events + until you interact with that pane. Experimental while we tune the signal. +

+
+ +
+
+ ) : null} + {showWorktreeSymlinks ? ( { const handleBeforeUnload = (event: BeforeUnloadEvent): void => { + if (isIntentionalAppRestartInProgress()) { + return + } if (!hasUnsavedCommitPromptChanges) { return } diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 102c44c55cc..0eb67306ad1 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -31,6 +31,22 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'sidebar' ] }, + { + title: 'Terminal attention', + description: 'Persistent pane highlight for terminal bell and agent-completion events.', + keywords: [ + 'experimental', + 'terminal', + 'attention', + 'highlight', + 'pane', + 'bell', + 'notification', + 'agent', + 'completion', + 'unread' + ] + }, { title: 'Symlinks on worktrees', description: @@ -64,5 +80,6 @@ function findEntry(title: string): SettingsSearchEntry { export const EXPERIMENTAL_SEARCH_ENTRY = { pet: findEntry('Pet'), activity: findEntry('Agents View'), + terminalAttention: findEntry('Terminal attention'), symlinks: findEntry('Symlinks on worktrees') } as const diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 701f93c8b4a..647ea521154 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -21,6 +21,14 @@ import { getDefaultCloneParent } from './clone-defaults' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry, + type NestedRepoTelemetryRuntimeKind +} from '../../../../shared/nested-repo-telemetry' import type { AddRepoExistingWorkspaceSource, AddRepoSetupStepAction @@ -83,6 +91,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) const [nestedGroupName, setNestedGroupName] = useState('') const [nestedConnectionId, setNestedConnectionId] = useState(null) + const [nestedAttemptId, setNestedAttemptId] = useState(null) + const [nestedRuntimeKind, setNestedRuntimeKind] = useState( + null + ) + + const getNestedRepoRuntimeKind = useCallback( + (connectionId: string | null): NestedRepoTelemetryRuntimeKind => { + if (connectionId) { + return 'ssh' + } + return settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' + }, + [settings?.activeRuntimeEnvironmentId] + ) // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) @@ -110,12 +132,25 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { closeModal, setExistingWorkspaceSource, scanNestedRepos, - (scan, selectedPath, connectionId) => { + (scan, selectedPath, connectionId, attemptId) => { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(scan.selectedPath || selectedPath)) setNestedConnectionId(connectionId) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('ssh') setStep('nested') + }, + (scan, attemptId) => { + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + scan + }) + ) } ) @@ -217,6 +252,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setNestedSelectedPaths(new Set()) setNestedGroupName('') setNestedConnectionId(null) + setNestedAttemptId(null) + setNestedRuntimeKind(null) resetCreateState() resetRemoteState() }, [resetRemoteState, resetCreateState]) @@ -242,12 +279,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (!path) { return } + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(path)) setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('local') setStep('nested') return } @@ -269,10 +318,33 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const handleImportNestedRepos = useCallback( async (mode: 'group' | 'separate') => { - if (!nestedScan || nestedSelectedPaths.size === 0) { + const attemptId = nestedAttemptId + if ( + !nestedScan || + !attemptId || + !shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: nestedSelectedPaths.size + }) + ) { return } + const foundCount = nestedScan.repos.length + const selectedCount = nestedSelectedPaths.size + const runtimeKind = nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId) setIsAdding(true) + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + action: mode === 'group' ? 'import_group' : 'import_separate', + foundCount, + selectedCount + }) + ) + let resultTracked = false try { const result = await importNestedRepos({ parentPath: nestedScan.selectedPath, @@ -281,6 +353,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { ...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}), mode }) + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + mode, + foundCount, + selectedCount, + result + }) + ) + resultTracked = true if (!result) { return } @@ -313,6 +398,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { }) } } finally { + if (!resultTracked) { + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + mode, + foundCount, + selectedCount, + result: null + }) + ) + } setIsAdding(false) } }, @@ -320,9 +419,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { fetchWorktrees, importNestedRepos, nestedGroupName, + nestedAttemptId, nestedScan, nestedSelectedPaths, nestedConnectionId, + nestedRuntimeKind, + getNestedRepoRuntimeKind, settings?.activeRuntimeEnvironmentId ] ) @@ -336,12 +438,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setIsAddingServerPath(true) try { if (kind === 'git') { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: getNestedRepoRuntimeKind(null), + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(path)) setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind(getNestedRepoRuntimeKind(null)) setStep('nested') return } @@ -361,7 +475,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setIsAddingServerPath(false) } }, - [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, serverPath] + [addRepoPath, closeModal, fetchWorktrees, getNestedRepoRuntimeKind, scanNestedRepos, serverPath] ) const handlePickDestination = useCallback(async () => { @@ -550,8 +664,36 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { updateRepo ]) + const trackNestedBackAction = useCallback((): void => { + if (nestedScan && nestedAttemptId) { + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId: nestedAttemptId, + surface: 'sidebar', + runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId), + action: 'back', + foundCount: nestedScan.repos.length, + selectedCount: nestedSelectedPaths.size + }) + ) + } + }, [ + getNestedRepoRuntimeKind, + nestedAttemptId, + nestedConnectionId, + nestedRuntimeKind, + nestedScan, + nestedSelectedPaths.size + ]) + // Why: handleBack reuses resetState which already aborts clones and resets all fields. - const handleBack = resetState + const handleBack = useCallback(() => { + if (step === 'nested') { + trackNestedBackAction() + } + resetState() + }, [resetState, step, trackNestedBackAction]) // Why: only the Setup step's "Add another project" back arrow counts as a // funnel event — the in-flight Back arrows on clone/remote/create are not @@ -576,6 +718,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { void finishImportedRepoWithoutOpening() return } + if (step === 'nested' && !isAdding) { + trackNestedBackAction() + } closeModal() resetState() } diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index cd57ce2183a..8d1bbf66455 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -17,6 +17,7 @@ import { SshTargetRow } from './SshTargetRow' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' import type { NestedRepoScanResult, Repo } from '../../../../shared/types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' +import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' // ── Remote project hook ───────────────────────────────────────────── @@ -30,8 +31,10 @@ export function useRemoteRepo( showNestedRepoReview?: ( scan: NestedRepoScanResult, selectedPath: string, - connectionId: string - ) => void + connectionId: string, + attemptId: string + ) => void, + onNestedScanResult?: (scan: NestedRepoScanResult | null, attemptId: string) => void ) { const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) const [selectedTargetId, setSelectedTargetId] = useState(null) @@ -111,9 +114,11 @@ export function useRemoteRepo( setIsAddingRemote(true) setRemoteError(null) try { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos?.(trimmedRemotePath, selectedTargetId) + onNestedScanResult?.(scan ?? null, attemptId) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId) + showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId, attemptId) return } const result = await window.api.repos.addRemote({ @@ -165,6 +170,7 @@ export function useRemoteRepo( remotePath, scanNestedRepos, showNestedRepoReview, + onNestedScanResult, fetchWorktrees, setStep, setAddedRepo, diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 476b4d42ef3..192d7ef79e5 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -8,6 +8,7 @@ import { Github, HardDrive, MessageSquareText, + RotateCw, School, Settings, Smartphone @@ -19,6 +20,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { @@ -258,6 +260,9 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { const openSpacePage = useAppStore((s) => s.openSpacePage) const openMobilePage = useAppStore((s) => s.openMobilePage) const [feedbackOpen, setFeedbackOpen] = useState(false) + const [helpMenuOpen, setHelpMenuOpen] = useState(false) + const [showAdminHelpOptions, setShowAdminHelpOptions] = useState(false) + const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) const handleShowOnboarding = (): void => { @@ -269,6 +274,34 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { void showOnboardingFromRenderer() } + const handleHelpMenuOpenChange = (open: boolean): void => { + setHelpMenuOpen(open) + if (!open) { + setShowAdminHelpOptions(false) + } + } + + const revealAdminHelpOptions = (altKey: boolean): void => { + // Why: keep restart off the ordinary Help menu; Alt/Option-click is an + // intentional admin affordance for recovering the app without teaching it + // as a normal user workflow. + setShowAdminHelpOptions(altKey) + } + + const handleRestartOrca = (): void => { + if (isRestartingOrca) { + return + } + setIsRestartingOrca(true) + toast.info('Restarting Orca…') + void window.api.app.restart().catch((error) => { + setIsRestartingOrca(false) + toast.error('Couldn’t restart Orca.', { + description: error instanceof Error ? error.message : undefined + }) + }) + } + return (
@@ -324,7 +357,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { - + @@ -334,6 +367,8 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { type="button" aria-label="Help" className="text-muted-foreground" + onPointerDown={(event) => revealAdminHelpOptions(event.altKey)} + onClick={(event) => revealAdminHelpOptions(event.altKey)} > @@ -360,6 +395,15 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { Docs + {showAdminHelpOptions ? ( + <> + + + + Restart Orca + + + ) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1ef4525960f..9028a738966 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -77,6 +77,7 @@ import { getActiveStickyHeaderIndex, getActiveStickyHeaderIndexForScroll, getPreviousStickyHeaderIndex, + GROUP_HEADER_ROW_HEIGHT, getStickyHeaderIndexes, getVirtualRowTransform, shouldUseHeaderTopSpacing, @@ -162,6 +163,9 @@ type ProjectGroupDeleteDialogState = { // terminal title changes) trigger score recalculations. const SORT_SETTLE_MS = 3_000 const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 +const WORKTREE_REVEAL_TOP_CLEARANCE = 6 +export const WORKTREE_SIDEBAR_REVEAL_TOP_INSET = + GROUP_HEADER_ROW_HEIGHT + WORKTREE_REVEAL_TOP_CLEARANCE const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // Why: TanStack Virtual owns scroll correction. Native browser anchoring can // fight virtual row measurement/remounts and produce visible jumps. @@ -253,12 +257,14 @@ function getMountedWorktreeBounds( export function getScrollTopToRevealBounds( container: HTMLElement, - bounds: Pick + bounds: Pick, + topInset = 0 ): number | null { - const viewportTop = container.scrollTop - const viewportBottom = viewportTop + container.clientHeight + const viewportTopInset = Math.max(0, Math.min(container.clientHeight, topInset)) + const viewportTop = container.scrollTop + viewportTopInset + const viewportBottom = container.scrollTop + container.clientHeight if (bounds.start < viewportTop) { - return bounds.start + return bounds.start - viewportTopInset } if (bounds.end > viewportBottom) { return bounds.end - container.clientHeight @@ -275,7 +281,11 @@ function revealMountedWorktreeElement( if (!bounds) { return false } - const nextScrollTop = getScrollTopToRevealBounds(container, bounds) + const nextScrollTop = getScrollTopToRevealBounds( + container, + bounds, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET + ) if (nextScrollTop !== null) { container.scrollTo({ top: Math.max(0, nextScrollTop), behavior }) } @@ -955,6 +965,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, []), overscan: 10, gap: 6, + // Why: the active sticky group header is rendered inside the virtual list, + // so TanStack's scroll math needs the same top inset as the exact DOM reveal. + scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, // Why: the sidebar rows are rich cards. Flushing their React render inside // TanStack's native scroll listener can make wheel input wait on card work; diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index 55b2240e2e5..eb0d9ed7714 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, + getScrollTopToRevealBounds, resolvePendingSidebarReveal, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll } from './WorktreeList' import { estimateRenderRowSize, + GROUP_HEADER_ROW_HEIGHT, getActiveStickyHeaderIndexForScroll } from './worktree-list-virtual-rows' @@ -18,6 +21,9 @@ const makeHeaderRow = (key: string) => tone: 'text-foreground' }) as const +const makeScrollContainer = (scrollTop: number, clientHeight: number): HTMLElement => + ({ scrollTop, clientHeight }) as HTMLElement + describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { it('counts record keys once per object reference', () => { const keysSpy = vi.spyOn(Object, 'keys') @@ -92,6 +98,68 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { }) }) +describe('getScrollTopToRevealBounds', () => { + it('treats the sticky header as occluding the viewport top', () => { + const container = makeScrollContainer(100, 400) + + expect( + getScrollTopToRevealBounds( + container, + { + start: 100, + end: 216 + }, + GROUP_HEADER_ROW_HEIGHT + ) + ).toBe(72) + }) + + it('includes extra reveal clearance for the highlight ring', () => { + const container = makeScrollContainer(100, 400) + + expect( + getScrollTopToRevealBounds( + container, + { + start: 100, + end: 216 + }, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET + ) + ).toBe(66) + }) + + it('does not scroll when the bounds are below the sticky header', () => { + const container = makeScrollContainer(100, 400) + + expect( + getScrollTopToRevealBounds( + container, + { + start: 128, + end: 244 + }, + GROUP_HEADER_ROW_HEIGHT + ) + ).toBeNull() + }) + + it('keeps the viewport bottom independent of the sticky header inset', () => { + const container = makeScrollContainer(100, 400) + + expect( + getScrollTopToRevealBounds( + container, + { + start: 430, + end: 520 + }, + GROUP_HEADER_ROW_HEIGHT + ) + ).toBe(120) + }) +}) + describe('estimateRenderRowSize', () => { it('keeps secondary group header size stable while it is the active sticky header', () => { const rows = [makeHeaderRow('first'), makeHeaderRow('second')] diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index f334de178de..dda04823b39 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -2,7 +2,7 @@ import type { VirtualItem } from '@tanstack/react-virtual' import type { Row } from './worktree-list-groups' import { PINNED_GROUP_KEY } from './worktree-list-groups' -const GROUP_HEADER_ROW_HEIGHT = 28 +export const GROUP_HEADER_ROW_HEIGHT = 28 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8 type WorktreeItemRow = Extract diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index bfa7e99c6f4..31c129c6f2b 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -283,7 +283,12 @@ export function SshStatusSegment({ )} - +
SSH Connections
diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx new file mode 100644 index 00000000000..f059748b7e7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserTab as BrowserTabState } from '../../../../shared/types' + +const reactHookRuntime = vi.hoisted(() => ({ + states: [] as unknown[], + index: 0 +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useEffect: () => {}, + useState(initial: T | (() => T)) { + const stateIndex = reactHookRuntime.index++ + if (!(stateIndex in reactHookRuntime.states)) { + reactHookRuntime.states[stateIndex] = + typeof initial === 'function' ? (initial as () => T)() : initial + } + const setState = (next: T | ((previous: T) => T)): void => { + reactHookRuntime.states[stateIndex] = + typeof next === 'function' + ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) + : next + } + return [reactHookRuntime.states[stateIndex] as T, setState] as const + } + } +}) + +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: () => ({ + attributes: {}, + listeners: { onPointerDown: vi.fn() }, + setNodeRef: vi.fn() + }) +})) + +vi.mock('lucide-react', () => ({ + Columns2: function Columns2(props: Record) { + return { type: 'Columns2', props } + }, + Copy: function Copy(props: Record) { + return { type: 'Copy', props } + }, + ExternalLink: function ExternalLink(props: Record) { + return { type: 'ExternalLink', props } + }, + Globe: function Globe(props: Record) { + return { type: 'Globe', props } + }, + Rows2: function Rows2(props: Record) { + return { type: 'Rows2', props } + }, + X: function X(props: Record) { + return { type: 'X', props } + } +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: function DropdownMenu(props: { children?: unknown }) { + return { type: 'DropdownMenu', props } + }, + DropdownMenuContent: function DropdownMenuContent(props: { children?: unknown }) { + return { type: 'DropdownMenuContent', props } + }, + DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { + return { type: 'DropdownMenuItem', props } + }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, + DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuTrigger', props } + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: function Tooltip(props: { children?: unknown }) { + return { type: 'Tooltip', props } + }, + TooltipContent: function TooltipContent(props: { children?: unknown }) { + return { type: 'TooltipContent', props } + }, + TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { + return props.children + } +})) + +vi.mock('../browser-pane/browser-runtime', () => ({ + getLiveBrowserUrl: () => null +})) + +type ReactElementLike = { + type: unknown + props: Record +} + +function baseBrowserTab(overrides: Partial = {}): BrowserTabState { + return { + id: 'browser-1', + worktreeId: 'wt-1', + label: 'Browser 1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com/', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0, + ...overrides + } +} + +async function renderBrowserTab(tab: BrowserTabState): Promise { + reactHookRuntime.index = 0 + const module = await import('./BrowserTab') + return module.default({ + tab, + isActive: true, + hasTabsToRight: false, + onActivate: () => {}, + onClose: () => {}, + onCloseToRight: () => {}, + onSplitGroup: () => {}, + onDuplicate: () => {}, + dragData: { + kind: 'tab', + worktreeId: tab.worktreeId, + groupId: 'group-1', + unifiedTabId: tab.id, + visibleTabId: tab.id, + tabType: 'browser', + label: tab.title + }, + dropIndicator: undefined + }) +} + +function expandNode(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map(expandNode) + } + const el = node as ReactElementLike + if (typeof el.type === 'function') { + return expandNode(el.type(el.props)) + } + return { + ...el, + props: { + ...el.props, + children: expandNode(el.props?.children) + } + } +} + +function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { + const results: ReactElementLike[] = [] + const visit = (current: unknown): void => { + if (current == null || typeof current === 'string' || typeof current === 'number') { + return + } + if (Array.isArray(current)) { + for (const child of current) { + visit(child) + } + return + } + const el = current as ReactElementLike + if (el.type === typeName) { + results.push(el) + } + visit(el.props?.children) + } + visit(node) + return results +} + +async function renderExpandedBrowserTab(tab: BrowserTabState): Promise { + return expandNode(await renderBrowserTab(tab)) +} + +describe('BrowserTab favicon', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + }) + + it('renders the favicon image when faviconUrl is present', async () => { + const iconUrl = 'https://example.com/favicon.ico' + const element = await renderExpandedBrowserTab(baseBrowserTab({ faviconUrl: iconUrl })) + + const images = findElementsByType(element, 'img') + expect(images).toHaveLength(1) + expect(images[0].props.src).toBe(iconUrl) + expect(images[0].props.alt).toBe('') + expect(images[0].props['aria-hidden']).toBe(true) + expect(images[0].props.draggable).toBe(false) + expect(images[0].props.className).toContain('size-3 mr-1 shrink-0') + expect(images[0].props.className).toContain('object-contain') + expect(images[0].props.className).toContain('drop-shadow-[0_0_1px_var(--foreground)]') + expect(findElementsByType(element, 'Globe')).toHaveLength(0) + }) + + it('keeps the globe fallback for blank tabs without faviconUrl', async () => { + const element = await renderExpandedBrowserTab( + baseBrowserTab({ + url: 'about:blank', + title: 'about:blank', + faviconUrl: null + }) + ) + + expect(findElementsByType(element, 'img')).toHaveLength(0) + const globes = findElementsByType(element, 'Globe') + expect(globes).toHaveLength(1) + expect(globes[0].props.className).toContain('size-3 mr-1 shrink-0') + expect(globes[0].props.className).toContain('text-blue-500') + }) + + it('falls back to the globe after the favicon image errors', async () => { + const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' }) + const firstRender = await renderExpandedBrowserTab(tab) + const image = findElementsByType(firstRender, 'img')[0] + + ;(image.props.onError as () => void)() + const secondRender = await renderExpandedBrowserTab(tab) + + expect(findElementsByType(secondRender, 'img')).toHaveLength(0) + expect(findElementsByType(secondRender, 'Globe')).toHaveLength(1) + }) + + it('resets the image-error fallback when faviconUrl changes', async () => { + const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' }) + const firstRender = await renderExpandedBrowserTab(tab) + const image = findElementsByType(firstRender, 'img')[0] + + ;(image.props.onError as () => void)() + const failedRender = await renderExpandedBrowserTab(tab) + expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1) + + const nextIconUrl = 'data:image/png;base64,abc123' + const resetRender = await renderExpandedBrowserTab( + baseBrowserTab({ id: tab.id, faviconUrl: nextIconUrl }) + ) + + const images = findElementsByType(resetRender, 'img') + expect(images).toHaveLength(1) + expect(images[0].props.src).toBe(nextIconUrl) + expect(findElementsByType(resetRender, 'Globe')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 9a810721808..d2f210748b4 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -8,6 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { redactKagiSessionToken } from '../../../../shared/browser-url' import type { BrowserTab as BrowserTabState } from '../../../../shared/types' @@ -49,6 +50,46 @@ function isBlankBrowserTab(tab: BrowserTabState): boolean { return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank' } +type FailedFavicon = { + tabId: string + faviconUrl: string +} + +function BrowserTabFavicon({ + tabId, + faviconUrl +}: { + tabId: string + faviconUrl: string | null +}): React.JSX.Element { + const displayFaviconUrl = faviconUrl?.trim() ? faviconUrl : null + const [failedFavicon, setFailedFavicon] = useState(null) + + useEffect(() => { + setFailedFavicon(null) + }, [tabId, displayFaviconUrl]) + + const currentFaviconFailed = + failedFavicon?.tabId === tabId && failedFavicon.faviconUrl === displayFaviconUrl + + if (displayFaviconUrl && !currentFaviconFailed) { + return ( + setFailedFavicon({ tabId, faviconUrl: displayFaviconUrl })} + /> + ) + } + + return +} + export default function BrowserTab({ tab, isActive, @@ -92,6 +133,7 @@ export default function BrowserTab({ } catch { // invalid URL — leave disabled } + const tabLabel = getBrowserTabLabel(tab) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -113,6 +155,65 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + const tabRoot = ( +
{ + if (e.button !== 0) { + return + } + onActivate() + listeners?.onPointerDown?.(e) + }} + onMouseDown={(e) => { + if (e.button === 1) { + e.preventDefault() + } + }} + onMouseUp={preventMiddleButtonDefault} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault() + e.stopPropagation() + onClose() + } + }} + > + {isActive && } + {/* Why: the browser tab icon is the only non-terminal, non-editor + surface in the tab strip. Coloring the Globe blue (matching the + in-app browser's identity and the default tab insertion bar) + gives it a distinct, recognizable anchor so users can spot + browser tabs at a glance even when the strip is saturated. We + keep full color on both active and inactive tabs — dimming to + muted-foreground made the icon read as "disabled" in practice. */} + + {tabLabel} + {tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && ( + + )} + +
+ ) + return ( <>
-
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) - }} - onMouseDown={(e) => { - if (e.button === 1) { - e.preventDefault() - } - }} - onMouseUp={preventMiddleButtonDefault} - onAuxClick={(e) => { - if (e.button === 1) { - e.preventDefault() - e.stopPropagation() - onClose() - } - }} - > - {isActive && } - {/* Why: the browser tab icon is the only non-terminal, non-editor - surface in the tab strip. Coloring the Globe blue (matching the - in-app browser's identity and the default tab insertion bar) - gives it a distinct, recognizable anchor so users can spot - browser tabs at a glance even when the strip is saturated. We - keep full color on both active and inactive tabs — dimming to - muted-foreground made the icon read as "disabled" in practice. */} - - {getBrowserTabLabel(tab)} - {tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && ( - - )} - -
+ {menuOpen ? ( + tabRoot + ) : ( + + {tabRoot} + + {tabLabel} + + + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx new file mode 100644 index 00000000000..ec051b3d969 --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '../../store/slices/editor' + +const reactHookRuntime = vi.hoisted(() => ({ + states: [] as unknown[], + index: 0 +})) + +const appStoreMocks = vi.hoisted(() => ({ + openMarkdownPreview: vi.fn(), + getState: vi.fn(() => ({ + settings: {} + })) +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useEffect: () => {}, + useRef(initial: T) { + return { current: initial } + }, + useState(initial: T | (() => T)) { + const stateIndex = reactHookRuntime.index++ + if (!(stateIndex in reactHookRuntime.states)) { + reactHookRuntime.states[stateIndex] = + typeof initial === 'function' ? (initial as () => T)() : initial + } + const setState = (next: T | ((previous: T) => T)): void => { + reactHookRuntime.states[stateIndex] = + typeof next === 'function' + ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T) + : next + } + return [reactHookRuntime.states[stateIndex] as T, setState] as const + } + } +}) + +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: () => ({ + attributes: {}, + listeners: { onPointerDown: vi.fn() }, + setNodeRef: vi.fn() + }) +})) + +vi.mock('lucide-react', () => ({ + Columns2: function Columns2(props: Record) { + return { type: 'Columns2', props } + }, + Copy: function Copy(props: Record) { + return { type: 'Copy', props } + }, + ExternalLink: function ExternalLink(props: Record) { + return { type: 'ExternalLink', props } + }, + Eye: function Eye(props: Record) { + return { type: 'Eye', props } + }, + GitCompareArrows: function GitCompareArrows(props: Record) { + return { type: 'GitCompareArrows', props } + }, + Pencil: function Pencil(props: Record) { + return { type: 'Pencil', props } + }, + Rows2: function Rows2(props: Record) { + return { type: 'Rows2', props } + }, + ShieldAlert: function ShieldAlert(props: Record) { + return { type: 'ShieldAlert', props } + }, + X: function X(props: Record) { + return { type: 'X', props } + } +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: function DropdownMenu(props: { children?: unknown }) { + return { type: 'DropdownMenu', props } + }, + DropdownMenuContent: function DropdownMenuContent(props: { children?: unknown }) { + return { type: 'DropdownMenuContent', props } + }, + DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { + return { type: 'DropdownMenuItem', props } + }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, + DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuTrigger', props } + } +})) + +vi.mock('@/components/ui/input', () => ({ + Input: function Input(props: Record) { + return { type: 'input', props } + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: function Tooltip(props: { children?: unknown }) { + return { type: 'Tooltip', props } + }, + TooltipContent: function TooltipContent(props: { children?: unknown }) { + return { type: 'TooltipContent', props } + }, + TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { + return props.children + } +})) + +vi.mock('@/components/editor/editor-labels', () => ({ + getEditorDisplayLabel: (file: OpenFile) => file.relativePath +})) + +vi.mock('@/lib/rename-file', () => ({ + renameFileOnDisk: vi.fn() +})) + +vi.mock('@/lib/file-type-icons', () => ({ + getFileTypeIcon: () => + function FileIcon(props: Record) { + return { type: 'FileIcon', props } + } +})) + +vi.mock('@/store/selectors', () => ({ + useRepoById: () => ({ connectionId: null }), + useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' }) +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: { openMarkdownPreview: typeof vi.fn }) => unknown) => + selector({ openMarkdownPreview: appStoreMocks.openMarkdownPreview }) + useAppStore.getState = appStoreMocks.getState + return { useAppStore } +}) + +vi.mock('../right-sidebar/status-display', () => ({ + STATUS_COLORS: {}, + STATUS_LABELS: {} +})) + +vi.mock('./SortableTab', () => ({ + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca-close-all-context-menus' +})) + +vi.mock('./drop-indicator', () => ({ + ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', + getDropIndicatorClasses: () => '' +})) + +vi.mock('@/components/editor/markdown-preview-controls', () => ({ + canOpenMarkdownPreview: () => false +})) + +vi.mock('@/lib/local-path-open-guard', () => ({ + shouldBlockEditorTabLocalOpen: () => false, + showLocalPathOpenBlockedToast: vi.fn() +})) + +type ReactElementLike = { + type: unknown + props: Record +} + +function baseFile(overrides: Partial = {}): OpenFile { + return { + id: '/repo/untitled-5.md', + filePath: '/repo/untitled-5.md', + relativePath: 'untitled-5.md', + worktreeId: 'wt-1', + language: 'markdown', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +async function renderEditorFileTab( + file: OpenFile, + onActivate = vi.fn() +): Promise<{ element: unknown; onActivate: ReturnType }> { + reactHookRuntime.index = 0 + const module = await import('./EditorFileTab') + const element = module.default({ + file, + isActive: true, + hasTabsToRight: false, + statusByRelativePath: new Map(), + onActivate, + onClose: () => {}, + onCloseToRight: () => {}, + onCloseAll: () => {}, + onPin: () => {}, + onSplitGroup: () => {}, + dragData: { + kind: 'tab', + worktreeId: file.worktreeId, + groupId: 'group-1', + unifiedTabId: file.id, + visibleTabId: file.id, + tabType: 'editor', + label: file.relativePath, + iconPath: file.filePath + } + }) + return { element, onActivate } +} + +function expandNode(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map(expandNode) + } + const el = node as ReactElementLike + if (typeof el.type === 'function') { + return expandNode(el.type(el.props)) + } + return { + ...el, + props: { + ...el.props, + children: expandNode(el.props?.children) + } + } +} + +function getText(node: unknown): string { + if (node == null) { + return '' + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map(getText).join('') + } + const el = node as ReactElementLike + return getText(el.props?.children) +} + +function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { + const results: ReactElementLike[] = [] + const visit = (current: unknown): void => { + if (current == null || typeof current === 'string' || typeof current === 'number') { + return + } + if (Array.isArray(current)) { + for (const child of current) { + visit(child) + } + return + } + const el = current as ReactElementLike + if (el.type === typeName) { + results.push(el) + } + visit(el.props?.children) + } + visit(node) + return results +} + +function findMenuItemByText(node: unknown, label: string): ReactElementLike { + const item = findElementsByType(node, 'DropdownMenuItem').find((candidate) => + getText(candidate).includes(label) + ) + if (!item) { + throw new Error(`Missing menu item: ${label}`) + } + return item +} + +describe('EditorFileTab rename menu', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + vi.resetModules() + vi.stubGlobal('navigator', { userAgent: 'Mac' }) + }) + + it('turns the tab filename into an inline input from the Rename context-menu item', async () => { + const onActivate = vi.fn() + const file = baseFile({ isUntitled: true }) + const firstRender = expandNode((await renderEditorFileTab(file, onActivate)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + // Why: New Markdown tabs are real on-disk files even while marked + // isUntitled; the tab menu must let users rename the screenshot-style + // "untitled-N.md" files directly. + expect(renameItem.props.disabled).toBe(false) + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element) + const inputs = findElementsByType(secondRender, 'input') + expect(inputs).toHaveLength(1) + expect(inputs[0].props.defaultValue).toBe('untitled-5.md') + expect(inputs[0].props['data-tab-rename-input']).toBe('true') + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('disables Rename for diff tabs that do not map to one writable file', async () => { + const file = baseFile({ + mode: 'diff', + diffSource: 'unstaged' + }) + const element = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(element, 'Rename') + + expect(renameItem.props.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index e3a82a36bf6..dc5005467d3 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -9,7 +9,8 @@ import { ShieldAlert, ExternalLink, Columns2, - Rows2 + Rows2, + Pencil } from 'lucide-react' import { DropdownMenu, @@ -18,6 +19,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' @@ -28,6 +31,7 @@ import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' import type { GitFileStatus } from '../../../../shared/types' import type { OpenFile } from '../../store/slices/editor' +import { getUntitledFileRoot } from '@/components/editor/untitled-file-rename-path' import { preventMiddleButtonDefault } from './middle-button-default-guard' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -109,15 +113,23 @@ export default function EditorFileTab({ const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [isRenaming, setIsRenaming] = useState(false) const renameInputRef = useRef(null) + const skipMenuFocusRestoreRef = useRef(false) // Escape fires setIsRenaming(false), which unmounts the input. The browser // still fires focusout as the focused node is removed, so onBlur can invoke // commitRename *after* cancel — committing the typed value against the // user's intent. This flag suppresses the trailing blur-commit. const renameCancelledRef = useRef(false) - // Only real on-disk files in edit mode are renameable. Diff, conflict-review, - // untitled drafts, and combined/virtual views don't point at a single concrete - // file we can safely rename. - const canRename = file.mode === 'edit' && !file.isUntitled && !file.diffSource && !file.conflict + // Only on-disk edit tabs are renameable. Diff, conflict-review, and + // combined/virtual views don't point at a single concrete file we can safely rename. + const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict + + const openRenameInput = (): void => { + if (!canRename) { + return + } + renameCancelledRef.current = false + setIsRenaming(true) + } const commitRename = (): void => { if (renameCancelledRef.current) { @@ -139,10 +151,7 @@ export default function EditorFileTab({ if (newName === oldName) { return } - const worktreePath = worktree?.path ?? null - if (!worktreePath) { - return - } + const worktreePath = getUntitledFileRoot(file, worktree?.path ?? null) void renameFileOnDisk({ oldPath: file.filePath, newName, @@ -177,6 +186,7 @@ export default function EditorFileTab({ ? null : (statusByRelativePath.get(normalizeRelativePath(file.relativePath)) ?? null) const tabStatusColor = tabStatus ? STATUS_COLORS[tabStatus] : undefined + const tabLabel = getEditorDisplayLabel(file) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -198,6 +208,146 @@ export default function EditorFileTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + const tabRoot = ( +
{ + if (e.button !== 0) { + return + } + onActivate() + listeners?.onPointerDown?.(e) + }} + onDoubleClick={() => { + if (file.isPreview && onPin) { + onPin() + } + }} + onMouseDown={(e) => { + if (e.button === 1) { + e.preventDefault() + } + }} + onMouseUp={preventMiddleButtonDefault} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault() + e.stopPropagation() + onClose() + } + }} + > + {isActive && } + {isConflictReview ? ( + + ) : isDiff ? ( + + ) : isMarkdownPreviewTab ? ( + + ) : ( + + )} + + {isRenaming ? ( + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + commitRename() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + renameCancelledRef.current = true + setIsRenaming(false) + } + }} + onBlur={commitRename} + /> + ) : ( + { + // Why: the outer tab's onDoubleClick pins preview tabs. Scope + // rename to the filename text only so pin-on-dblclick still + // works anywhere else on the tab chrome (matching VS Code). + if (!canRename) { + return + } + e.stopPropagation() + openRenameInput() + }} + > + {tabLabel} + + )} + {file.externalMutation && !isRenaming && ( + + {file.externalMutation} + + )} + {tabStatus && !isRenaming && !file.externalMutation && ( + + {STATUS_LABELS[tabStatus]} + + )} + + {/* Dirty dot and close button share the same slot to prevent tab width shift during auto-save. + When dirty: dot is shown, close button appears on hover (replacing the dot). + When clean: close button is shown normally (visible on active tab, on hover for others). */} +
+ {file.isDirty && ( + + )} + +
+
+ ) + return ( <>
-
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) - }} - onDoubleClick={() => { - if (file.isPreview && onPin) { - onPin() - } - }} - onMouseDown={(e) => { - if (e.button === 1) { - e.preventDefault() - } - }} - onMouseUp={preventMiddleButtonDefault} - onAuxClick={(e) => { - if (e.button === 1) { - e.preventDefault() - e.stopPropagation() - onClose() - } - }} - > - {isActive && } - {isConflictReview ? ( - - ) : isDiff ? ( - - ) : isMarkdownPreviewTab ? ( - - ) : ( - - )} - - {isRenaming ? ( - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - commitRename() - } else if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - renameCancelledRef.current = true - setIsRenaming(false) - } - }} - onBlur={commitRename} - /> - ) : ( - { - // Why: the outer tab's onDoubleClick pins preview tabs. Scope - // rename to the filename text only so pin-on-dblclick still - // works anywhere else on the tab chrome (matching VS Code). - if (!canRename) { - return - } - e.stopPropagation() - setIsRenaming(true) - }} - > - {getEditorDisplayLabel(file)} - - )} - {file.externalMutation && !isRenaming && ( - - {file.externalMutation} - - )} - {tabStatus && !isRenaming && !file.externalMutation && ( - - {STATUS_LABELS[tabStatus]} - - )} - - {/* Dirty dot and close button share the same slot to prevent tab width shift during auto-save. - When dirty: dot is shown, close button appears on hover (replacing the dot). - When clean: close button is shown normally (visible on active tab, on hover for others). */} -
- {file.isDirty && ( - - )} - -
-
+ {tabLabel} + + + )}
@@ -353,7 +383,18 @@ export default function EditorFileTab({ style={{ left: menuPoint.x, top: menuPoint.y }} /> - + { + if (!skipMenuFocusRestoreRef.current) { + return + } + skipMenuFocusRestoreRef.current = false + event.preventDefault() + }} + > onSplitGroup('up', file.tabId ?? file.id)}> Split Up @@ -371,6 +412,18 @@ export default function EditorFileTab({ Split Right + { + skipMenuFocusRestoreRef.current = true + onActivate() + openRenameInput() + }} + > + + Rename + + Close Close All Editor Tabs diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3fdee063489..e4f3f587a9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -175,6 +176,193 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const tabTitle = tab.customTitle ?? tab.title + const tabRoot = ( +
{ + if (isEditing) { + return + } + e.stopPropagation() + handleRenameOpen() + }} + onPointerDown={(e) => { + if (isEditing || e.button !== 0) { + return + } + onActivate(tab.id) + dragListeners?.onPointerDown?.(e) + }} + onMouseDown={(e) => { + // Why: prevent default browser middle-click behavior (auto-scroll) + // but do NOT close here — closing removes the element before mouseup, + // causing the mouseup to fall through to the terminal and trigger + // an X11 primary selection paste on Linux. + if (e.button === 1) { + e.preventDefault() + } + }} + onMouseUp={preventMiddleButtonDefault} + onAuxClick={(e) => { + if (isEditing) { + return + } + if (e.button === 1) { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + } + }} + > + {isActive && } + {showActivityAffordance && ( + // Why: amber wash for unread tabs. Rendered as a real DOM child so + // both drop indicators (::before left / ::after right in + // drop-indicator.ts) stay free for drag-and-drop feedback — a prior + // ::after-based implementation collided with the right-edge drop + // indicator and hid it on unread tabs. pointer-events-none keeps + // clicks reaching the underlying tab handlers. + + )} + {showActivityAffordance ? ( + // Why: the activity marker sits to the LEFT of the tab title using + // Orca's filled bell glyph (amber-500 with a subtle drop shadow) + // so it matches the worktree-level bell in the sidebar — keeping + // every "needs your attention" surface in Orca consistent. + + + + ) : ( + // Why: ShellIcon renders a colored brand-style tile for PowerShell, + // CMD, and WSL so Windows users can distinguish shells at a glance. + // On mac/linux (or Windows tabs without a resolved shell) it falls + // back to a matching colored generic-terminal tile — keeping every + // tab's leading glyph in the same visual idiom instead of mixing a + // flat lucide chevron with the brand tiles. Opacity dims the icon + // on inactive tabs to match the existing text treatment without + // desaturating the brand colors beyond recognition. + + + + )} + {isEditing ? ( + setRenameValue(event.target.value)} + onBlur={commitRename} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitRename() + } else if (event.key === 'Escape') { + event.preventDefault() + cancelRename() + } + }} + // Why: stop pointer/mouse events from bubbling to the outer div, which + // would otherwise trigger tab activation or start a dnd-kit drag while + // the user is trying to click inside the input. + onPointerDown={(event) => event.stopPropagation()} + onMouseDown={(event) => { + // Why: stop propagation so the outer tab's activation/drag handlers + // don't fire on clicks inside the input. Also preventDefault on middle + // click (button 1) to block Linux X11 primary-selection paste into the + // rename field, matching the outer tab's behavior. + event.stopPropagation() + if (event.button === 1) { + event.preventDefault() + } + }} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onAuxClick={(event) => event.stopPropagation()} + // Why: the base Input applies w-full min-w-0, which lets flex + // shrink it to ~0 when many tabs compete for horizontal space. + // Force a minimum width that matches the normal title box so the + // rename input stays usable even when the tab bar is saturated. + className="h-5 w-[72px] min-w-[72px] max-w-[72px] mr-1 px-1 py-0 text-xs" + spellCheck={false} + /> + ) : ( + {tabTitle} + )} + {tab.color && !isEditing && ( + + )} + {isExpanded && !isEditing && ( + + )} + {!isEditing && ( + + )} +
+ ) return ( <> @@ -186,190 +374,20 @@ export default function SortableTab({ setMenuOpen(true) }} > -
{ - if (isEditing) { - return - } - e.stopPropagation() - handleRenameOpen() - }} - onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) - }} - onMouseDown={(e) => { - // Why: prevent default browser middle-click behavior (auto-scroll) - // but do NOT close here — closing removes the element before mouseup, - // causing the mouseup to fall through to the terminal and trigger - // an X11 primary selection paste on Linux. - if (e.button === 1) { - e.preventDefault() - } - }} - onMouseUp={preventMiddleButtonDefault} - onAuxClick={(e) => { - if (isEditing) { - return - } - if (e.button === 1) { - e.preventDefault() - e.stopPropagation() - onClose(tab.id) - } - }} - > - {isActive && } - {showActivityAffordance && ( - // Why: amber wash for unread tabs. Rendered as a real DOM child so - // both drop indicators (::before left / ::after right in - // drop-indicator.ts) stay free for drag-and-drop feedback — a prior - // ::after-based implementation collided with the right-edge drop - // indicator and hid it on unread tabs. pointer-events-none keeps - // clicks reaching the underlying tab handlers. - - )} - {showActivityAffordance ? ( - // Why: the activity marker sits to the LEFT of the tab title using - // Orca's filled bell glyph (amber-500 with a subtle drop shadow) - // so it matches the worktree-level bell in the sidebar — keeping - // every "needs your attention" surface in Orca consistent. - - - - ) : ( - // Why: ShellIcon renders a colored brand-style tile for PowerShell, - // CMD, and WSL so Windows users can distinguish shells at a glance. - // On mac/linux (or Windows tabs without a resolved shell) it falls - // back to a matching colored generic-terminal tile — keeping every - // tab's leading glyph in the same visual idiom instead of mixing a - // flat lucide chevron with the brand tiles. Opacity dims the icon - // on inactive tabs to match the existing text treatment without - // desaturating the brand colors beyond recognition. - + {tabRoot} + - - - )} - {isEditing ? ( - setRenameValue(event.target.value)} - onBlur={commitRename} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - commitRename() - } else if (event.key === 'Escape') { - event.preventDefault() - cancelRename() - } - }} - // Why: stop pointer/mouse events from bubbling to the outer div, which - // would otherwise trigger tab activation or start a dnd-kit drag while - // the user is trying to click inside the input. - onPointerDown={(event) => event.stopPropagation()} - onMouseDown={(event) => { - // Why: stop propagation so the outer tab's activation/drag handlers - // don't fire on clicks inside the input. Also preventDefault on middle - // click (button 1) to block Linux X11 primary-selection paste into the - // rename field, matching the outer tab's behavior. - event.stopPropagation() - if (event.button === 1) { - event.preventDefault() - } - }} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onAuxClick={(event) => event.stopPropagation()} - // Why: the base Input applies w-full min-w-0, which lets flex - // shrink it to ~0 when many tabs compete for horizontal space. - // Force a minimum width that matches the normal title box so the - // rename input stays usable even when the tab bar is saturated. - className="h-5 w-[72px] min-w-[72px] max-w-[72px] mr-1 px-1 py-0 text-xs" - spellCheck={false} - /> - ) : ( - {tab.customTitle ?? tab.title} - )} - {tab.color && !isEditing && ( - - )} - {isExpanded && !isEditing && ( - - )} - {!isEditing && ( - - )} -
+ {tabTitle} + +
+ )}
diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx new file mode 100644 index 00000000000..d1efca049dc --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -0,0 +1,278 @@ +import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { + BrowserTab as BrowserTabState, + GitFileStatus, + TerminalTab +} from '../../../../shared/types' +import type { OpenFile } from '../../store/slices/editor' +import type { TabDragItemData } from '../tab-group/useTabDragSplit' +import BrowserTab from './BrowserTab' +import EditorFileTab from './EditorFileTab' +import SortableTab from './SortableTab' + +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: ({ id }: { id: string }) => ({ + attributes: { + role: 'tab', + tabIndex: 0, + 'data-sortable-id': id + }, + listeners: { + onPointerDown: vi.fn() + }, + setNodeRef: vi.fn() + }) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ + children, + className, + side, + sideOffset + }: { + children: ReactNode + className?: string + side?: string + sideOffset?: number + }) => ( +
+ {children} +
+ ), + TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => { + if (asChild && isValidElement(children)) { + return cloneElement(children as ReactElement>, { + 'data-tooltip-trigger': 'true' + }) + } + return {children} + } +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSeparator: () => null, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: Record) => +})) + +vi.mock('../sidebar/WorktreeCardHelpers', () => ({ + FilledBellIcon: () => +})) + +vi.mock('./shell-icons', () => ({ + ShellIcon: () => +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { unreadTerminalTabs: Record }) => unknown) => + selector({ unreadTerminalTabs: {} }) +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openMarkdownPreview: () => void + settings: null + unreadTerminalTabs: Record + }) => unknown + ) => selector({ openMarkdownPreview: vi.fn(), settings: null, unreadTerminalTabs: {} }) +})) + +vi.mock('@/store/selectors', () => ({ + useRepoById: () => ({ connectionId: null }), + useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' }) +})) + +vi.mock('../browser-pane/browser-runtime', () => ({ + getLiveBrowserUrl: () => 'https://live.example/not-the-tab-label' +})) + +vi.mock('@/lib/file-type-icons', () => ({ + getFileTypeIcon: () => + function FileIcon() { + return + } +})) + +vi.mock('@/lib/rename-file', () => ({ + renameFileOnDisk: vi.fn() +})) + +vi.mock('@/lib/language-detect', () => ({ + detectLanguage: () => 'typescript' +})) + +vi.mock('@/components/editor/markdown-preview-controls', () => ({ + canOpenMarkdownPreview: () => false +})) + +vi.mock('@/lib/local-path-open-guard', () => ({ + showLocalPathOpenBlockedToast: vi.fn() +})) + +vi.mock('./editor-tab-local-open-guard', () => ({ + shouldBlockEditorTabLocalOpen: () => false +})) + +function makeDragData(tabType: TabDragItemData['tabType'], visibleTabId: string): TabDragItemData { + return { + kind: 'tab', + worktreeId: 'wt-1', + groupId: 'group-1', + unifiedTabId: `unified-${visibleTabId}`, + visibleTabId, + tabType, + label: visibleTabId + } +} + +function openingTag(markup: string, attr: string, value: string): string { + const match = markup.match(new RegExp(`]*${attr}="${value}")[^>]*>`)) + if (!match) { + throw new Error(`opening div with ${attr}="${value}" not found in ${markup}`) + } + return match[0] +} + +function expectTooltipContent(markup: string, text: string): void { + expect(markup).toContain('data-tooltip-content="true"') + expect(markup).toContain('data-side="bottom"') + expect(markup).toContain('data-side-offset="6"') + expect(markup).toContain('max-w-80 whitespace-normal break-words text-left') + expect(markup).toContain(text) +} + +function makeTerminalTab(overrides: Partial = {}): TerminalTab { + return { + id: 'terminal-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Runtime terminal title', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function makeBrowserTab(overrides: Partial = {}): BrowserTabState { + return { + id: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.com/docs/long-browser-tab-path', + title: 'Browser title', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0, + ...overrides + } +} + +function makeEditorFile(overrides: Partial = {}): OpenFile & { + tabId?: string +} { + return { + id: '/repo/src/components/VeryLongEditorFileName.tsx', + tabId: 'editor-tab-1', + filePath: '/repo/src/components/VeryLongEditorFileName.tsx', + relativePath: 'src/components/VeryLongEditorFileName.tsx', + worktreeId: 'wt-1', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +describe('tab title tooltips', () => { + it('uses the terminal custom title for the visible label and tooltip trigger content', () => { + const markup = renderToStaticMarkup( + + ) + + expectTooltipContent(markup, 'Custom terminal title') + expect(markup).not.toContain('Runtime terminal title') + const root = openingTag(markup, 'data-testid', 'sortable-tab') + expect(root).toContain('data-tooltip-trigger="true"') + expect(root).toContain('role="tab"') + expect(root).toContain('tabindex="0"') + }) + + it('uses the browser tab fallback label from the tab prop, not the live URL', () => { + const markup = renderToStaticMarkup( + + ) + + expectTooltipContent(markup, 'example.com/docs/long-browser-tab-path') + expect(markup).not.toContain('live.example') + const root = openingTag(markup, 'data-sortable-id', 'browser-1') + expect(root).toContain('data-tooltip-trigger="true"') + expect(root).toContain('role="tab"') + expect(root).toContain('tabindex="0"') + }) + + it('uses the editor display label while leaving adjacent adornments outside the label', () => { + const markup = renderToStaticMarkup( + ()} + onActivate={vi.fn()} + onClose={vi.fn()} + onCloseToRight={vi.fn()} + onCloseAll={vi.fn()} + onPin={vi.fn()} + onSplitGroup={vi.fn()} + dragData={makeDragData('editor', 'editor-tab-1')} + /> + ) + + expectTooltipContent(markup, 'VeryLongEditorFileName.tsx') + expect(markup).toContain('line-through') + expect(markup).toContain('renamed') + const root = openingTag(markup, 'data-sortable-id', 'editor-tab-1') + expect(root).toContain('data-tooltip-trigger="true"') + expect(root).toContain('role="tab"') + expect(root).toContain('tabindex="0"') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4071bee4810..c021047331c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -74,6 +74,7 @@ import { TerminalQuickCommandDialog } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' import { keybindingMatchesAction } from '../../../../shared/keybindings' +import { pasteTerminalClipboard } from './terminal-clipboard-paste' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -314,8 +315,14 @@ export default function TerminalPane({ const clearTabPtyId = useAppStore((store) => store.clearTabPtyId) const markWorktreeUnread = useAppStore((store) => store.markWorktreeUnread) const markTerminalTabUnread = useAppStore((store) => store.markTerminalTabUnread) + const markTerminalPaneUnread = useAppStore((store) => store.markTerminalPaneUnread) const clearWorktreeUnread = useAppStore((store) => store.clearWorktreeUnread) const clearTerminalTabUnread = useAppStore((store) => store.clearTerminalTabUnread) + const clearTerminalPaneUnread = useAppStore((store) => store.clearTerminalPaneUnread) + const unreadTerminalPanes = useAppStore((store) => store.unreadTerminalPanes) + const terminalAttentionEnabled = useAppStore( + (store) => store.settings?.experimentalTerminalAttention === true + ) const openSpacePage = useAppStore((store) => store.openSpacePage) const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace) const settings = useAppStore((store) => store.settings) @@ -700,8 +707,10 @@ export default function TerminalPane({ updateTabPtyId, markWorktreeUnread, markTerminalTabUnread, + markTerminalPaneUnread, clearWorktreeUnread, clearTerminalTabUnread, + clearTerminalPaneUnread, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -903,8 +912,10 @@ export default function TerminalPane({ updateTabPtyId, markWorktreeUnread, markTerminalTabUnread, + markTerminalPaneUnread, clearWorktreeUnread, clearTerminalTabUnread, + clearTerminalPaneUnread, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding @@ -920,8 +931,10 @@ export default function TerminalPane({ dispatchNotification, markWorktreeUnread, markTerminalTabUnread, + markTerminalPaneUnread, clearWorktreeUnread, clearTerminalTabUnread, + clearTerminalPaneUnread, onPtyExitRef, setCacheTimerStartedAt, setRuntimePaneTitle, @@ -1063,36 +1076,17 @@ export default function TerminalPane({ return } - // Shared helper: try text first (fast path, single IPC call for the - // common case), then check for a clipboard image only when text is empty - // — which is the image-only clipboard scenario this fix targets. const pasteFromClipboard = (pane: ManagedPane): void => { - void window.api.ui - .readClipboardText() - .then((text) => { - if (text) { - pasteTerminalText(pane.terminal, text) - return - } - // Why: clipboard has no text — check for an image. This is the - // image-only clipboard case (e.g. screenshot) where Chromium's paste - // event would never fire on a textarea. We save the image to a temp - // file owned by the terminal host and paste that path. - const connectionId = getConnectionId(worktreeId) ?? null - return window.api.ui - .saveClipboardImageAsTempFile({ connectionId }) - .then((filePath) => { - if (filePath) { - pasteTerminalText(pane.terminal, filePath) - } - }) - .catch((error: unknown) => { - setTerminalError(formatClipboardImagePasteError(error)) - }) - }) - .catch(() => { - /* ignore clipboard failures */ - }) + const connectionId = getConnectionId(worktreeId) ?? null + void pasteTerminalClipboard({ + readClipboardText: window.api.ui.readClipboardText, + saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile, + connectionId, + pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), + onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)) + }).catch(() => { + /* ignore clipboard failures */ + }) } const isMac = navigator.userAgent.includes('Mac') @@ -1182,7 +1176,7 @@ export default function TerminalPane({ }, [isActive, worktreeId, keybindings]) // Why: a click inside the terminal container is a deliberate interaction - // with the pane — dismiss the bell indicator for this tab and worktree + // with the pane — dismiss the attention indicator for this tab and worktree // (ghostty "show until interact" semantics). onData already covers // keystrokes; pointerdown covers the mouse path, including right-click // and middle-click paste, which also count as engagement with the pane. @@ -1202,15 +1196,36 @@ export default function TerminalPane({ if (!container) { return } - const onPointerDown = (): void => { + const onPointerDown = (event: PointerEvent): void => { clearTerminalTabUnread(tabId) clearWorktreeUnread(worktreeId) + const paneElement = + event.target instanceof Element ? event.target.closest('.pane[data-leaf-id]') : null + const leafId = paneElement?.getAttribute('data-leaf-id') + if (leafId) { + clearTerminalPaneUnread(makePaneKey(tabId, leafId)) + } } container.addEventListener('pointerdown', onPointerDown, { capture: true }) return () => { container.removeEventListener('pointerdown', onPointerDown, { capture: true }) } - }, [tabId, worktreeId, clearTerminalTabUnread, clearWorktreeUnread]) + }, [tabId, worktreeId, clearTerminalTabUnread, clearTerminalPaneUnread, clearWorktreeUnread]) + + useLayoutEffect(() => { + const manager = managerRef.current + if (!manager) { + return + } + for (const pane of manager.getPanes()) { + const paneKey = makePaneKey(tabId, pane.leafId) + if (terminalAttentionEnabled && unreadTerminalPanes[paneKey]) { + pane.container.setAttribute('data-terminal-attention', '') + } else { + pane.container.removeAttribute('data-terminal-attention') + } + } + }, [tabId, paneCount, terminalAttentionEnabled, unreadTerminalPanes]) // Sync the data-has-title attribute on pane containers when titles change, // and reflow terminals so safeFit() sees the correct available height. diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 93c272a5b0f..e5b653a57e3 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -42,8 +42,9 @@ export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { // 1000/1002/1003/1006 — mouse reporting variants // 1004 — focus event reporting (the actual bug source) // 2004 — bracketed paste -export const POST_REPLAY_MODE_RESET = - '\x1b[0 q\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l' +export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' + +export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the // full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still @@ -63,7 +64,7 @@ export const POST_REPLAY_MODE_RESET = // 1004 — focus event reporting: preserving `?1004h` makes restored shells // ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` // as unbound key input). -export const POST_REPLAY_REATTACH_RESET = '\x1b[0 q\x1b[?25h\x1b[?1004l' +export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 23b7a8d4ad1..d7703f8d382 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -35,8 +35,10 @@ export type PtyConnectionDeps = { updateTabPtyId: (tabId: string, ptyId: string) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void + markTerminalPaneUnread: (paneKey: string) => void clearWorktreeUnread: (worktreeId: string) => void clearTerminalTabUnread: (tabId: string) => void + clearTerminalPaneUnread: (paneKey: string) => void // Why: the renderer dispatches two notification sources — BEL from the PTY // byte stream and agent-task-complete on the working→idle title transition. // shared/types.ts keeps a wider NotificationEventSource union because the diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 33825de605c..d6f18855bf8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1,9 +1,14 @@ /* oxlint-disable max-lines */ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET } from './layout-serialization' +import { + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_TERMINAL_CURSOR_STYLE +} from './layout-serialization' import type * as UseNotificationDispatchModule from './use-notification-dispatch' import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' // Why: the fresh-spawn and reattach paths now chain pre-signal → spawn → // register/settle through multiple microtasks. Tests that previously flushed @@ -24,8 +29,10 @@ function leafIdForPane(paneId: number): string { } type StoreState = { + activeWorktreeId: string | null tabsByWorktree: Record ptyIdsByTabId?: Record + terminalLayoutsByTabId?: Record unreadTerminalTabs?: Record worktreesByRepo: Record< string, @@ -44,6 +51,7 @@ type StoreState = { settings: { promptCacheTimerEnabled?: boolean activeRuntimeEnvironmentId?: string | null + experimentalTerminalAttention?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -64,9 +72,12 @@ type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record + markWorktreeUnread: ReturnType setAgentStatus: ReturnType removeAgentStatus: ReturnType dropAgentStatus: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType } type ConnectCallbacks = { @@ -302,8 +313,10 @@ function createDeps(overrides: Record = {}) { updateTabPtyId: vi.fn(), markWorktreeUnread: vi.fn(), markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), clearWorktreeUnread: vi.fn(), clearTerminalTabUnread: vi.fn(), + clearTerminalPaneUnread: vi.fn(), dispatchNotification: vi.fn(), setCacheTimerStartedAt: vi.fn(), syncPanePtyLayoutBinding: vi.fn(), @@ -379,12 +392,21 @@ describe('connectPanePty', () => { createdTransportOptions = [] storeSubscribers = [] mockStoreState = { + activeWorktreeId: 'wt-1', tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] }, ptyIdsByTabId: { 'tab-1': ['tab-pty'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'tab-pty' } + } + }, unreadTerminalTabs: {}, worktreesByRepo: { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', displayName: 'feat/notis' }] @@ -392,7 +414,7 @@ describe('connectPanePty', () => { repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true }, + settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -402,6 +424,7 @@ describe('connectPanePty', () => { consumePendingSnapshot: vi.fn(() => null), runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, + markWorktreeUnread: vi.fn(), setAgentStatus: vi.fn((paneKey: string, payload: Record) => { mockStoreState.agentStatusByPaneKey[paneKey] = { ...payload, @@ -412,7 +435,9 @@ describe('connectPanePty', () => { } }), removeAgentStatus: vi.fn(), - dropAgentStatus: vi.fn() + dropAgentStatus: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn() } as StoreState ;(globalThis as unknown as { window: unknown }).window = { api: { @@ -3249,10 +3274,9 @@ describe('connectPanePty', () => { // Why: BEL (0x07) is the attention signal. connectPanePty wires an // onBell handler that raises the worktree unread dot, the tab-level - // bell indicator, and an OS notification. Under the ghostty - // show-until-interact model, the unread flags clear when the user - // actually interacts with the pane — keystroke via xterm onData or - // pointerdown on the container (see TerminalPane.tsx). This test + // indicator, the pane marker, and an OS notification. The unread flags + // clear when the user actually interacts with the pane — keystroke via + // xterm onData or pointerdown on the container (see TerminalPane.tsx). This test // locks in the mark wiring; separate tests below cover the clear path. it('wires onBell to raise worktree unread, tab unread, and OS notification', async () => { const { connectPanePty } = await import('./pty-connection') @@ -3275,6 +3299,7 @@ describe('connectPanePty', () => { expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.markTerminalPaneUnread).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) expect(deps.dispatchNotification).not.toHaveBeenCalled() vi.advanceTimersByTime(250) expect(deps.dispatchNotification).toHaveBeenCalledWith( @@ -3285,6 +3310,33 @@ describe('connectPanePty', () => { ) }) + it('does not raise pane attention on bell when the experimental setting is disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + if (!bellHandler) { + throw new Error('Expected onBell to be registered') + } + + bellHandler() + + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( @@ -3921,6 +3973,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect(deps.clearTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.clearTerminalPaneUnread).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) expect(deps.clearWorktreeUnread).toHaveBeenCalledWith('wt-1') expect(transport.sendInput).toHaveBeenCalledWith('a') }) @@ -4146,10 +4199,11 @@ describe('connectPanePty', () => { }) // Why: the working→idle transition fires an 'agent-task-complete' OS - // notification (user-toggleable in Settings) but MUST NOT raise tab/worktree - // unread — those stay BEL-only so non-agent long-running tasks remain - // first-class attention sources. Double-firing with a concurrent BEL is - // collapsed by the per-worktree dedupe in main/ipc/notifications.ts. + // notification (user-toggleable in Settings) and raises the same visual + // attention marker as BEL so agents that don't emit BEL still leave a + // findable terminal highlight. Double-firing with a concurrent BEL is + // idempotent in the unread stores and collapsed by the per-worktree dedupe + // in main/ipc/notifications.ts. // // This test deliberately wires the real useNotificationDispatch hook into // connectPanePty instead of a vi.fn() stub. A stub would let the producer @@ -4157,7 +4211,7 @@ describe('connectPanePty', () => { // routing through the real hook to window.api.notifications.dispatch means // removing the producer breaks the IPC assertion, which is the user-facing // contract. - it('dispatches agent-task-complete on working→idle but does not raise tab/worktree unread', async () => { + it('dispatches agent-task-complete on working→idle and raises tab/worktree unread', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( './use-notification-dispatch' @@ -4193,6 +4247,7 @@ describe('connectPanePty', () => { { id: 'wt-2', repoId: 'repo2', path: '/tmp/wt-2', displayName: 'feat/other' } ] mockStoreState.repos.push({ id: 'repo2', connectionId: null, displayName: 'docs' }) + mockStoreState.activeWorktreeId = 'wt-2' const pane = createPane(1) const manager = createManager(1) @@ -4210,8 +4265,8 @@ describe('connectPanePty', () => { vi.useFakeTimers() idleHandler('* Claude done') - expect(deps.markWorktreeUnread).not.toHaveBeenCalled() - expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() expect(dispatchNotification).not.toHaveBeenCalled() vi.advanceTimersByTime(250) @@ -4222,6 +4277,9 @@ describe('connectPanePty', () => { terminalTitle: '* Claude done', paneKey }) + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith('wt-1') + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(paneKey) expect(window.api.notifications.dispatch).toHaveBeenCalledWith( expect.objectContaining({ source: 'agent-task-complete', @@ -4241,6 +4299,67 @@ describe('connectPanePty', () => { ) }) + it('resets renderer cursor style when an agent becomes idle', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + + it('queues the idle cursor reset behind hidden agent output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + vi.useFakeTimers() + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!capturedDataCallback.current || !idleHandler) { + throw new Error('Expected PTY data and idle handlers to be registered') + } + + capturedDataCallback.current('\x1b[6 q') + idleHandler('* Codex done') + + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith(`\x1b[6 q${RESET_TERMINAL_CURSOR_STYLE}`) + }) + it('waits briefly for delayed agent status before dispatching task-complete', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5d2f61b1787..ef1383d37cf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -18,7 +18,11 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal } from './replay-guard' import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script' -import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET } from './layout-serialization' +import { + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_TERMINAL_CURSOR_STYLE +} from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' @@ -242,6 +246,17 @@ export function connectPanePty( let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false + // Why: idle callbacks are registered before the deferred PTY output plumbing + // exists. Start with the shared scheduler, then switch to the PTY writer + // below so hidden-tab resets keep backlog-recovery callbacks and byte order. + let queueAgentIdleCursorReset = (): void => { + if (disposed) { + return + } + writeTerminalOutput(pane.terminal, RESET_TERMINAL_CURSOR_STYLE, { + foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current) + }) + } // Why: passphrase-gate waits register a teardown here so dispose() can // actively unsubscribe + resolve them. Without this, a pane disposed // mid-wait leaks its zustand subscriber and the surrounding async IIFE @@ -690,11 +705,9 @@ export function connectPanePty( } // ─── Attention signal: BEL ──────────────────────────────────────────── // - // BEL (0x07) is the attention signal. A BEL raises both the tab-level - // bell indicator and the worktree-level dot, and fires an OS - // notification. The unread flag clears when the user activates the tab - // (see activateTab / focusGroup in the terminals slice) — the bell - // auto-clears on focus/keystroke. + // BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level + // indicators, and fires an OS notification. The experimental pane marker + // clears when the user interacts with the exact pane. // // The one case where BEL falsely fires is when a crashed TUI left DEC // private mode 1004 (focus event reporting) enabled — pane clicks then @@ -711,6 +724,9 @@ export function connectPanePty( // decision higher up, not a transport-layer guess. deps.markWorktreeUnread(deps.worktreeId) deps.markTerminalTabUnread(deps.tabId) + if (useAppStore.getState().settings?.experimentalTerminalAttention === true) { + deps.markTerminalPaneUnread(cacheKey) + } // Why: agent CLIs often emit BEL in the same completion burst as their // working->idle title change. Delay only the OS notification so the richer // agent-complete notification can win the main-process worktree cooldown. @@ -844,21 +860,18 @@ export function connectPanePty( } }) - // ─── Agent task-complete: OS notification, not tab attention ────────── + // ─── Agent task-complete: notification-backed attention ─────────────── // // The working→idle title transition drives two independent concerns: // 1. The Claude prompt-cache countdown in the sidebar. // 2. The "Agent Task Complete" OS notification users toggle in Settings. // - // We intentionally do NOT raise tab/worktree unread from here — that - // remains BEL-only so non-agent long-running tasks stay first-class and - // so unread state only reflects what the terminal byte stream actually - // signals. OS notifications are a separate channel: not every agent CLI - // reliably emits BEL on completion (Gemini, some Codex flows), and - // without this dispatch the Settings toggle would have zero producers. - // Double-firing with a concurrent BEL is handled by delaying the BEL OS - // notification below; main still keeps a 5 s per-worktree dedupe as the - // final guard. + // This path raises the same terminal attention marker as BEL through the + // shared notification dispatcher. Not every agent CLI reliably emits BEL on + // completion (Gemini, some Codex flows), and the highlight needs to remain + // findable after the OS banner is gone. Double-firing with a concurrent BEL + // is handled by delaying the BEL OS notification below; main still keeps a + // 5 s per-worktree dedupe as the final guard. const onAgentBecameIdle = (title: string): void => { // Why: only start the prompt-cache countdown for Claude agents — other // agents have different (or no) prompt-caching semantics and showing a @@ -877,6 +890,9 @@ export function connectPanePty( if (syncAgentTaskCompleteNotificationEnabled()) { agentCompletionCoordinator.observeClassifiedTitleCompletion(title) } + // Why: some agent TUIs leave xterm in DECSCUSR steady-cursor mode when + // they become idle. Reset to Orca's configured cursor once the turn ends. + queueAgentIdleCursorReset() } const onAgentBecameWorking = (): void => { if (syncAgentTaskCompleteNotificationEnabled()) { @@ -1022,10 +1038,10 @@ export function connectPanePty( return } // Why: a real keystroke into the terminal is the unambiguous "user is - // here" signal that dismisses the bell (ghostty "show until interact"). - // Guarded by the replay and codex-stale checks above so synthetic xterm - // auto-replies never count as interaction. + // here" signal that dismisses attention. Guarded by the replay and + // codex-stale checks above so synthetic xterm auto-replies never count. deps.clearTerminalTabUnread(deps.tabId) + deps.clearTerminalPaneUnread(cacheKey) deps.clearWorktreeUnread(deps.worktreeId) if (shouldSuppressForegroundCursor) { // Why: native Windows ConPTY can leave the old visual cursor painted @@ -1343,6 +1359,16 @@ export function connectPanePty( }) } + queueAgentIdleCursorReset = (): void => { + if (disposed) { + return + } + writePtyOutputToXterm( + RESET_TERMINAL_CURSOR_STYLE, + shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + } + function markHiddenOutputRestoreNeeded(): void { const ptyId = transport.getPtyId() if (!canUseMainBufferSnapshot(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 1e3c444c808..03fe661f9d5 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -154,6 +154,13 @@ export function subscribeToPtyExit(ptyId: string, watcher: (code: number) => voi export type EagerPtyHandle = { flush: () => string; dispose: () => void } const eagerPtyHandles = new Map() +const eagerBufferTextEncoder = new TextEncoder() +const eagerBufferTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true }) + +type EagerBufferChunk = { + data: string + bytes: number +} export function getEagerPtyBufferHandle(ptyId: string): EagerPtyHandle | undefined { return eagerPtyHandles.get(ptyId) @@ -164,22 +171,47 @@ export function getEagerPtyBufferHandle(ptyId: string): EagerPtyHandle | undefin // runs a long-lived command (e.g. tail -f) in a worktree the user never opens. const EAGER_BUFFER_MAX_BYTES = 512 * 1024 +function clampUtf8Tail(data: string, maxBytes: number): EagerBufferChunk { + const encoded = eagerBufferTextEncoder.encode(data) + if (encoded.byteLength <= maxBytes) { + return { data, bytes: encoded.byteLength } + } + let start = encoded.byteLength - maxBytes + while (start < encoded.byteLength && (encoded[start] & 0xc0) === 0x80) { + start += 1 + } + const tail = eagerBufferTextDecoder.decode(encoded.subarray(start)) + return { data: tail, bytes: encoded.byteLength - start } +} + export function registerEagerPtyBuffer( ptyId: string, onExit: (ptyId: string, code: number) => void ): EagerPtyHandle { ensurePtyDispatcher() - const buffer: string[] = [] + // Why: a head index instead of Array.shift() — shift() is O(n), making + // pre-attach buffering quadratic under many small chunks. Compaction is deferred. + const chunks: EagerBufferChunk[] = [] + let head = 0 let bufferBytes = 0 const dataHandler = (data: string): void => { - buffer.push(data) - bufferBytes += data.length - // Trim from the front when the buffer exceeds the cap, keeping the - // most recent output which contains the shell prompt. - while (bufferBytes > EAGER_BUFFER_MAX_BYTES && buffer.length > 1) { - bufferBytes -= buffer.shift()!.length + // A single chunk larger than the cap would otherwise bypass trimming and + // store the whole payload; keep only its most-recent tail. + const chunk = clampUtf8Tail(data, EAGER_BUFFER_MAX_BYTES) + chunks.push(chunk) + bufferBytes += chunk.bytes + // Drop whole leading chunks (keeping the prompt-bearing tail) until within cap. + while (bufferBytes > EAGER_BUFFER_MAX_BYTES && head < chunks.length - 1) { + bufferBytes -= chunks[head].bytes + chunks[head] = { data: '', bytes: 0 } + head += 1 + } + // Compact when dead slots reach half the array so it can't grow unbounded. + if (head > 0 && head * 2 >= chunks.length) { + chunks.splice(0, head) + head = 0 } } const exitHandler = (code: number): void => { @@ -197,8 +229,13 @@ export function registerEagerPtyBuffer( const handle: EagerPtyHandle = { flush() { - const data = buffer.join('') - buffer.length = 0 + const data = chunks + .slice(head) + .map((chunk) => chunk.data) + .join('') + chunks.length = 0 + head = 0 + bufferBytes = 0 return data }, dispose() { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 4fa8ff492df..27c4d3415c2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -124,6 +124,113 @@ describe('createIpcPtyTransport', () => { } }) + it('limits deferred PTY side-effect work per timer tick', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const processor = createPtyOutputProcessor({ onTitleChange }) + const callbacks = { onData: vi.fn() } + + for (let i = 0; i < 200; i++) { + processor.processData(`\x1b]0;title-${i}\x07`, callbacks) + } + + expect(onTitleChange).not.toHaveBeenCalled() + await vi.runOnlyPendingTimersAsync() + + expect(onTitleChange.mock.calls.length).toBeGreaterThan(0) + expect(onTitleChange.mock.calls.length).toBeLessThan(200) + + await vi.runAllTimersAsync() + expect(onTitleChange).toHaveBeenCalledTimes(200) + expect(onTitleChange).toHaveBeenLastCalledWith('title-199', 'title-199') + } finally { + vi.useRealTimers() + } + }) + + it('limits coalesced OSC titles in one PTY chunk per timer tick', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const processor = createPtyOutputProcessor({ onTitleChange }) + const callbacks = { onData: vi.fn() } + const titles = Array.from({ length: 200 }, (_, i) => `\x1b]0;chunk-title-${i}\x07`).join('') + + processor.processData(titles, callbacks) + await vi.runOnlyPendingTimersAsync() + + expect(onTitleChange.mock.calls.length).toBeGreaterThan(0) + expect(onTitleChange.mock.calls.length).toBeLessThan(200) + + await vi.runAllTimersAsync() + expect(onTitleChange).toHaveBeenCalledTimes(200) + expect(onTitleChange).toHaveBeenLastCalledWith('chunk-title-199', 'chunk-title-199') + } finally { + vi.useRealTimers() + } + }) + + it('flushes all remaining PTY side effects after a partial bounded drain', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const processor = createPtyOutputProcessor({ onTitleChange }) + const callbacks = { onData: vi.fn() } + + for (let i = 0; i < 200; i++) { + processor.processData(`\x1b]0;flush-title-${i}\x07`, callbacks) + } + + await vi.runOnlyPendingTimersAsync() + expect(onTitleChange.mock.calls.length).toBeLessThan(200) + + processor.flushPendingSideEffects() + + expect(onTitleChange).toHaveBeenCalledTimes(200) + expect(onTitleChange).toHaveBeenLastCalledWith('flush-title-199', 'flush-title-199') + } finally { + vi.useRealTimers() + } + }) + + it('still runs stale-title detection when an OSC status chunk has no title', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onAgentStatus = vi.fn() + const onAgentBecameIdle = vi.fn() + const processor = createPtyOutputProcessor({ + onTitleChange, + onAgentStatus, + onAgentBecameIdle + }) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]0;. Claude working\x07', callbacks) + processor.processData( + '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07plain output\r\n', + callbacks + ) + + await vi.runOnlyPendingTimersAsync() + expect(onAgentStatus).toHaveBeenCalledWith({ + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + + vi.advanceTimersByTime(3_000) + expect(onAgentBecameIdle).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('uses acknowledged writes only for local IPC PTYs', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const localTransport = createIpcPtyTransport({}) @@ -242,6 +349,90 @@ describe('createIpcPtyTransport', () => { expect(onBell).toHaveBeenCalledTimes(1) }) + it('bounds the eager buffer to its cap and keeps the most recent output', async () => { + const { registerEagerPtyBuffer } = await import('./pty-transport') + const cap = 512 * 1024 + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + + // 8 x 100 KB = 800 KB of distinct chunks, exceeding the 512 KB cap; the + // earliest chunks must be dropped while the prompt-bearing tail is kept. + for (let i = 0; i < 8; i += 1) { + onData?.({ id: 'pty-restored', data: String.fromCharCode(65 + i).repeat(100 * 1024) }) + } + onData?.({ id: 'pty-restored', data: 'PROMPT$' }) + + const flushed = handle.flush() + expect(flushed.length).toBeLessThanOrEqual(cap) + expect(flushed.endsWith('PROMPT$')).toBe(true) + expect(flushed).not.toContain('A') // oldest chunk trimmed + }) + + it('caps a single oversized eager chunk to its most-recent tail', async () => { + const { registerEagerPtyBuffer } = await import('./pty-transport') + const cap = 512 * 1024 + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + + // One chunk larger than the cap must not be stored whole. + onData?.({ id: 'pty-restored', data: `${'x'.repeat(cap)}TAIL$` }) + + const flushed = handle.flush() + expect(flushed.length).toBeLessThanOrEqual(cap) + expect(flushed.endsWith('TAIL$')).toBe(true) + }) + + it('enforces the eager buffer cap in UTF-8 bytes for multi-byte output', async () => { + const { registerEagerPtyBuffer } = await import('./pty-transport') + const cap = 512 * 1024 + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + + onData?.({ id: 'pty-restored', data: `${'界'.repeat(cap)}PROMPT$` }) + + const flushed = handle.flush() + expect(new TextEncoder().encode(flushed).byteLength).toBeLessThanOrEqual(cap) + expect(flushed.endsWith('PROMPT$')).toBe(true) + }) + + it('preserves a BOM when it starts the retained oversized eager-buffer tail', async () => { + const { registerEagerPtyBuffer } = await import('./pty-transport') + const cap = 512 * 1024 + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + + onData?.({ id: 'pty-restored', data: `${'x'.repeat(16)}\uFEFF${'y'.repeat(cap - 3)}` }) + + const flushed = handle.flush() + expect(new TextEncoder().encode(flushed).byteLength).toBe(cap) + expect(flushed.startsWith('\uFEFF')).toBe(true) + }) + + it('does not use Array.shift while trimming many eager chunks', async () => { + const { registerEagerPtyBuffer } = await import('./pty-transport') + const handle = registerEagerPtyBuffer('pty-restored', vi.fn()) + const originalShift = Array.prototype.shift + + try { + // Why: this hot path used to call Array.shift() once per trim, which + // reindexed the live buffer and made many small chunks quadratic. + Object.defineProperty(Array.prototype, 'shift', { + configurable: true, + writable: true, + value() { + throw new Error('Array.shift should not be used by the eager buffer') + } + }) + for (let i = 0; i < 2048; i += 1) { + onData?.({ id: 'pty-restored', data: 'x'.repeat(1024) }) + } + } finally { + Object.defineProperty(Array.prototype, 'shift', { + configurable: true, + writable: true, + value: originalShift + }) + } + + expect(handle.flush().length).toBeLessThanOrEqual(512 * 1024) + }) + it('routes eager-buffered bytes through onReplayData so the renderer can engage the replay guard', async () => { const { createIpcPtyTransport, registerEagerPtyBuffer } = await import('./pty-transport') diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index b88a70107a8..d8557f62bd8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -45,6 +45,7 @@ export { extractLastOscTitle } from '../../../../shared/agent-detection' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared +const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -99,6 +100,7 @@ export function createPtyOutputProcessor({ let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] + let pendingSideEffectIndex = 0 const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -140,6 +142,33 @@ export function createPtyOutputProcessor({ } } + function scheduleSideEffectDrain(): void { + if (sideEffectDrainTimer !== null) { + return + } + // Why: xterm.write() buffers parsing onto its own timer. Defer Orca's + // title/status/BEL store work so live terminal rendering gets the next turn. + sideEffectDrainTimer = setTimeout(drainPtySideEffects, 0) + } + + function enqueuePtySideEffect(next: PendingPtySideEffect): void { + const prior = pendingSideEffects.at(-1) + if ( + prior && + prior.titles.length === 0 && + prior.payloads.length === 0 && + !prior.containsBell && + prior.suppressAttentionEvents === next.suppressAttentionEvents && + next.titles.length === 0 && + next.payloads.length === 0 && + !next.containsBell + ) { + prior.scannedForTitles ||= next.scannedForTitles + return + } + pendingSideEffects.push(next) + } + function schedulePtySideEffects( data: string, payloads: ReturnType['payloads'], @@ -156,36 +185,56 @@ export function createPtyOutputProcessor({ return } - const prior = pendingSideEffects.at(-1) - if ( - prior && - prior.titles.length === 0 && - prior.payloads.length === 0 && - !prior.containsBell && - prior.suppressAttentionEvents === suppressAttentionEvents && - titles.length === 0 && - deliveredPayloads.length === 0 && - !containsBell - ) { - prior.scannedForTitles ||= scannedForTitles - } else { - // Why: keep only compact derived side-effect facts here. Retaining raw - // PTY chunks duplicates the terminal scheduler backlog while timers are - // throttled in a backgrounded Electron window. - pendingSideEffects.push({ - titles, - payloads: deliveredPayloads, + // Why: keep only compact derived side-effect facts here. Retaining raw + // PTY chunks duplicates the terminal scheduler backlog while timers are + // throttled in a backgrounded Electron window. + if (deliveredPayloads.length === 0 && titles.length === 0) { + enqueuePtySideEffect({ + payloads: [], + titles: [], scannedForTitles, containsBell, suppressAttentionEvents }) + } else { + for (const payload of deliveredPayloads) { + enqueuePtySideEffect({ + payloads: [payload], + titles: [], + scannedForTitles: false, + containsBell: false, + suppressAttentionEvents + }) + } + if (titles.length === 0 && scannedForTitles) { + enqueuePtySideEffect({ + payloads: [], + titles: [], + scannedForTitles: true, + containsBell: false, + suppressAttentionEvents + }) + } + for (const title of titles) { + enqueuePtySideEffect({ + payloads: [], + titles: [title], + scannedForTitles, + containsBell: false, + suppressAttentionEvents + }) + } + if (containsBell) { + enqueuePtySideEffect({ + payloads: [], + titles: [], + scannedForTitles: false, + containsBell: true, + suppressAttentionEvents + }) + } } - if (sideEffectDrainTimer !== null) { - return - } - // Why: xterm.write() buffers parsing onto its own timer. Defer Orca's - // title/status/BEL store work so live terminal rendering gets the next turn. - sideEffectDrainTimer = setTimeout(drainPtySideEffects, 0) + scheduleSideEffectDrain() } function clearSideEffectDrainTimer(): void { @@ -195,26 +244,58 @@ export function createPtyOutputProcessor({ } } - function drainPtySideEffects(): void { + function compactPendingSideEffectsIfNeeded(force = false): void { + if (pendingSideEffectIndex === 0) { + return + } + if (pendingSideEffectIndex >= pendingSideEffects.length) { + pendingSideEffects = [] + pendingSideEffectIndex = 0 + return + } + if (force || pendingSideEffectIndex >= MAX_PTY_SIDE_EFFECTS_PER_DRAIN * 4) { + pendingSideEffects = pendingSideEffects.slice(pendingSideEffectIndex) + pendingSideEffectIndex = 0 + } + } + + function applyPtySideEffect(next: PendingPtySideEffect): void { + if (onAgentStatus) { + for (const payload of next.payloads) { + onAgentStatus(payload) + } + } + processObservedTitles(next.titles, next.scannedForTitles, next.suppressAttentionEvents) + if (onBell && next.containsBell) { + onBell() + } + } + + function drainPtySideEffects(options: { flushAll?: boolean } = {}): void { sideEffectDrainTimer = null - const effects = pendingSideEffects - pendingSideEffects = [] - for (const next of effects) { - if (onAgentStatus) { - for (const payload of next.payloads) { - onAgentStatus(payload) - } - } - processObservedTitles(next.titles, next.scannedForTitles, next.suppressAttentionEvents) - if (onBell && next.containsBell) { - onBell() + const maxEffects = options.flushAll ? Number.POSITIVE_INFINITY : MAX_PTY_SIDE_EFFECTS_PER_DRAIN + let processed = 0 + while (pendingSideEffectIndex < pendingSideEffects.length && processed < maxEffects) { + const next = pendingSideEffects[pendingSideEffectIndex] + if (!next) { + break } + pendingSideEffectIndex += 1 + processed += 1 + applyPtySideEffect(next) + } + compactPendingSideEffectsIfNeeded(options.flushAll === true) + if (pendingSideEffectIndex < pendingSideEffects.length) { + // Why: long-idle agent CLIs can queue thousands of OSC title/status + // facts while Chromium throttles timers. Bound each callback so cursor + // blink, paint, and terminal input get chances to run between batches. + scheduleSideEffectDrain() } } function flushPendingSideEffects(): void { clearSideEffectDrainTimer() - drainPtySideEffects() + drainPtySideEffects({ flushAll: true }) } function processObservedTitles( @@ -285,6 +366,7 @@ export function createPtyOutputProcessor({ function clearAccumulatedState(): void { clearSideEffectDrainTimer() pendingSideEffects.length = 0 + pendingSideEffectIndex = 0 clearStaleTitleTimer() agentTracker?.reset() bellDetector.reset() diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts index 2144d0e5225..dcedee5eace 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts @@ -48,6 +48,53 @@ describe('terminal bracketed paste policy', () => { expect(observedIgnoreValues).toEqual([false]) }) + it('forces bracketed paste behavior when requested after Ctrl+C', () => { + const terminal = createTerminal(true) + const observedIgnoreValues: (boolean | undefined)[] = [] + terminal.paste.mockImplementation(() => { + observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode) + }) + + markTerminalBracketedPasteInterrupted(terminal) + pasteTerminalText(terminal, '/tmp/orca-paste-1760000000000-id.png', { + forceBracketedPaste: true + }) + + expect(terminal.paste).toHaveBeenCalledWith( + '\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~' + ) + expect(observedIgnoreValues).toEqual([true]) + expect(terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + + it('forces bracketed paste behavior even when terminal mode is off', () => { + const terminal = createTerminal(false) + const observedIgnoreValues: (boolean | undefined)[] = [] + terminal.paste.mockImplementation(() => { + observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode) + }) + + pasteTerminalText(terminal, '/tmp/orca-paste-1760000000000-id.png', { + forceBracketedPaste: true + }) + + expect(terminal.paste).toHaveBeenCalledWith( + '\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~' + ) + expect(observedIgnoreValues).toEqual([true]) + expect(terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + + it('renders embedded escape bytes inert when forcing bracketed paste', () => { + const terminal = createTerminal(false) + + pasteTerminalText(terminal, '/tmp/before\x1b[201~after.png', { + forceBracketedPaste: true + }) + + expect(terminal.paste).toHaveBeenCalledWith('\x1b[200~/tmp/before\u241b[201~after.png\x1b[201~') + }) + it('does not change paste behavior when Ctrl+C happened outside bracketed paste mode', () => { const terminal = createTerminal(false) const observedIgnoreValues: (boolean | undefined)[] = [] diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts index 44dbe930aad..68562169019 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts @@ -11,9 +11,15 @@ type PasteTerminal = BracketedPasteTerminal & { paste: (text: string) => void } +type PasteTerminalTextOptions = { + forceBracketedPaste?: boolean +} + const interruptedBracketedPasteTerminals = new WeakSet() const bracketedPasteModeOutputTail = new WeakMap() const ESCAPE = '\u001b' +const BRACKETED_PASTE_START = `${ESCAPE}[200~` +const BRACKETED_PASTE_END = `${ESCAPE}[201~` const BRACKETED_PASTE_MODE_SEQUENCE_RE = /^\[\?(?:\d+;)*2004(?:;\d+)*[hl]/ const BRACKETED_PASTE_MODE_TAIL_MAX = 128 const LINE_BREAK_RE = /[\r\n]/ @@ -28,6 +34,22 @@ function hasBracketedPasteModeSequence(data: string): boolean { return false } +function sanitizeBracketedPasteText(text: string): string { + return text.split(ESCAPE).join('\u241b') +} + +function forceBracketedPaste(terminal: PasteTerminal, text: string): void { + const previousIgnoreBracketedPasteMode = terminal.options.ignoreBracketedPasteMode + terminal.options.ignoreBracketedPasteMode = true + try { + terminal.paste( + `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(text)}${BRACKETED_PASTE_END}` + ) + } finally { + terminal.options.ignoreBracketedPasteMode = previousIgnoreBracketedPasteMode + } +} + export function markTerminalBracketedPasteInterrupted(terminal: BracketedPasteTerminal): void { if (terminal.modes.bracketedPasteMode) { interruptedBracketedPasteTerminals.add(terminal) @@ -50,7 +72,17 @@ export function observeTerminalBracketedPasteModeOutput( } } -export function pasteTerminalText(terminal: PasteTerminal, text: string): void { +export function pasteTerminalText( + terminal: PasteTerminal, + text: string, + options?: PasteTerminalTextOptions +): void { + if (options?.forceBracketedPaste) { + // Why: generated image paths are paste payloads, even when they are a + // single line, so they must bypass stale Ctrl+C plain-text suppression. + forceBracketedPaste(terminal, text) + return + } if (!interruptedBracketedPasteTerminals.has(terminal)) { terminal.paste(text) return @@ -71,7 +103,7 @@ export function pasteTerminalText(terminal: PasteTerminal, text: string): void { // process dies. Single-line paste does not need wrappers, so avoid leaking them. terminal.options.ignoreBracketedPasteMode = true try { - terminal.paste(text.split(ESCAPE).join('\u241b')) + terminal.paste(sanitizeBracketedPasteText(text)) } finally { terminal.options.ignoreBracketedPasteMode = previousIgnoreBracketedPasteMode } diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts new file mode 100644 index 00000000000..e2ea7605564 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' + +import { pasteTerminalClipboard } from './terminal-clipboard-paste' +import { + markTerminalBracketedPasteInterrupted, + pasteTerminalText +} from './terminal-bracketed-paste' + +describe('terminal clipboard paste', () => { + it('forces bracketed paste for generated image-only clipboard paths', async () => { + const pasteText = vi.fn() + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue(''), + saveClipboardImageAsTempFile: vi + .fn() + .mockResolvedValue( + '/var/folders/3l/b7w02vh17tg5r5s3nhhdf3kh0000gn/T/orca-paste-1760000000000-id.png' + ), + pasteText + }) + + expect(pasteText).toHaveBeenCalledWith( + '/var/folders/3l/b7w02vh17tg5r5s3nhhdf3kh0000gn/T/orca-paste-1760000000000-id.png', + { forceBracketedPaste: true } + ) + }) + + it('forces generated image paste onto the native bracketed-paste path after Ctrl+C', async () => { + const observedIgnoreBracketedPasteMode: boolean[] = [] + const terminal = { + modes: { bracketedPasteMode: true }, + options: { ignoreBracketedPasteMode: false }, + paste: vi.fn(() => { + observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode) + }) + } + markTerminalBracketedPasteInterrupted(terminal) + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue(''), + saveClipboardImageAsTempFile: vi + .fn() + .mockResolvedValue('/tmp/orca-paste-1760000000000-id.png'), + pasteText: (text, options) => pasteTerminalText(terminal, text, options) + }) + + expect(terminal.paste).toHaveBeenCalledWith( + '\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~' + ) + expect(observedIgnoreBracketedPasteMode).toEqual([true]) + expect(terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + + it('forces generated image paste even when xterm bracketed paste mode is off', async () => { + const observedIgnoreBracketedPasteMode: boolean[] = [] + const terminal = { + modes: { bracketedPasteMode: false }, + options: { ignoreBracketedPasteMode: false }, + paste: vi.fn(() => { + observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode) + }) + } + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue(''), + saveClipboardImageAsTempFile: vi + .fn() + .mockResolvedValue('/tmp/orca-paste-1760000000000-id.png'), + pasteText: (text, options) => pasteTerminalText(terminal, text, options) + }) + + expect(terminal.paste).toHaveBeenCalledWith( + '\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~' + ) + expect(observedIgnoreBracketedPasteMode).toEqual([true]) + expect(terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + + it('forwards SSH connection context and bracket-pastes the returned remote image path', async () => { + const pasteText = vi.fn() + const saveClipboardImageAsTempFile = vi + .fn() + .mockResolvedValue('/var/tmp/orca-paste-1760000000000-id.png') + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue(''), + saveClipboardImageAsTempFile, + connectionId: 'ssh-1', + pasteText + }) + + expect(saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) + expect(pasteText).toHaveBeenCalledWith('/var/tmp/orca-paste-1760000000000-id.png', { + forceBracketedPaste: true + }) + }) + + it('bracket-pastes generated image paths without relying on agent detection', async () => { + const pasteText = vi.fn() + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue(''), + saveClipboardImageAsTempFile: vi + .fn() + .mockResolvedValue('/tmp/orca-paste-1760000000000-id.png'), + pasteText + }) + + expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-id.png', { + forceBracketedPaste: true + }) + }) + + it('preserves the text fast path without probing for images', async () => { + const saveClipboardImageAsTempFile = vi.fn() + const pasteText = vi.fn() + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue('hello'), + saveClipboardImageAsTempFile, + pasteText + }) + + expect(pasteText).toHaveBeenCalledWith('hello') + expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled() + }) + + it('keeps normal single-line text paste on the stale Ctrl+C protection path', async () => { + const observedIgnoreBracketedPasteMode: boolean[] = [] + const terminal = { + modes: { bracketedPasteMode: true }, + options: { ignoreBracketedPasteMode: false }, + paste: vi.fn(() => { + observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode) + }) + } + const saveClipboardImageAsTempFile = vi.fn() + markTerminalBracketedPasteInterrupted(terminal) + + await pasteTerminalClipboard({ + readClipboardText: vi.fn().mockResolvedValue('a69ce28e1d092e0c8825cd1a109ac36409962bc1'), + saveClipboardImageAsTempFile, + pasteText: (text, options) => pasteTerminalText(terminal, text, options) + }) + + expect(terminal.paste).toHaveBeenCalledWith('a69ce28e1d092e0c8825cd1a109ac36409962bc1') + expect(observedIgnoreBracketedPasteMode).toEqual([true]) + expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts new file mode 100644 index 00000000000..26ee8e0adba --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts @@ -0,0 +1,43 @@ +type PasteTextOptions = { + forceBracketedPaste?: boolean +} + +type SaveClipboardImageAsTempFile = (args?: { + connectionId?: string | null +}) => Promise + +type PasteTerminalClipboardDeps = { + readClipboardText: () => Promise + saveClipboardImageAsTempFile: SaveClipboardImageAsTempFile + pasteText: (text: string, options?: PasteTextOptions) => void + connectionId?: string | null + onImagePasteError?: (error: unknown) => void +} + +export async function pasteTerminalClipboard({ + readClipboardText, + saveClipboardImageAsTempFile, + pasteText, + connectionId, + onImagePasteError +}: PasteTerminalClipboardDeps): Promise { + const text = await readClipboardText() + if (text) { + pasteText(text) + return + } + + try { + const filePath = await saveClipboardImageAsTempFile({ connectionId }) + if (!filePath) { + return + } + pasteText(filePath, { + // Why: a generated clipboard-image path is terminal image injection, not + // ordinary one-line text. Keep it off the Ctrl+C stale-text paste path. + forceBracketedPaste: true + }) + } catch (error) { + onImagePasteError?.(error) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts index 7680ce980fb..e0713c9145d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts @@ -1,5 +1,5 @@ import type { IBufferLine, IBufferRange } from '@xterm/xterm' -import { extractTerminalFileLinks, resolveTerminalFileLink } from '@/lib/terminal-links' +import { extractTerminalFileLinkCandidates, resolveTerminalFileLink } from '@/lib/terminal-links' import { openDetectedFilePath } from './terminal-file-open-routing' import { buildHardWrappedPathLogicalLineCandidates, @@ -10,9 +10,11 @@ import { type FileLinkHitTestDeps = { startupCwd: string + terminalHomePath?: string | null worktreeId: string worktreePath: string runtimeEnvironmentId?: string | null + pathExistsCache?: Map } export function openFilePathLinkAtBufferPosition( @@ -27,8 +29,17 @@ export function openFilePathLinkAtBufferPosition( } for (const logicalLine of logicalLines) { - for (const parsed of extractTerminalFileLinks(logicalLine.text)) { - const resolved = deps.startupCwd ? resolveTerminalFileLink(parsed, deps.startupCwd) : null + const matches: { + absolutePath: string + line: number | null + column: number | null + pathText: string + cachedExists: boolean | undefined + }[] = [] + for (const parsed of extractTerminalFileLinkCandidates(logicalLine.text)) { + const resolved = deps.startupCwd + ? resolveTerminalFileLink(parsed, deps.startupCwd, deps.terminalHomePath) + : null if (!resolved) { continue } @@ -36,7 +47,23 @@ export function openFilePathLinkAtBufferPosition( if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) { continue } - openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, deps) + const cacheKey = `${deps.runtimeEnvironmentId ?? 'active'}\0${resolved.absolutePath}` + matches.push({ + absolutePath: resolved.absolutePath, + line: resolved.line, + column: resolved.column, + pathText: parsed.pathText, + cachedExists: deps.pathExistsCache?.get(cacheKey) + }) + } + + const cachedMatch = matches + .filter((match) => match.cachedExists) + .sort((a, b) => b.pathText.length - a.pathText.length)[0] + const uncachedMatch = matches.find((match) => match.cachedExists !== false) + const match = cachedMatch ?? uncachedMatch + if (match) { + openDetectedFilePath(match.absolutePath, match.line, match.column, deps) return true } } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 46c4252342b..2ef87325972 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -369,6 +369,31 @@ describe('handleOscLink', () => { ) }) + it('opens tilde OSC file links against explicit terminal home when cwd is outside home', async () => { + setPlatform('Macintosh') + + handleOscLink( + '~/file.ts', + { metaKey: true, ctrlKey: false }, + { + ...deps, + startupCwd: '/workspace/project', + terminalHomePath: '/home/alice' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).toHaveBeenCalledWith({ + targetPath: '/home/alice/file.ts' + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/home/alice/file.ts' + }) + ) + }) + it('stats remote-runtime file links through the active runtime environment', async () => { setPlatform('Macintosh') storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } @@ -593,7 +618,9 @@ describe('createFilePathLinkProvider range bounds', () => { linkProviderDisposablesRef: { current: new Map() }, pathExistsCache: new Map([ ['/repo/CLAUDE.md', true], - ['/repo/package.json', true] + ['/repo/package.json', true], + ['/repo/Folder With Space/content.js', true], + ['/repo/My Folder', true] ]) }, { textContent: '', style: { display: '' } } as unknown as HTMLElement, @@ -748,6 +775,51 @@ describe('createFilePathLinkProvider range bounds', () => { ) }) + it('opens a tilde-prefixed path from a direct modifier-click fallback', async () => { + setPlatform('Macintosh') + + const opened = openFilePathLinkAtBufferPosition( + makeBuffer([makeBufferLine('~/Documents/Path/file_name')]), + { x: 4, y: 1 }, + 80, + { + startupCwd: '/Users/alice/project', + worktreeId: 'wt-1', + worktreePath: '/Users/alice/project', + runtimeEnvironmentId: null + } + ) + await flushAsyncWork() + + expect(opened).toBe(true) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/Users/alice/Documents/Path/file_name' }) + ) + }) + + it('opens a tilde path using explicit terminal home when cwd is outside home', async () => { + setPlatform('Macintosh') + + const opened = openFilePathLinkAtBufferPosition( + makeBuffer([makeBufferLine('~/Documents/Path/file_name')]), + { x: 4, y: 1 }, + 80, + { + startupCwd: '/workspace/project', + terminalHomePath: '/home/alice', + worktreeId: 'wt-1', + worktreePath: '/workspace/project', + runtimeEnvironmentId: null + } + ) + await flushAsyncWork() + + expect(opened).toBe(true) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/home/alice/Documents/Path/file_name' }) + ) + }) + it('opens a wrapped continuation-row html path from a direct modifier-click fallback', async () => { setPlatform('Macintosh') const rows = [ @@ -776,6 +848,65 @@ describe('createFilePathLinkProvider range bounds', () => { ) }) + it('returns one file link for an absolute path containing spaces', async () => { + const pathText = '/repo/Folder With Space/content.js' + const links = await collectLinks(pathText) + + expect(links.map((link) => link.text)).toEqual([pathText]) + expect(links[0].range).toEqual({ + start: { x: 1, y: 1 }, + end: { x: pathText.length, y: 1 } + }) + }) + + it('returns one file link for an extensionless path ending in a spaced segment', async () => { + const pathText = '/repo/My Folder' + const links = await collectLinks(pathText) + + expect(links.map((link) => link.text)).toEqual([pathText]) + expect(links[0].range).toEqual({ + start: { x: 1, y: 1 }, + end: { x: pathText.length, y: 1 } + }) + }) + + it('returns an existing extensionless spaced prefix before trailing prose', async () => { + vi.mocked(window.api.shell.pathExists).mockImplementation(async (pathValue) => { + return pathValue === '/repo/My Folder' + }) + + const links = await collectLinks('see /repo/My Folder now') + + expect(links.map((link) => link.text)).toEqual(['/repo/My Folder']) + }) + + it('opens an existing extensionless spaced prefix from direct fallback cache', async () => { + setPlatform('Macintosh') + const line = 'see /repo/My Folder now' + + const opened = openFilePathLinkAtBufferPosition( + makeBuffer([makeBufferLine(line)]), + { x: line.indexOf('Folder') + 1, y: 1 }, + 80, + { + startupCwd: '/repo', + worktreeId: 'wt-1', + worktreePath: '/repo', + runtimeEnvironmentId: null, + pathExistsCache: new Map([ + ['active\0/repo/My Folder now', false], + ['active\0/repo/My Folder', true] + ]) + } + ) + await flushAsyncWork() + + expect(opened).toBe(true) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/My Folder' }) + ) + }) + it('retries a wrapped file click even when xterm already marked the link active', async () => { setPlatform('Macintosh') const rows = [ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 00d9d8dcc1a..8aa165b5278 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -1,5 +1,9 @@ import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm' -import { extractTerminalFileLinks, resolveTerminalFileLink } from '@/lib/terminal-links' +import { + extractTerminalFileLinkCandidates, + extractTerminalFileLinks, + resolveTerminalFileLink +} from '@/lib/terminal-links' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { isRemoteRuntimeFileOperation, runtimePathExists } from '@/runtime/runtime-file-client' import { @@ -30,6 +34,7 @@ export type LinkHandlerDeps = { linkProviderDisposablesRef: React.RefObject> pathExistsCache: Map runtimeEnvironmentId?: string | null + terminalHomePath?: string | null getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null } @@ -38,6 +43,33 @@ type ProvidedFileLink = { logicalLine: WrappedLogicalLine } +function rangesOverlap(left: ILink['range'], right: ILink['range']): boolean { + const leftStartsAfterRightEnds = + left.start.y > right.end.y || (left.start.y === right.end.y && left.start.x > right.end.x) + const rightStartsAfterLeftEnds = + right.start.y > left.end.y || (right.start.y === left.end.y && right.start.x > left.end.x) + return !leftStartsAfterRightEnds && !rightStartsAfterLeftEnds +} + +function preferLongestNonOverlappingLinks(links: ProvidedFileLink[]): ProvidedFileLink[] { + const selected: ProvidedFileLink[] = [] + const byLengthDescending = [...links].sort( + (a, b) => + b.link.text.length - a.link.text.length || + a.link.range.start.y - b.link.range.start.y || + a.link.range.start.x - b.link.range.start.x + ) + for (const link of byLengthDescending) { + if (!selected.some((existing) => rangesOverlap(existing.link.range, link.link.range))) { + selected.push(link) + } + } + return selected.sort( + (a, b) => + a.link.range.start.y - b.link.range.start.y || a.link.range.start.x - b.link.range.start.x + ) +} + function isMacPlatform(): boolean { return navigator.userAgent.includes('Mac') } @@ -95,9 +127,11 @@ export function createFilePathLinkProvider( void Promise.all( logicalLines.flatMap((logicalLine) => - extractTerminalFileLinks(logicalLine.text).map( + extractTerminalFileLinkCandidates(logicalLine.text).map( async (parsed): Promise => { - const resolved = startupCwd ? resolveTerminalFileLink(parsed, startupCwd) : null + const resolved = startupCwd + ? resolveTerminalFileLink(parsed, startupCwd, deps.terminalHomePath) + : null if (!resolved) { return null } @@ -169,7 +203,7 @@ export function createFilePathLinkProvider( const providedLinks = resolvedLinks.filter( (link): link is ProvidedFileLink => link !== null ) - const links = providedLinks + const links = preferLongestNonOverlappingLinks(providedLinks) .filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint)) .map(({ link }) => link) if (providedLinks.length > 0 && links.length === 0) { @@ -239,9 +273,11 @@ export function installFilePathLinkClickFallback( terminal.cols, { startupCwd: deps.startupCwd, + terminalHomePath: deps.terminalHomePath, worktreeId: deps.worktreeId, worktreePath: deps.worktreePath, - runtimeEnvironmentId + runtimeEnvironmentId, + pathExistsCache: deps.pathExistsCache } ) if (opened) { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 579dc78167e..d13e29297ed 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -12,7 +12,7 @@ export function handleOscLink( rawText: string, event: TerminalLinkEvent | undefined, deps: Pick & - Partial> + Partial> ): void { if (!isTerminalLinkActivation(event)) { return @@ -33,7 +33,11 @@ export function handleOscLink( try { parsed = new URL(rawText) } catch { - const resolved = resolveTerminalFileLinkText(rawText, deps.startupCwd || deps.worktreePath) + const resolved = resolveTerminalFileLinkText( + rawText, + deps.startupCwd || deps.worktreePath, + deps.terminalHomePath + ) if (resolved) { openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, deps) } diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 2f7712be669..721721ece0c 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { dispatchTerminalNotification } from './use-notification-dispatch' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' type MockState = { activeWorktreeId: string | null tabsByWorktree: Record ptyIdsByTabId: Record + terminalLayoutsByTabId: Record browserTabsByWorktree: Record retainedAgentsByPaneKey: Record agentStatusByPaneKey: Record @@ -21,10 +23,15 @@ type MockState = { > repos: { id: string; displayName?: string; connectionId?: string | null }[] settings: { + experimentalTerminalAttention?: boolean notifications?: { customSoundPath?: string | null + customSoundId?: string | null } } + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType } const playDesktopNotificationSound = vi.hoisted(() => vi.fn()) @@ -55,16 +62,29 @@ function makeAgentStatus(paneKey: string): AgentStatusEntry { } describe('dispatchTerminalNotification', () => { - const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const liveLeafId = '11111111-1111-4111-8111-111111111111' + const staleLeafId = '22222222-2222-4222-8222-222222222222' + const paneKey = `tab-1:${liveLeafId}` + const stalePaneKey = `tab-1:${staleLeafId}` beforeEach(() => { vi.clearAllMocks() mockState = { activeWorktreeId: 'wt-secondary', - tabsByWorktree: {}, + tabsByWorktree: { + 'wt-primary': [{ id: 'tab-1' }] + }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: liveLeafId }, + activeLeafId: liveLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { [liveLeafId]: 'pty-1' } + } + }, browserTabsByWorktree: {}, retainedAgentsByPaneKey: {}, agentStatusByPaneKey: { @@ -87,7 +107,10 @@ describe('dispatchTerminalNotification', () => { ] }, repos: [{ id: 'repo1', displayName: 'orca', connectionId: null }], - settings: { notifications: { customSoundPath: null } } + settings: { experimentalTerminalAttention: true, notifications: { customSoundPath: null } }, + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn() } ;(globalThis as unknown as { window: unknown }).window = { api: { @@ -98,7 +121,7 @@ describe('dispatchTerminalNotification', () => { } }) - it('uses a live pane key when inactive worktree tab membership is not hydrated', () => { + it('uses a live pane key when marking inactive worktree attention', () => { dispatchTerminalNotification('wt-primary', { source: 'agent-task-complete', terminalTitle: 'codex', @@ -120,6 +143,114 @@ describe('dispatchTerminalNotification', () => { agentLastAssistantMessage: 'Done.' }) ) + expect(mockState.markWorktreeUnread).toHaveBeenCalledWith('wt-primary') + expect(mockState.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(mockState.markTerminalPaneUnread).toHaveBeenCalledWith(paneKey) + }) + + it('uses a live pane key when inactive worktree tab membership is not hydrated', () => { + mockState.tabsByWorktree = {} + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + worktreeId: 'wt-primary', + paneKey + }) + ) + expect(mockState.markWorktreeUnread).toHaveBeenCalledWith('wt-primary') + expect(mockState.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(mockState.markTerminalPaneUnread).toHaveBeenCalledWith(paneKey) + }) + + it('uses tab liveness when the layout has the leaf but no leaf pty binding yet', () => { + mockState.terminalLayoutsByTabId['tab-1'].ptyIdsByLeafId = {} + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + worktreeId: 'wt-primary', + paneKey + }) + ) + expect(mockState.markWorktreeUnread).toHaveBeenCalledWith('wt-primary') + expect(mockState.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(mockState.markTerminalPaneUnread).toHaveBeenCalledWith(paneKey) + }) + + it('falls back to background-worktree unread when terminal attention is disabled', () => { + mockState.settings.experimentalTerminalAttention = false + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalled() + expect(mockState.markWorktreeUnread).toHaveBeenCalledWith('wt-primary') + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('does not mark the visible worktree unread when terminal attention is disabled', () => { + mockState.settings.experimentalTerminalAttention = false + mockState.activeWorktreeId = 'wt-primary' + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops a pane key when its tab is hydrated under another worktree', () => { + mockState.tabsByWorktree = { + 'wt-secondary': [{ id: 'tab-1' }] + } + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('does not mark unread for a stale closed pane key when another pty in the tab is live', () => { + mockState.agentStatusByPaneKey[stalePaneKey] = makeAgentStatus(stalePaneKey) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey: stalePaneKey + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() }) it('still drops stale notifications when neither worktree nor pane has a live pty', () => { @@ -132,5 +263,8 @@ describe('dispatchTerminalNotification', () => { }) expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + expect(mockState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index 82ea6343d19..5388d32b50c 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -5,6 +5,7 @@ import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalPaneLayoutNode } from '../../../../shared/types' const AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS = 10_000 @@ -44,6 +45,57 @@ function hasLivePtyForNotification( return hasLivePtyForWorktree(state, worktreeId) || hasLivePtyForPaneKey(state, paneKey) } +function layoutContainsLeaf( + node: TerminalPaneLayoutNode | null | undefined, + leafId: string +): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutContainsLeaf(node.first, leafId) || layoutContainsLeaf(node.second, leafId) +} + +function isCurrentLivePaneKey( + state: ReturnType, + worktreeId: string, + paneKey: string +): boolean { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return false + } + + const tabExistsInAnotherWorktree = Object.entries(state.tabsByWorktree).some( + ([candidateWorktreeId, tabs]) => + candidateWorktreeId !== worktreeId && tabs.some((tab) => tab.id === parsed.tabId) + ) + if (tabExistsInAnotherWorktree) { + return false + } + + const livePtyIds = state.ptyIdsByTabId[parsed.tabId] ?? [] + if (livePtyIds.length === 0) { + return false + } + + const layout = state.terminalLayoutsByTabId?.[parsed.tabId] + if (!layout) { + return true + } + + if (!layoutContainsLeaf(layout.root, parsed.leafId)) { + return false + } + + const leafPtyId = layout.ptyIdsByLeafId?.[parsed.leafId] + // Why: layout hydration can briefly know the leaf before restoring its PTY + // binding; the tab-level live PTY list remains the liveness source then. + return leafPtyId === undefined || livePtyIds.includes(leafPtyId) +} + function getPaneKeyTabId(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) if (parsed) { @@ -158,6 +210,30 @@ export function dispatchTerminalNotification( return } + if (event.source === 'agent-task-complete') { + const terminalAttentionEnabled = state.settings?.experimentalTerminalAttention === true + let tabId: string | null = null + if (event.paneKey) { + tabId = getPaneKeyTabId(event.paneKey) + // Why: delayed completion hooks from a closed split pane can arrive while + // another pane in the tab is still live; stale leaf completions must not + // create unread state or OS notifications. + if (!tabId || !isCurrentLivePaneKey(state, worktreeId, event.paneKey)) { + return + } + } + + if (terminalAttentionEnabled && tabId && event.paneKey) { + state.markWorktreeUnread(worktreeId) + state.markTerminalTabUnread(tabId) + state.markTerminalPaneUnread(event.paneKey) + } else if (state.activeWorktreeId !== worktreeId) { + // Why: when pane attention is disabled, agent completion still preserves + // the existing background-workspace unread signal for Dock/sidebar state. + state.markWorktreeUnread(worktreeId) + } + } + // Why: prefer worktree.repoId over string-parsing the worktreeId. The // `${repoId}::${path}` format is an implementation detail of id // construction; coupling the notification dispatcher to it would silently diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 260d1bbc6e7..02a136907f8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -7,6 +7,7 @@ import type { TerminalQuickCommand } from '../../../../shared/types' import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch' import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { pasteTerminalText } from './terminal-bracketed-paste' +import { pasteTerminalClipboard } from './terminal-clipboard-paste' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -107,24 +108,17 @@ export function useTerminalPaneContextMenu({ if (!pane) { return } - const text = await window.api.ui.readClipboardText() - if (text) { - pasteTerminalText(pane.terminal, text) - pane.terminal.focus() - return - } - // Why: clipboard has no text — check for an image (e.g. screenshot) and - // save it on the same host as this terminal before pasting the file path. - try { - const connectionId = getConnectionId(worktreeId) ?? null - const filePath = await window.api.ui.saveClipboardImageAsTempFile({ connectionId }) - if (filePath) { - pasteTerminalText(pane.terminal, filePath) + const connectionId = getConnectionId(worktreeId) ?? null + await pasteTerminalClipboard({ + readClipboardText: window.api.ui.readClipboardText, + saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile, + connectionId, + pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), + onImagePasteError: (error) => { + const detail = error instanceof Error ? error.message : String(error) + onPasteError(`Image paste failed: ${detail}`) } - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - onPasteError(`Image paste failed: ${detail}`) - } + }) // Why: Radix returns focus to the menu trigger (the pane container) on // close, but xterm.js only accepts input when its own helper textarea is // focused. Without this, the user has to click the pane again before diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5d4d3fdeda8..2f1676a48ce 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -124,8 +124,10 @@ type UseTerminalPaneLifecycleDeps = { updateTabPtyId: (tabId: string, ptyId: string) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void + markTerminalPaneUnread: (paneKey: string) => void clearWorktreeUnread: (worktreeId: string) => void clearTerminalTabUnread: (tabId: string) => void + clearTerminalPaneUnread: (paneKey: string) => void dispatchNotification: (event: { source: 'terminal-bell' | 'agent-task-complete' terminalTitle?: string @@ -180,6 +182,20 @@ type SplitWithStartupDeps = { startup?: SplitStartupPayload | null } +function resolveTerminalHomePathFromEnv(env: Record | undefined): string | null { + const home = env?.HOME?.trim() + if (home) { + return home + } + const userProfile = env?.USERPROFILE?.trim() + if (userProfile) { + return userProfile + } + const homeDrive = env?.HOMEDRIVE?.trim() + const homePath = env?.HOMEPATH?.trim() + return homeDrive && homePath ? `${homeDrive}${homePath}` : null +} + /** Scopes `deps.startup` to a single call of `splitPane()`, clearing it in `finally` so later splits do not replay the payload. */ export function splitPaneWithOneShotStartup( deps: SplitWithStartupDeps, @@ -255,8 +271,10 @@ export function useTerminalPaneLifecycle({ updateTabPtyId, markWorktreeUnread, markTerminalTabUnread, + markTerminalPaneUnread, clearWorktreeUnread, clearTerminalTabUnread, + clearTerminalPaneUnread, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -355,6 +373,7 @@ export function useTerminalPaneLifecycle({ cwd ?? '' const startupCwd = cwd ?? worktreePath + const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env) // Why: existence probes can cross SSH/runtime boundaries. This cache is // lifecycle-scoped, so external mutations and the initial 'active' runtime // fallback can temporarily leave stale entries. @@ -363,6 +382,7 @@ export function useTerminalPaneLifecycle({ worktreeId, worktreePath, startupCwd, + terminalHomePath, managerRef, linkProviderDisposablesRef, pathExistsCache, @@ -428,8 +448,10 @@ export function useTerminalPaneLifecycle({ updateTabPtyId, markWorktreeUnread, markTerminalTabUnread, + markTerminalPaneUnread, clearWorktreeUnread, clearTerminalTabUnread, + clearTerminalPaneUnread, dispatchNotification, setCacheTimerStartedAt, syncPanePtyLayoutBinding, @@ -734,6 +756,18 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. + const leafId = closedPane?.leafId + if (leafId) { + const paneKey = makePaneKey(tabId, leafId) + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + clearTerminalPaneUnread(paneKey) + useAppStore.getState().dropAgentStatus(paneKey) + } if (transport) { const ptyId = suppressIntentionalPaneCloseExit( transport, @@ -746,16 +780,6 @@ export function useTerminalPaneLifecycle({ syncPanePtyLayoutBinding(paneId, null) clearTabPtyId(tabId, ptyId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. - const leafId = closedPane?.leafId - if (leafId) { - const paneKey = makePaneKey(tabId, leafId) - useAppStore.getState().setCacheTimerStartedAt(paneKey, null) - useAppStore.getState().dropAgentStatus(paneKey) - } transport.destroy?.() paneTransportsRef.current.delete(paneId) } diff --git a/src/renderer/src/components/terminal/useTerminalShortcuts.ts b/src/renderer/src/components/terminal/useTerminalShortcuts.ts index 0d875a22819..02b4404d41d 100644 --- a/src/renderer/src/components/terminal/useTerminalShortcuts.ts +++ b/src/renderer/src/components/terminal/useTerminalShortcuts.ts @@ -1,7 +1,7 @@ import { useEffect, useEffectEvent } from 'react' import type { UnifiedTerminalItem } from './useTerminalTabs' import { getNextTabAcrossAllTypes, getNextTabWithinActiveType } from './tab-type-cycle' -import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' +import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' type UseTerminalShortcutsParams = { activeWorktreeId: string | null @@ -105,7 +105,7 @@ export function useTerminalShortcuts({ }) const handleBeforeUnload = useEffectEvent((event: BeforeUnloadEvent) => { - if (isUpdaterQuitAndInstallInProgress()) { + if (isIntentionalAppRestartInProgress()) { return } if (!hasDirtyFiles) { diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index f0993fc0075..fe1749393fc 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -14,8 +14,8 @@ type MockStoreState = { terminalLayoutsByTabId: Record< string, { - root: { type: 'leaf'; leafId: string } - activeLeafId: string + root: { type: 'leaf'; leafId: string } | null + activeLeafId: string | null expandedLeafId: string | null ptyIdsByLeafId?: Record } @@ -132,4 +132,36 @@ describe('agent hook completion notifications', () => { }) ) }) + + it('uses tab-level PTY liveness when an inactive layout is empty', async () => { + mockStoreState.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey + }) + ) + }) }) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 0973efa125c..f8901a6e6e1 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -57,6 +57,12 @@ function getPtyIdForPaneKey(paneKey: string): string | null { if (leafPtyId && tabPtyIds.includes(leafPtyId)) { return leafPtyId } + if (!layout?.root) { + // Why: inactive worktree switches can temporarily preserve only tab-level + // PTY liveness; do not drop hook completions just because layout metadata + // is at the empty snapshot. + return tabPtyIds[0] ?? null + } // Why: switching worktrees can unmount the terminal pane and clear the // leaf binding before the hook completion arrives, while the tab PTY is // still live. Keep closed leaves suppressed by requiring the leaf in layout. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 31dc98c548d..04f86886202 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2741,6 +2741,80 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('applies ready push events for inactive terminal tabs with empty layout snapshots', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: false } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Inactive Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'done', + prompt: 'inactive prompt', + agentType: 'codex', + lastAssistantMessage: 'inactive completion', + receivedAt: 1_700_000_000_200, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ + state: 'done', + prompt: 'inactive prompt', + agentType: 'codex', + lastAssistantMessage: 'inactive completion' + }), + 'Inactive Tab', + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + ) + }) + it('buffers ready push events until a mounted tab contains the pane leaf', async () => { const setAgentStatus = vi.fn() const track = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b6e55c5cff9..9b629c0d251 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2425,7 +2425,10 @@ function resolvePaneKey( owningWorktreeId } } - const leafExists = layout ? collectLeafIdsInOrder(layout.root).includes(leafId) : true + // Why: inactive worktree switches can leave the tab's layout at the empty + // snapshot while the tab and PTY are still live. Treat that like missing + // layout metadata; a non-empty layout that lacks the leaf still means closed. + const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true if (!leafExists) { return { exists: false, diff --git a/src/renderer/src/lib/github-pr-workspace-attachment.test.ts b/src/renderer/src/lib/github-pr-workspace-attachment.test.ts new file mode 100644 index 00000000000..efc29c42a40 --- /dev/null +++ b/src/renderer/src/lib/github-pr-workspace-attachment.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { + findGithubPrWorkspaceAttachment, + getGithubPrWorkspaceAttachmentLabel +} from './github-pr-workspace-attachment' +import type { Worktree } from '../../../shared/types' + +function worktree(overrides: Partial = {}): Worktree { + return { + id: overrides.id ?? 'wt-1', + repoId: overrides.repoId ?? 'repo-1', + path: overrides.path ?? '/tmp/repo-1/wt-1', + head: 'abc123', + branch: overrides.branch ?? 'refs/heads/feature/pr-workspace', + isBare: false, + isMainWorktree: false, + displayName: overrides.displayName ?? 'PR workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('github PR workspace attachment', () => { + it('finds the first non-archived workspace linked to the repo PR', () => { + const first = worktree({ id: 'first', linkedPR: 42 }) + const second = worktree({ id: 'second', linkedPR: 42 }) + + expect(findGithubPrWorkspaceAttachment([first, second], 'repo-1', 42)).toBe(first) + }) + + it('does not match workspaces from a different repo', () => { + const attachedElsewhere = worktree({ repoId: 'repo-2', linkedPR: 42 }) + + expect(findGithubPrWorkspaceAttachment([attachedElsewhere], 'repo-1', 42)).toBeNull() + }) + + it('does not match archived workspaces', () => { + const archived = worktree({ linkedPR: 42, isArchived: true }) + + expect(findGithubPrWorkspaceAttachment([archived], 'repo-1', 42)).toBeNull() + }) + + it('returns null when no GitHub PR attachment exists', () => { + const unlinked = worktree({ linkedPR: null }) + + expect(findGithubPrWorkspaceAttachment([unlinked], 'repo-1', 42)).toBeNull() + }) + + it('does not treat GitLab MR metadata as a GitHub PR attachment', () => { + const gitlabOnly = worktree({ linkedPR: null, linkedGitLabMR: 42 }) + + expect(findGithubPrWorkspaceAttachment([gitlabOnly], 'repo-1', 42)).toBeNull() + }) + + it('labels attachments without exposing a full path when display or branch is available', () => { + expect(getGithubPrWorkspaceAttachmentLabel(worktree({ displayName: ' Named PR ' }))).toBe( + 'Named PR' + ) + expect( + getGithubPrWorkspaceAttachmentLabel( + worktree({ displayName: '', branch: 'refs/heads/fix-ci' }) + ) + ).toBe('fix-ci') + expect( + getGithubPrWorkspaceAttachmentLabel( + worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' }) + ) + ).toBe('workspace-tail') + }) +}) diff --git a/src/renderer/src/lib/github-pr-workspace-attachment.ts b/src/renderer/src/lib/github-pr-workspace-attachment.ts new file mode 100644 index 00000000000..d820f5ba3c1 --- /dev/null +++ b/src/renderer/src/lib/github-pr-workspace-attachment.ts @@ -0,0 +1,46 @@ +import type { Worktree } from '../../../shared/types' +import { basename } from './path' + +export function findGithubPrWorkspaceAttachment( + worktrees: readonly Worktree[], + repoId: string | null | undefined, + prNumber: number +): Worktree | null { + if (!repoId) { + return null + } + + return ( + worktrees.find( + (worktree) => + worktree.repoId === repoId && worktree.linkedPR === prNumber && !worktree.isArchived + ) ?? null + ) +} + +export function getGithubPrWorkspaceAttachmentLabel(worktree: Worktree): string { + const displayName = worktree.displayName.trim() + if (displayName) { + return displayName + } + + const branch = getBranchLabel(worktree.branch) + if (branch) { + return branch + } + + return basename(worktree.path) || worktree.path +} + +function getBranchLabel(branch: string | null | undefined): string | null { + const trimmed = branch?.trim() + if (!trimmed) { + return null + } + + if (trimmed.startsWith('refs/heads/')) { + return trimmed.slice('refs/heads/'.length) + } + + return trimmed +} diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts index cab27a65f42..d641666359a 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts @@ -163,4 +163,33 @@ describe('remapOpenEditorTabsForPathChange', () => { markdownPreviewSourceFileId: floatingNewSourceId }) }) + + it('clears the untitled marker when remapping a renamed new markdown file', () => { + const state = useAppStore.getState() + const oldPath = '/repo/untitled.md' + const newPath = '/repo/renamed.md' + + state.openFile({ + filePath: oldPath, + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + remapOpenEditorTabsForPathChange({ + fromPath: oldPath, + toPath: newPath, + worktreePath: '/repo', + worktreeId: 'wt-1' + }) + + expect(useAppStore.getState().openFiles).toHaveLength(1) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + filePath: newPath, + relativePath: 'renamed.md' + }) + expect(useAppStore.getState().openFiles[0].isUntitled).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index b119fbc00d6..5f354f06b1a 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts @@ -160,6 +160,12 @@ export function remapOpenEditorTabsForPathChange({ const draft = state.editorDrafts[file.id] const wasDirty = file.isDirty + // Why: renameRuntimePath already moved the file. Clear the untitled marker + // before closeFile so its cleanup path does not try to delete the old path. + if (file.isUntitled) { + useAppStore.getState().clearUntitled(file.id) + } + // Why: preview tabs use synthetic ids (`markdown-preview::...`) instead of // filePath, so close the real tab id before reopening at the new path. state.closeFile(file.id) diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 21e44013e3d..a3bdf7542b2 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -62,6 +62,55 @@ describe('terminal path helpers', () => { }) }) + describe('extractTerminalFileLinks local path tokens', () => { + it('detects tilde-prefixed POSIX paths', () => { + const links = extractTerminalFileLinks('~/Documents/Path/file_name') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: '~/Documents/Path/file_name', + displayText: '~/Documents/Path/file_name' + }) + }) + + it('detects absolute paths with spaces before the filename', () => { + const links = extractTerminalFileLinks('/Users/Path/FolderName with Space/content.js') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: '/Users/Path/FolderName with Space/content.js', + displayText: '/Users/Path/FolderName with Space/content.js' + }) + }) + + it('stops a spaced absolute path before trailing prose', () => { + const links = extractTerminalFileLinks( + 'Open /Users/Path/FolderName with Space/content.js for details' + ) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: '/Users/Path/FolderName with Space/content.js', + displayText: '/Users/Path/FolderName with Space/content.js' + }) + }) + + it('detects an extensionless absolute path ending in a spaced segment', () => { + const links = extractTerminalFileLinks('/Users/alice/My Folder') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: '/Users/alice/My Folder', + displayText: '/Users/alice/My Folder' + }) + }) + + it('detects an extensionless relative path ending in a spaced segment', () => { + const links = extractTerminalFileLinks('./My Folder') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: './My Folder', + displayText: './My Folder' + }) + }) + }) + it('supports Windows cwd resolution for terminal file links', () => { expect( resolveTerminalFileLink( @@ -82,6 +131,67 @@ describe('terminal path helpers', () => { }) }) + it('resolves tilde-prefixed POSIX paths against the cwd user home', () => { + expect( + resolveTerminalFileLink( + { + pathText: '~/Documents/Path/file_name', + line: null, + column: null, + startIndex: 0, + endIndex: 26, + displayText: '~/Documents/Path/file_name' + }, + '/Users/alice/project' + ) + ).toEqual({ + absolutePath: '/Users/alice/Documents/Path/file_name', + line: null, + column: null + }) + }) + + it('resolves tilde-prefixed Windows paths against the cwd user home', () => { + expect( + resolveTerminalFileLink( + { + pathText: '~/Documents/Path/file_name', + line: null, + column: null, + startIndex: 0, + endIndex: 26, + displayText: '~/Documents/Path/file_name' + }, + 'C:\\Users\\Alice\\project' + ) + ).toEqual({ + absolutePath: 'C:/Users/Alice/Documents/Path/file_name', + line: null, + column: null + }) + }) + + it('resolves tilde-prefixed paths against explicit terminal home when cwd is outside home', () => { + expect( + resolveTerminalFileLink( + { + pathText: '~/Documents/Path/file_name', + line: null, + column: null, + startIndex: 0, + endIndex: 26, + displayText: '~/Documents/Path/file_name' + }, + '/workspace/project', + '/home/alice' + ) + ).toEqual({ + absolutePath: '/home/alice/Documents/Path/file_name', + line: null, + column: null + }) + }) + it('resolves exact repo-relative OSC hyperlink text', () => { expect(resolveTerminalFileLinkText('docs/README.md', '/repo')).toEqual({ absolutePath: '/repo/docs/README.md', diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 1d9c4ee910e..530ea4a2831 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,3 +1,10 @@ +/* eslint-disable max-lines -- Why: terminal link parsing depends on ordered passes sharing range state. */ +import { + joinAbsolutePath, + normalizeAbsolutePath, + resolveTildePath +} from './terminal-path-normalization' + export type ParsedTerminalFileLink = { pathText: string line: number | null @@ -18,7 +25,23 @@ export type ResolvedTerminalFileLink = Pick|`\r\n]*\s+[^()[\]{}'",;<>|`\r\n]*[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g +const SPACED_PATH_WITH_EXTENSION_REGEX = + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]*?\s+[^()[\]{}'",;<>|`\\/ \r\n]*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?/g +const LINE_ENDING_SPACED_PATH_REGEX = + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])(?=[^()[\]{}'",;<>|`\r\n]*\s+)[^()[\]{}'",;<>|`\r\n]*\S(?=\s*$)/g +const SPACED_LOCAL_PATH_REGEXES = [ + SPACED_PATH_WITH_SEPARATOR_REGEX, + SPACED_PATH_WITH_EXTENSION_REGEX, + LINE_ENDING_SPACED_PATH_REGEX +] // Word separators used by the bare-filename pass. Mirrors the default set in // VSCode's `terminal.integrated.wordSeparators` with the exception that we @@ -79,97 +102,6 @@ function parsePathWithOptionalLineColumn(value: string): { return { pathText, line, column } } -type NormalizedAbsolutePath = { - normalized: string - comparisonKey: string - rootKind: 'posix' | 'windows' | 'unc' -} - -function normalizeSegments(pathValue: string): string[] { - const segments = pathValue.split(/[\\/]+/) - const stack: string[] = [] - for (const segment of segments) { - if (!segment || segment === '.') { - continue - } - if (segment === '..') { - if (stack.length > 0) { - stack.pop() - } - continue - } - stack.push(segment) - } - - return stack -} - -function normalizeAbsolutePath(pathValue: string): NormalizedAbsolutePath | null { - const windowsDriveMatch = /^([A-Za-z]):[\\/]*(.*)$/.exec(pathValue) - if (windowsDriveMatch) { - const driveLetter = windowsDriveMatch[1].toUpperCase() - const suffix = normalizeSegments(windowsDriveMatch[2]).join('/') - const normalized = suffix ? `${driveLetter}:/${suffix}` : `${driveLetter}:/` - return { - normalized, - comparisonKey: normalized.toLowerCase(), - rootKind: 'windows' - } - } - - const uncMatch = /^\\\\([^\\/]+)[\\/]+([^\\/]+)(?:[\\/]*(.*))?$/.exec(pathValue) - if (uncMatch) { - const server = uncMatch[1] - const share = uncMatch[2] - const suffix = normalizeSegments(uncMatch[3] ?? '').join('/') - const normalizedRoot = `//${server}/${share}` - const normalized = suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot - return { - normalized, - comparisonKey: normalized.toLowerCase(), - rootKind: 'unc' - } - } - - if (pathValue.startsWith('/')) { - const normalized = `/${normalizeSegments(pathValue).join('/')}`.replace(/\/+$/, '') || '/' - return { - normalized, - comparisonKey: normalized, - rootKind: 'posix' - } - } - - return null -} - -function joinAbsolutePath(basePath: string, relativePath: string): string | null { - const normalizedBase = normalizeAbsolutePath(basePath) - if (!normalizedBase) { - return null - } - - return normalizeJoinedPath(normalizedBase, relativePath) -} - -function normalizeJoinedPath(basePath: NormalizedAbsolutePath, relativePath: string): string { - const normalizedBaseSegments = normalizeSegments(basePath.normalized) - const relativeSegments = normalizeSegments(relativePath) - const joinedSegments = [...normalizedBaseSegments, ...relativeSegments] - - if (basePath.rootKind === 'unc') { - const [server, share, ...rest] = joinedSegments - return rest.length > 0 ? `//${server}/${share}/${rest.join('/')}` : `//${server}/${share}` - } - - if (basePath.rootKind === 'windows') { - const [drive, ...rest] = joinedSegments - return rest.length > 0 ? `${drive}/${rest.join('/')}` : drive - } - - return `/${joinedSegments.join('/')}`.replace(/\/+$/, '') || '/' -} - // Project files that look like filenames despite having no extension. The // word detector otherwise requires a `.` in the token to keep noise down — // without this list, `ls` output containing `Makefile` or `LICENSE` would @@ -235,6 +167,42 @@ function isInsideUriScheme(lineText: string, range: DetectedRange): boolean { ) } +function rangesOverlap(range: DetectedRange, claimedRanges: readonly [number, number][]): boolean { + return claimedRanges.some(([start, end]) => range.startIndex < end && range.endIndex > start) +} + +function trimSpacedPathTrailingProse(range: DetectedRange): DetectedRange { + const filenameBeforeProseMatch = /^(.+\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?:\s+.+)$/.exec( + range.text + ) + if (!filenameBeforeProseMatch) { + return range + } + + const text = filenameBeforeProseMatch[1] + return { + text, + startIndex: range.startIndex, + endIndex: range.startIndex + text.length + } +} + +function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRange[] { + const ranges: DetectedRange[] = [] + for (const match of range.text.matchAll(/\s+/g)) { + const endIndex = match.index ?? 0 + const text = range.text.slice(0, endIndex).trimEnd() + if (text.includes(' ')) { + ranges.push({ + text, + startIndex: range.startIndex, + endIndex: range.startIndex + text.length + }) + } + } + return ranges.reverse() +} + function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null { const parsed = parsePathWithOptionalLineColumn(range.text) if (!parsed) { @@ -250,16 +218,32 @@ function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null { } } +function sortLinksByPosition(links: ParsedTerminalFileLink[]): ParsedTerminalFileLink[] { + return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex) +} + // Ported from VSCode's TerminalLocalLinkDetector. Extracts anything that // contains a path separator, optionally with a `:line:col` suffix — covers // `./src/foo.ts`, `/abs/bar`, `src/foo.ts:12:3`, etc. -function detectLocalPathLinks(lineText: string): ParsedTerminalFileLink[] { +function detectLocalPathLinks( + lineText: string, + includeLineEndingPrefixCandidates = false +): ParsedTerminalFileLink[] { const links: ParsedTerminalFileLink[] = [] + const spacedLinks = detectSpacedLocalPathLinks(lineText, includeLineEndingPrefixCandidates) + const spacedRanges = spacedLinks.map(({ startIndex, endIndex }): [number, number] => [ + startIndex, + endIndex + ]) + links.push(...spacedLinks) for (const range of detectRanges(lineText, LOCAL_PATH_REGEX)) { + if (rangesOverlap(range, spacedRanges)) { + continue + } if (isInsideUriScheme(lineText, range)) { continue } - if (!range.text.includes('/')) { + if (!/[\\/]/.test(range.text)) { continue } const link = toParsedLink(range) @@ -267,6 +251,34 @@ function detectLocalPathLinks(lineText: string): ParsedTerminalFileLink[] { links.push(link) } } + return sortLinksByPosition(links) +} + +function detectSpacedLocalPathLinks( + lineText: string, + includeLineEndingPrefixCandidates = false +): ParsedTerminalFileLink[] { + const links: ParsedTerminalFileLink[] = [] + const claimedRanges: [number, number][] = [] + for (const regex of SPACED_LOCAL_PATH_REGEXES) { + for (const range of detectRanges(lineText, regex)) { + if (rangesOverlap(range, claimedRanges) || isInsideUriScheme(lineText, range)) { + continue + } + const candidateRanges = + includeLineEndingPrefixCandidates && regex === LINE_ENDING_SPACED_PATH_REGEX + ? [range, ...buildLineEndingSpacedPathPrefixRanges(range)] + : [range] + const candidateLinks = candidateRanges + .map((candidateRange) => toParsedLink(trimSpacedPathTrailingProse(candidateRange))) + .filter((link): link is ParsedTerminalFileLink => link !== null) + const link = candidateLinks[0] + if (link) { + links.push(...candidateLinks) + claimedRanges.push([link.startIndex, link.endIndex]) + } + } + } return links } @@ -308,12 +320,24 @@ export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLi return [...pathLinks, ...wordLinks] } +export function extractTerminalFileLinkCandidates(lineText: string): ParsedTerminalFileLink[] { + const pathLinks = detectLocalPathLinks(lineText, true) + const claimed = pathLinks.map(({ startIndex, endIndex }): [number, number] => [ + startIndex, + endIndex + ]) + const wordLinks = detectBareFilenameLinks(lineText, claimed) + return [...pathLinks, ...wordLinks] +} + export function resolveTerminalFileLink( parsed: ParsedTerminalFileLink, - cwd: string + cwd: string, + homePath?: string | null ): ResolvedTerminalFileLink | null { - const absolutePath = - normalizeAbsolutePath(parsed.pathText)?.normalized ?? joinAbsolutePath(cwd, parsed.pathText) + const absolutePath = /^~[\\/]/.test(parsed.pathText) + ? resolveTildePath(parsed.pathText, cwd, homePath) + : (normalizeAbsolutePath(parsed.pathText)?.normalized ?? joinAbsolutePath(cwd, parsed.pathText)) if (!absolutePath) { return null } @@ -327,11 +351,12 @@ export function resolveTerminalFileLink( export function resolveTerminalFileLinkText( linkText: string, - cwd: string + cwd: string, + homePath?: string | null ): ResolvedTerminalFileLink | null { const links = extractTerminalFileLinks(linkText) const exactLink = links.find((link) => link.startIndex === 0 && link.endIndex === linkText.length) - return exactLink ? resolveTerminalFileLink(exactLink, cwd) : null + return exactLink ? resolveTerminalFileLink(exactLink, cwd, homePath) : null } export function isPathInsideWorktree(filePath: string, worktreePath: string): boolean { diff --git a/src/renderer/src/lib/terminal-path-normalization.ts b/src/renderer/src/lib/terminal-path-normalization.ts new file mode 100644 index 00000000000..d72f80857b1 --- /dev/null +++ b/src/renderer/src/lib/terminal-path-normalization.ts @@ -0,0 +1,146 @@ +type NormalizedAbsolutePath = { + normalized: string + comparisonKey: string + rootKind: 'posix' | 'windows' | 'unc' +} + +function normalizeSegments(pathValue: string): string[] { + const segments = pathValue.split(/[\\/]+/) + const stack: string[] = [] + for (const segment of segments) { + if (!segment || segment === '.') { + continue + } + if (segment === '..') { + if (stack.length > 0) { + stack.pop() + } + continue + } + stack.push(segment) + } + + return stack +} + +export function normalizeAbsolutePath(pathValue: string): NormalizedAbsolutePath | null { + const windowsDriveMatch = /^([A-Za-z]):[\\/]*(.*)$/.exec(pathValue) + if (windowsDriveMatch) { + const driveLetter = windowsDriveMatch[1].toUpperCase() + const suffix = normalizeSegments(windowsDriveMatch[2]).join('/') + const normalized = suffix ? `${driveLetter}:/${suffix}` : `${driveLetter}:/` + return { + normalized, + comparisonKey: normalized.toLowerCase(), + rootKind: 'windows' + } + } + + const uncMatch = /^\\\\([^\\/]+)[\\/]+([^\\/]+)(?:[\\/]*(.*))?$/.exec(pathValue) + if (uncMatch) { + const server = uncMatch[1] + const share = uncMatch[2] + const suffix = normalizeSegments(uncMatch[3] ?? '').join('/') + const normalizedRoot = `//${server}/${share}` + const normalized = suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot + return { + normalized, + comparisonKey: normalized.toLowerCase(), + rootKind: 'unc' + } + } + + if (pathValue.startsWith('/')) { + const normalized = `/${normalizeSegments(pathValue).join('/')}`.replace(/\/+$/, '') || '/' + return { + normalized, + comparisonKey: normalized, + rootKind: 'posix' + } + } + + return null +} + +function inferHomePathFromCwd(cwd: string): string | null { + const normalizedCwd = normalizeAbsolutePath(cwd) + if (!normalizedCwd) { + return null + } + + const segments = normalizeSegments(normalizedCwd.normalized) + if (normalizedCwd.rootKind === 'windows') { + const [drive, usersSegment, userSegment] = segments + if (!drive || !usersSegment || !userSegment || usersSegment.toLowerCase() !== 'users') { + return null + } + return `${drive}/${usersSegment}/${userSegment}` + } + + if (normalizedCwd.rootKind === 'posix') { + const [rootParent, userSegment] = segments + if ((rootParent === 'Users' || rootParent === 'home') && userSegment) { + return `/${rootParent}/${userSegment}` + } + if (rootParent === 'root') { + return '/root' + } + } + + return null +} + +function normalizeExplicitHomePath(homePath: string | null | undefined): string | null { + const trimmedHomePath = homePath?.trim() + if (!trimmedHomePath) { + return null + } + + return normalizeAbsolutePath(trimmedHomePath)?.normalized ?? null +} + +export function resolveTildePath( + pathValue: string, + cwd: string, + homePath?: string | null +): string | null { + if (!/^~[\\/]/.test(pathValue)) { + return null + } + + // Why: remote/devcontainer terminals can have cwd outside the user's home; + // prefer the terminal's explicit home when the caller has it. + const resolvedHomePath = normalizeExplicitHomePath(homePath) ?? inferHomePathFromCwd(cwd) + if (!resolvedHomePath) { + return null + } + + return joinAbsolutePath(resolvedHomePath, pathValue.slice(2)) +} + +export function joinAbsolutePath(basePath: string, relativePath: string): string | null { + const normalizedBase = normalizeAbsolutePath(basePath) + if (!normalizedBase) { + return null + } + + return normalizeJoinedPath(normalizedBase, relativePath) +} + +function normalizeJoinedPath(basePath: NormalizedAbsolutePath, relativePath: string): string { + const normalizedBaseSegments = normalizeSegments(basePath.normalized) + const relativeSegments = normalizeSegments(relativePath) + const joinedSegments = [...normalizedBaseSegments, ...relativeSegments] + + if (basePath.rootKind === 'unc') { + const [server, share, ...rest] = joinedSegments + return rest.length > 0 ? `//${server}/${share}/${rest.join('/')}` : `//${server}/${share}` + } + + if (basePath.rootKind === 'windows') { + const [drive, ...rest] = joinedSegments + return rest.length > 0 ? `${drive}/${rest.join('/')}` : drive + } + + return `/${joinedSegments.join('/')}`.replace(/\/+$/, '') || '/' +} diff --git a/src/renderer/src/lib/updater-beforeunload.test.ts b/src/renderer/src/lib/updater-beforeunload.test.ts index 34d7b58f1ff..a32364fcad7 100644 --- a/src/renderer/src/lib/updater-beforeunload.test.ts +++ b/src/renderer/src/lib/updater-beforeunload.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + isIntentionalAppRestartInProgress, isUpdaterQuitAndInstallInProgress, registerUpdaterBeforeUnloadBypass } from './updater-beforeunload' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../../../shared/updater-renderer-events' @@ -30,9 +33,25 @@ describe('registerUpdaterBeforeUnloadBypass', () => { window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) expect(isUpdaterQuitAndInstallInProgress()).toBe(true) + expect(isIntentionalAppRestartInProgress()).toBe(true) window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT)) expect(isUpdaterQuitAndInstallInProgress()).toBe(false) + expect(isIntentionalAppRestartInProgress()).toBe(false) + + cleanup() + }) + + it('tracks app restart lifecycle events', () => { + const cleanup = registerUpdaterBeforeUnloadBypass() + expect(isIntentionalAppRestartInProgress()).toBe(false) + + window.dispatchEvent(new Event(ORCA_APP_RESTART_STARTED_EVENT)) + expect(isIntentionalAppRestartInProgress()).toBe(true) + expect(isUpdaterQuitAndInstallInProgress()).toBe(true) + + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + expect(isIntentionalAppRestartInProgress()).toBe(false) cleanup() }) diff --git a/src/renderer/src/lib/updater-beforeunload.ts b/src/renderer/src/lib/updater-beforeunload.ts index 073bb0770e5..ba26ae9fdc7 100644 --- a/src/renderer/src/lib/updater-beforeunload.ts +++ b/src/renderer/src/lib/updater-beforeunload.ts @@ -1,31 +1,41 @@ import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../../../shared/updater-renderer-events' -let updaterQuitAndInstallInProgress = false +let intentionalAppRestartInProgress = false export function isUpdaterQuitAndInstallInProgress(): boolean { - return updaterQuitAndInstallInProgress + return isIntentionalAppRestartInProgress() +} + +export function isIntentionalAppRestartInProgress(): boolean { + return intentionalAppRestartInProgress } export function registerUpdaterBeforeUnloadBypass(): () => void { const markInProgress = (): void => { - updaterQuitAndInstallInProgress = true + intentionalAppRestartInProgress = true } const clearInProgress = (): void => { - updaterQuitAndInstallInProgress = false + intentionalAppRestartInProgress = false } window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, markInProgress) window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, clearInProgress) + window.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, markInProgress) + window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, clearInProgress) return () => { window.removeEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, markInProgress) window.removeEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, clearInProgress) + window.removeEventListener(ORCA_APP_RESTART_STARTED_EVENT, markInProgress) + window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, clearInProgress) // Why: hot reloads can re-register this listener inside the same renderer. - // Reset the module flag on cleanup so a failed earlier install attempt + // Reset the module flag on cleanup so a failed earlier restart attempt // cannot silently suppress future unsaved-change prompts. - updaterQuitAndInstallInProgress = false + intentionalAppRestartInProgress = false } } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 6c763e8c3fd..9c0162db84a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -166,16 +166,16 @@ export type TerminalSlice = { /** Live pane titles keyed by tabId then paneId. Unlike the legacy tab title, * this preserves split-pane agent status per pane while TerminalPane is mounted. */ runtimePaneTitlesByTabId: Record> - /** Why: per-tab activity indicators. A tab gets flagged unread when an - * agent in any of its panes transitions working→idle (onAgentBecameIdle - * in pty-connection.ts) while the tab is not currently focused. The flag - * clears when the user activates the tab. This is ephemeral UI state - * only — not persisted across restarts. - * - * Note: BEL (0x07) bytes intentionally do NOT flip this flag. See the - * long-form rationale in pty-connection.ts around `onAgentBecameIdle` - * for why BEL is too ambiguous to drive cross-surface attention. */ + /** Why: per-tab activity indicators. A tab gets flagged unread when terminal + * output requests attention (BEL) or an agent-complete notification is + * dispatched for one of its panes. The flag clears when the user activates + * or interacts with the tab. This is ephemeral UI state only — not + * persisted across restarts. */ unreadTerminalTabs: Record + /** Pane-keyed attention marker for split-pane precision. This is narrower + * than unreadTerminalTabs and clears when the user interacts with the exact + * pane that raised attention. */ + unreadTerminalPanes: Record suppressedPtyExitIds: Record pendingCodexPaneRestartIds: Record codexRestartNoticeByPtyId: Record< @@ -287,11 +287,13 @@ export type TerminalSlice = { * group within the active worktree. A visible tab is already "seen", * so a flag would never clear naturally. */ markTerminalTabUnread: (tabId: string) => void + markTerminalPaneUnread: (paneKey: string) => void /** Clear a tab's unread indicator. Called on user interaction with the * pane (keystroke, click) — matches ghostty's "show until interact" * model where the bell stays visible until the user engages with the * surface that raised it. */ clearTerminalTabUnread: (tabId: string) => void + clearTerminalPaneUnread: (paneKey: string) => void setTabCustomTitle: ( tabId: string, title: string | null, @@ -372,6 +374,7 @@ export const createTerminalSlice: StateCreator ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, unreadTerminalTabs: {}, + unreadTerminalPanes: {}, suppressedPtyExitIds: {}, pendingCodexPaneRestartIds: {}, codexRestartNoticeByPtyId: {}, @@ -720,6 +723,15 @@ export const createTerminalSlice: StateCreator nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } delete nextUnreadTerminalTabs[tabId] } + let nextUnreadTerminalPanes = s.unreadTerminalPanes + for (const paneKey of Object.keys(s.unreadTerminalPanes)) { + if (paneKey.startsWith(`${tabId}:`)) { + if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { + nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + } + delete nextUnreadTerminalPanes[paneKey] + } + } const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } delete nextPendingStartupByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } @@ -785,6 +797,9 @@ export const createTerminalSlice: StateCreator ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), + ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes + ? { unreadTerminalPanes: nextUnreadTerminalPanes } + : {}), expandedPaneByTabId: nextExpanded, canExpandPaneByTabId: nextCanExpand, terminalLayoutsByTabId: nextLayouts, @@ -1074,9 +1089,9 @@ export const createTerminalSlice: StateCreator if (!ownerTab) { return } - // Why: BEL must fire regardless of focus (ghostty semantics — "show - // until interact"). A BEL on the focused tab sets the indicator; real - // user interaction with the pane dismisses it. Keystroke/pointerdown + // Why: terminal attention must stay visible until interaction ("show + // until interact"). A signal on the focused tab still sets the indicator; + // real user interaction with the pane dismisses it. Keystroke/pointerdown // routes through clearTerminalTabUnread (see pty-connection.ts and // TerminalPane.tsx); tab/group activation clears unreadTerminalTabs // directly in activateTab/focusGroup as a pre-existing side-effect. @@ -1088,6 +1103,15 @@ export const createTerminalSlice: StateCreator }) }, + markTerminalPaneUnread: (paneKey) => { + set((s) => { + if (s.unreadTerminalPanes[paneKey]) { + return s + } + return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } } + }) + }, + clearTerminalTabUnread: (tabId) => { set((s) => { if (!s.unreadTerminalTabs[tabId]) { @@ -1099,6 +1123,17 @@ export const createTerminalSlice: StateCreator }) }, + clearTerminalPaneUnread: (paneKey) => { + set((s) => { + if (!s.unreadTerminalPanes[paneKey]) { + return s + } + const copy = { ...s.unreadTerminalPanes } + delete copy[paneKey] + return { unreadTerminalPanes: copy } + }) + }, + setTabCustomTitle: (tabId, title, opts) => { set((s) => { const next = { ...s.tabsByWorktree } @@ -1386,6 +1421,7 @@ export const createTerminalSlice: StateCreator // selectors on unrelated shutdown calls. Mirrors the sibling pattern // in tabs.ts. let nextUnreadTerminalTabs = s.unreadTerminalTabs + let nextUnreadTerminalPanes = s.unreadTerminalPanes for (const tab of tabs) { if (!keepIdentifiers) { delete nextRuntimePaneTitlesByTabId[tab.id] @@ -1398,6 +1434,14 @@ export const createTerminalSlice: StateCreator } delete nextUnreadTerminalTabs[tab.id] } + for (const paneKey of Object.keys(nextUnreadTerminalPanes)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { + nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + } + delete nextUnreadTerminalPanes[paneKey] + } + } if (!keepIdentifiers) { const existingLayout = nextTerminalLayoutsByTabId[tab.id] if (existingLayout?.ptyIdsByLeafId) { @@ -1439,6 +1483,9 @@ export const createTerminalSlice: StateCreator // of full-state selectors. Mirrors the sibling pattern in tabs.ts. ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes + ? { unreadTerminalPanes: nextUnreadTerminalPanes } : {}) } }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2e8c0d61e9c..f5810059787 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1466,11 +1466,11 @@ export const createWorktreeSlice: StateCreator }, markWorktreeUnread: (worktreeId) => { - // Why: BEL must fire regardless of focus (ghostty semantics — "show - // until interact"). Interaction with a pane inside the worktree - // dismisses the dot via clearWorktreeUnread. Worktree activation via - // setActiveWorktree also clears isUnread as a side-effect; that path - // predates this PR and is unaffected here. + // Why: terminal attention should remain visible until the user engages + // with the worktree. Interaction with a pane inside the worktree dismisses + // the dot via clearWorktreeUnread. Worktree activation via setActiveWorktree + // also clears isUnread as a side-effect; that path predates this PR and is + // unaffected here. let shouldPersist = false const now = Date.now() set((s) => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2e966db2949..f569d5e0769 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -303,6 +303,7 @@ function createWebPreloadApi(): Partial { }), getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), relaunch: () => Promise.resolve(window.location.reload()), + restart: () => Promise.resolve(window.location.reload()), reload: () => Promise.resolve(window.location.reload()), getKeyboardInputSourceId: () => Promise.resolve(null), setUnreadDockBadgeCount: () => Promise.resolve(), diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 0b591aaa35b..669da848010 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -80,6 +80,27 @@ describe('shared agent-hook-listener', () => { expect(event!.payload.agentType).toBe('claude') }) + it('normalizes Gemini BeforeTool to working with tool fields', () => { + const event = normalizeHookPayload( + state, + 'gemini', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'BeforeTool', + tool_name: 'read_file', + args: { file_path: 'src/index.ts' } + } + }, + 'production' + ) + + expect(event?.payload.state).toBe('working') + expect(event?.payload.agentType).toBe('gemini') + expect(event?.payload.toolName).toBe('read_file') + expect(event?.payload.toolInput).toBe('src/index.ts') + }) + it('normalizes OMP Pi-compatible hooks with OMP attribution', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 610148cc716..e29f9d61e8c 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -971,7 +971,12 @@ function extractGeminiToolFields( eventName: unknown, hookPayload: Record ): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { + if ( + eventName === 'BeforeTool' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) ?? @@ -1756,8 +1761,11 @@ function normalizeGeminiEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + // Why: Gemini CLI's native pre-tool event is BeforeTool. PreToolUse/PostToolUse + // remain accepted for legacy Antigravity-compatible payloads on this endpoint. const stateName = eventName === 'BeforeAgent' || + eventName === 'BeforeTool' || eventName === 'AfterTool' || eventName === 'PreToolUse' || eventName === 'PostToolUse' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1a7c2aa073c..339d121486d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -273,6 +273,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalPet: false, experimentalActivity: false, experimentalActivityDefaultedOffForAllUsers: true, + experimentalTerminalAttention: false, experimentalWorktreeSymlinks: false, // Why: local desktop remains the default server until the user explicitly // selects a saved runtime environment. diff --git a/src/main/file-explorer-rename-collision.ts b/src/shared/filesystem-rename-collision.ts similarity index 95% rename from src/main/file-explorer-rename-collision.ts rename to src/shared/filesystem-rename-collision.ts index 073eaf2b4aa..5c24fcf6772 100644 --- a/src/main/file-explorer-rename-collision.ts +++ b/src/shared/filesystem-rename-collision.ts @@ -28,7 +28,7 @@ function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean { ) } -export async function assertFileExplorerRenameDestinationAvailable( +export async function assertNoClobberRenameDestinationAvailable( oldPath: string, newPath: string ): Promise { diff --git a/src/shared/git-discard-path-safety.test.ts b/src/shared/git-discard-path-safety.test.ts new file mode 100644 index 00000000000..f6b0e2dbefc --- /dev/null +++ b/src/shared/git-discard-path-safety.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { access, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import * as path from 'path' +import { tmpdir } from 'os' +import { removeSafeUntrackedDiscardTarget } from './git-discard-path-safety' + +const tempRoots: string[] = [] + +async function createWorktree(): Promise { + const worktreePath = await mkdtemp(path.join(tmpdir(), 'orca-discard-safety-')) + tempRoots.push(worktreePath) + return worktreePath +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('removeSafeUntrackedDiscardTarget', () => { + it('rejects empty and root targets before removing anything', async () => { + const worktreePath = await createWorktree() + const keepFile = path.join(worktreePath, 'keep.txt') + const removePath = vi.fn(async () => undefined) + await writeFile(keepFile, 'keep') + + await expect(removeSafeUntrackedDiscardTarget(worktreePath, '', removePath)).rejects.toThrow( + 'resolves outside the worktree' + ) + await expect(removeSafeUntrackedDiscardTarget(worktreePath, '.', removePath)).rejects.toThrow( + 'resolves outside the worktree' + ) + await expect( + removeSafeUntrackedDiscardTarget(worktreePath, 'child/..', removePath) + ).rejects.toThrow('resolves outside the worktree') + + await expect(access(keepFile)).resolves.toBeUndefined() + expect(removePath).not.toHaveBeenCalled() + }) + + it('allows missing child paths whose nearest existing parent is the worktree', async () => { + const worktreePath = await createWorktree() + const removePath = vi.fn(async () => undefined) + await mkdir(path.join(worktreePath, 'existing')) + + await expect( + removeSafeUntrackedDiscardTarget(worktreePath, 'existing/missing.txt', removePath) + ).resolves.toBeUndefined() + await expect( + removeSafeUntrackedDiscardTarget(worktreePath, 'new-dir/missing.txt', removePath) + ).resolves.toBeUndefined() + await expect( + removeSafeUntrackedDiscardTarget(worktreePath, 'missing.txt', removePath) + ).resolves.toBeUndefined() + await expect(access(worktreePath)).resolves.toBeUndefined() + expect(removePath).toHaveBeenNthCalledWith(1, 'existing/missing.txt') + expect(removePath).toHaveBeenNthCalledWith(2, 'new-dir/missing.txt') + expect(removePath).toHaveBeenNthCalledWith(3, 'missing.txt') + }) +}) diff --git a/src/shared/git-discard-path-safety.ts b/src/shared/git-discard-path-safety.ts new file mode 100644 index 00000000000..8eadb4b402d --- /dev/null +++ b/src/shared/git-discard-path-safety.ts @@ -0,0 +1,126 @@ +import { lstat, realpath } from 'fs/promises' +import * as path from 'path' + +function isENOENT(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + +function isInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const relativePath = path.relative(rootPath, candidatePath) + return ( + relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ) +} + +async function assertRealPathInsideWorktree( + realWorktreePath: string, + candidatePath: string, + originalFilePath: string +): Promise { + const realCandidatePath = path.resolve(await realpath(candidatePath)) + if (!isInsideOrEqual(realWorktreePath, realCandidatePath)) { + throw new Error(`Path "${originalFilePath}" resolves outside the worktree`) + } +} + +async function assertNearestExistingParentInsideWorktree( + realWorktreePath: string, + candidatePath: string, + originalFilePath: string +): Promise { + let parentPath = path.dirname(candidatePath) + while (parentPath !== path.dirname(parentPath)) { + try { + await assertRealPathInsideWorktree(realWorktreePath, parentPath, originalFilePath) + return + } catch (error) { + if (!isENOENT(error)) { + throw error + } + parentPath = path.dirname(parentPath) + } + } + + throw new Error(`Path "${originalFilePath}" resolves outside the worktree`) +} + +function assertTargetIsWorktreeChild( + resolvedWorktreePath: string, + resolvedTarget: string, + originalFilePath: string +): void { + const relativeTarget = path.relative(resolvedWorktreePath, resolvedTarget) + // Why: force-removing the worktree root is never a valid untracked discard, + // even when callers accidentally pass an empty or self-referential path. + if ( + relativeTarget === '' || + relativeTarget === '.' || + relativeTarget === '..' || + relativeTarget.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeTarget) + ) { + throw new Error(`Path "${originalFilePath}" resolves outside the worktree`) + } +} + +async function validateUntrackedDiscardTarget( + worktreePath: string, + filePath: string +): Promise { + const resolvedWorktreePath = path.resolve(worktreePath) + const resolvedTarget = path.resolve(worktreePath, filePath) + assertTargetIsWorktreeChild(resolvedWorktreePath, resolvedTarget, filePath) + + const realWorktreePath = path.resolve(await realpath(worktreePath)) + + try { + const targetStats = await lstat(resolvedTarget) + // Why: discard should remove a symlink leaf itself, but symlinked parents + // must not redirect recursive removal outside the real worktree. + const pathToValidate = targetStats.isSymbolicLink() + ? path.dirname(resolvedTarget) + : resolvedTarget + await assertRealPathInsideWorktree(realWorktreePath, pathToValidate, filePath) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + await assertNearestExistingParentInsideWorktree(realWorktreePath, resolvedTarget, filePath) + } + + return resolvedTarget +} + +export async function removeSafeUntrackedDiscardTarget( + worktreePath: string, + filePath: string, + removePath: (filePath: string) => Promise +): Promise { + await validateUntrackedDiscardTarget(worktreePath, filePath) + await removePath(filePath) +} + +export async function removeSafeUntrackedDiscardTargets( + worktreePath: string, + filePaths: readonly string[], + removePaths: (filePaths: readonly string[]) => Promise, + beforeRemove?: () => Promise +): Promise { + await Promise.all( + filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath)) + ) + + // Why: bulk discard must validate every untracked path before mutating + // tracked files, then recheck before the caller's Git-bounded cleanup runs. + await beforeRemove?.() + + await Promise.all( + filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath)) + ) + await removePaths(filePaths) +} diff --git a/src/shared/nested-repo-telemetry-schema.test.ts b/src/shared/nested-repo-telemetry-schema.test.ts new file mode 100644 index 00000000000..7b9450a6b3b --- /dev/null +++ b/src/shared/nested-repo-telemetry-schema.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-events' + +const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09' + +describe('nested repo import telemetry schemas', () => { + it('accepts scan, action, and result telemetry payloads', () => { + expect( + eventSchemas.add_repo_nested_scan_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 3, + found_count_bucket: '2-3', + truncated: false, + timed_out: false + }).success + ).toBe(true) + + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + action: 'import_group', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 3, + selected_count_bucket: '2-3', + all_selected: true + }).success + ).toBe(true) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'runtime', + mode: 'group', + outcome: 'success', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 3, + selected_count_bucket: '2-3', + imported_count: 3, + imported_count_bucket: '2-3', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }).success + ).toBe(true) + }) + + it('rejects raw paths, repo names, and raw errors via .strict()', () => { + expect( + eventSchemas.add_repo_nested_scan_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 1, + found_count_bucket: '1', + truncated: false, + timed_out: false, + selected_path: '/Users/alice/work/platform' + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + action: 'import_group', + found_count: 1, + found_count_bucket: '1', + selected_count: 1, + selected_count_bucket: '1', + all_selected: true, + repo_name: 'secret-service' + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + mode: 'group', + outcome: 'failed', + found_count: 1, + found_count_bucket: '1', + selected_count: 1, + selected_count_bucket: '1', + imported_count: 0, + imported_count_bucket: '0', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 1, + failed_count_bucket: '1', + all_selected: true, + error_message: 'failed at /Users/alice/work/platform' + }).success + ).toBe(false) + }) + + it('rejects unbounded imported counts', () => { + const parsed = eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + mode: 'separate', + outcome: 'success', + found_count: 501, + found_count_bucket: '16+', + selected_count: 501, + selected_count_bucket: '16+', + imported_count: 501, + imported_count_bucket: '16+', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }) + + expect(parsed.success).toBe(false) + }) + + it('rejects mismatched exact counts and buckets', () => { + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + action: 'import_group', + found_count: 1, + found_count_bucket: '16+', + selected_count: 1, + selected_count_bucket: '1', + all_selected: true + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + mode: 'group', + outcome: 'success', + found_count: 2, + found_count_bucket: '2-3', + selected_count: 2, + selected_count_bucket: '2-3', + imported_count: 2, + imported_count_bucket: '16+', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/nested-repo-telemetry.test.ts b/src/shared/nested-repo-telemetry.test.ts new file mode 100644 index 00000000000..4e7db731b9d --- /dev/null +++ b/src/shared/nested-repo-telemetry.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { + NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, + bucketNestedRepoTelemetryCount, + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + capNestedRepoTelemetryCount, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry +} from './nested-repo-telemetry' +import type { NestedRepoScanResult, ProjectGroupImportResult } from './types' + +const scanResult: NestedRepoScanResult = { + selectedPath: '/workspace/platform', + selectedPathKind: 'non_git_folder', + repos: [ + { path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }, + { path: '/workspace/platform/services/api', displayName: 'api', depth: 2 }, + { path: '/workspace/platform/services/billing', displayName: 'billing', depth: 2 } + ], + truncated: false, + timedOut: false, + durationMs: 42, + maxDepth: 3 +} +const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09' +const nextAttemptId = 'd22bb9e0-b7f8-480a-8a2a-9b34f84f2c42' + +describe('nested repo telemetry payloads', () => { + it('caps and buckets repo counts for low-cardinality breakdowns', () => { + expect(capNestedRepoTelemetryCount(-1)).toBe(0) + expect(capNestedRepoTelemetryCount(2.9)).toBe(2) + expect(capNestedRepoTelemetryCount(Number.NaN)).toBe(0) + expect(capNestedRepoTelemetryCount(999)).toBe(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT) + + expect(bucketNestedRepoTelemetryCount(0)).toBe('0') + expect(bucketNestedRepoTelemetryCount(1)).toBe('1') + expect(bucketNestedRepoTelemetryCount(3)).toBe('2-3') + expect(bucketNestedRepoTelemetryCount(7)).toBe('4-7') + expect(bucketNestedRepoTelemetryCount(15)).toBe('8-15') + expect(bucketNestedRepoTelemetryCount(16)).toBe('16+') + }) + + it('classifies a scan that should show nested repo review', () => { + expect( + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'local', + scan: scanResult + }) + ).toEqual({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 3, + found_count_bucket: '2-3', + truncated: false, + timed_out: false + }) + }) + + it('records import action selection without raw path details', () => { + expect( + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + action: 'import_group', + foundCount: 3, + selectedCount: 2 + }) + ).toEqual({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + action: 'import_group', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 2, + selected_count_bucket: '2-3', + all_selected: false + }) + }) + + it('computes all_selected from raw counts before caps are applied', () => { + const action = buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + action: 'import_group', + foundCount: 600, + selectedCount: 500 + }) + const result = buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + mode: 'group', + foundCount: 600, + selectedCount: 500, + result: { importedCount: 500, alreadyKnownCount: 0, failedCount: 0, projects: [] } + }) + + expect(action.found_count).toBe(500) + expect(action.selected_count).toBe(500) + expect(action.all_selected).toBe(false) + expect(result.all_selected).toBe(false) + }) + + it('keeps exact imported counts on import result payloads', () => { + const result: ProjectGroupImportResult = { + importedCount: 2, + alreadyKnownCount: 1, + failedCount: 1, + projects: [ + { path: '/workspace/platform/apps/web', projectId: 'web', status: 'imported' }, + { path: '/workspace/platform/services/api', projectId: 'api', status: 'imported' }, + { + path: '/workspace/platform/services/billing', + projectId: 'billing', + status: 'already-known' + }, + { path: '/workspace/platform/tools/cli', status: 'failed', error: 'Not a git repo' } + ] + } + + expect( + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'runtime', + mode: 'group', + foundCount: 4, + selectedCount: 4, + result + }) + ).toMatchObject({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'runtime', + mode: 'group', + outcome: 'partial_failure', + found_count: 4, + selected_count: 4, + imported_count: 2, + already_known_count: 1, + failed_count: 1, + all_selected: true + }) + }) + + it('generates non-persistent random attempt ids', () => { + const first = createNestedRepoTelemetryAttemptId() + const second = createNestedRepoTelemetryAttemptId() + + expect(first).toMatch(/^[0-9a-f-]{36}$/) + expect(second).not.toBe(first) + }) + + it('threads one attempt id across scan, action, and result and allows a new scan id', () => { + const scan = buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan: scanResult + }) + const action = buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + action: 'import_separate', + foundCount: 3, + selectedCount: 3 + }) + const result = buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + mode: 'separate', + foundCount: 3, + selectedCount: 3, + result: { importedCount: 3, alreadyKnownCount: 0, failedCount: 0, projects: [] } + }) + const nextScan = buildNestedRepoScanTelemetry({ + attemptId: nextAttemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan: scanResult + }) + + expect(action.attempt_id).toBe(scan.attempt_id) + expect(result.attempt_id).toBe(scan.attempt_id) + expect(nextScan.attempt_id).not.toBe(scan.attempt_id) + }) + + it('prevents zero-selection submit telemetry', () => { + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 0 + }) + ).toBe(false) + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 1, + isBusy: true + }) + ).toBe(false) + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 1 + }) + ).toBe(true) + }) +}) diff --git a/src/shared/nested-repo-telemetry.ts b/src/shared/nested-repo-telemetry.ts new file mode 100644 index 00000000000..4f5aca068e8 --- /dev/null +++ b/src/shared/nested-repo-telemetry.ts @@ -0,0 +1,228 @@ +import type { + NestedRepoScanResult, + ProjectGroupImportMode, + ProjectGroupImportResult +} from './types' + +export const NESTED_REPO_TELEMETRY_MAX_REPO_COUNT = 500 + +export const NESTED_REPO_TELEMETRY_SURFACES = ['onboarding', 'sidebar'] as const +export type NestedRepoTelemetrySurface = (typeof NESTED_REPO_TELEMETRY_SURFACES)[number] + +export const NESTED_REPO_TELEMETRY_RUNTIME_KINDS = ['local', 'runtime', 'ssh'] as const +export type NestedRepoTelemetryRuntimeKind = (typeof NESTED_REPO_TELEMETRY_RUNTIME_KINDS)[number] + +export const NESTED_REPO_SCAN_RESULTS = [ + 'review_shown', + 'git_repo', + 'no_nested_repos', + 'scan_failed' +] as const +export type NestedRepoScanTelemetryResult = (typeof NESTED_REPO_SCAN_RESULTS)[number] + +export const NESTED_REPO_IMPORT_ACTIONS = ['import_group', 'import_separate', 'back'] as const +export type NestedRepoImportTelemetryAction = (typeof NESTED_REPO_IMPORT_ACTIONS)[number] + +export const NESTED_REPO_IMPORT_OUTCOMES = ['success', 'partial_failure', 'failed'] as const +export type NestedRepoImportTelemetryOutcome = (typeof NESTED_REPO_IMPORT_OUTCOMES)[number] + +export const NESTED_REPO_COUNT_BUCKETS = ['0', '1', '2-3', '4-7', '8-15', '16+'] as const +export type NestedRepoCountBucket = (typeof NESTED_REPO_COUNT_BUCKETS)[number] + +type NestedRepoTelemetryBase = { + attempt_id: string + surface: NestedRepoTelemetrySurface + runtime_kind: NestedRepoTelemetryRuntimeKind +} + +export type NestedRepoScanTelemetry = NestedRepoTelemetryBase & { + result: NestedRepoScanTelemetryResult + selected_path_kind?: NestedRepoScanResult['selectedPathKind'] + found_count: number + found_count_bucket: NestedRepoCountBucket + truncated: boolean + timed_out: boolean +} + +export type NestedRepoImportActionTelemetry = NestedRepoTelemetryBase & { + action: NestedRepoImportTelemetryAction + found_count: number + found_count_bucket: NestedRepoCountBucket + selected_count: number + selected_count_bucket: NestedRepoCountBucket + all_selected: boolean +} + +export type NestedRepoImportResultTelemetry = NestedRepoTelemetryBase & { + mode: ProjectGroupImportMode + outcome: NestedRepoImportTelemetryOutcome + found_count: number + found_count_bucket: NestedRepoCountBucket + selected_count: number + selected_count_bucket: NestedRepoCountBucket + imported_count: number + imported_count_bucket: NestedRepoCountBucket + already_known_count: number + already_known_count_bucket: NestedRepoCountBucket + failed_count: number + failed_count_bucket: NestedRepoCountBucket + all_selected: boolean +} + +export function capNestedRepoTelemetryCount(count: number): number { + if (!Number.isFinite(count)) { + return 0 + } + return Math.max(0, Math.min(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, Math.floor(count))) +} + +function normalizeNestedRepoTelemetryCount(count: number): number { + if (!Number.isFinite(count)) { + return 0 + } + return Math.max(0, Math.floor(count)) +} + +export function bucketNestedRepoTelemetryCount(count: number): NestedRepoCountBucket { + const capped = capNestedRepoTelemetryCount(count) + if (capped === 0) { + return '0' + } + if (capped === 1) { + return '1' + } + if (capped <= 3) { + return '2-3' + } + if (capped <= 7) { + return '4-7' + } + if (capped <= 15) { + return '8-15' + } + return '16+' +} + +export function shouldEmitNestedRepoImportSubmitTelemetry(args: { + attemptId: string | null + selectedCount: number + isBusy?: boolean +}): boolean { + return Boolean(args.attemptId && args.selectedCount > 0 && !args.isBusy) +} + +export function createNestedRepoTelemetryAttemptId(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + + const bytes = new Uint8Array(16) + if (typeof cryptoApi?.getRandomValues === 'function') { + cryptoApi.getRandomValues(bytes) + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256) + } + } + + // Why: keep the fallback schema-compatible without deriving from any stable repo input. + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} + +export function buildNestedRepoScanTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + scan: NestedRepoScanResult | null +}): NestedRepoScanTelemetry { + const foundCount = capNestedRepoTelemetryCount(args.scan?.repos.length ?? 0) + const result: NestedRepoScanTelemetryResult = + args.scan === null + ? 'scan_failed' + : args.scan.selectedPathKind === 'git_repo' + ? 'git_repo' + : foundCount > 0 + ? 'review_shown' + : 'no_nested_repos' + + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + result, + ...(args.scan ? { selected_path_kind: args.scan.selectedPathKind } : {}), + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + truncated: args.scan?.truncated ?? false, + timed_out: args.scan?.timedOut ?? false + } +} + +export function buildNestedRepoImportActionTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + action: NestedRepoImportTelemetryAction + foundCount: number + selectedCount: number +}): NestedRepoImportActionTelemetry { + const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount) + const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount) + const foundCount = capNestedRepoTelemetryCount(args.foundCount) + const selectedCount = capNestedRepoTelemetryCount(args.selectedCount) + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + action: args.action, + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + selected_count: selectedCount, + selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount), + all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount + } +} + +export function buildNestedRepoImportResultTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + mode: ProjectGroupImportMode + foundCount: number + selectedCount: number + result: ProjectGroupImportResult | null +}): NestedRepoImportResultTelemetry { + const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount) + const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount) + const foundCount = capNestedRepoTelemetryCount(args.foundCount) + const selectedCount = capNestedRepoTelemetryCount(args.selectedCount) + const importedCount = capNestedRepoTelemetryCount(args.result?.importedCount ?? 0) + const alreadyKnownCount = capNestedRepoTelemetryCount(args.result?.alreadyKnownCount ?? 0) + const failedCount = capNestedRepoTelemetryCount(args.result?.failedCount ?? selectedCount) + const acceptedCount = importedCount + alreadyKnownCount + const outcome: NestedRepoImportTelemetryOutcome = + acceptedCount === 0 ? 'failed' : failedCount > 0 ? 'partial_failure' : 'success' + + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + mode: args.mode, + outcome, + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + selected_count: selectedCount, + selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount), + imported_count: importedCount, + imported_count_bucket: bucketNestedRepoTelemetryCount(importedCount), + already_known_count: alreadyKnownCount, + already_known_count_bucket: bucketNestedRepoTelemetryCount(alreadyKnownCount), + failed_count: failedCount, + failed_count_bucket: bucketNestedRepoTelemetryCount(failedCount), + all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount + } +} diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 3f802752cb9..3aa012ea434 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -18,6 +18,16 @@ import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry' import { FEATURE_WALL_EXIT_ACTIONS, FEATURE_WALL_TOUR_DEPTH_STEPS } from './feature-wall-tour-depth' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' +import { + NESTED_REPO_COUNT_BUCKETS, + NESTED_REPO_IMPORT_ACTIONS, + NESTED_REPO_IMPORT_OUTCOMES, + NESTED_REPO_SCAN_RESULTS, + NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, + NESTED_REPO_TELEMETRY_RUNTIME_KINDS, + NESTED_REPO_TELEMETRY_SURFACES, + bucketNestedRepoTelemetryCount +} from './nested-repo-telemetry' import { AGENT_HOOK_TARGETS } from './agent-hook-types' import { ONBOARDING_FINAL_STEP } from './constants' @@ -227,6 +237,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalMobile', 'experimentalPet', 'experimentalActivity', + 'experimentalTerminalAttention', 'experimentalWorktreeSymlinks', 'geminiCliOAuthEnabled' ] as const satisfies readonly BooleanGlobalSettingsKey[] @@ -580,6 +591,103 @@ void _onboardingChecklistItemSyncCheck // `'cohort' in schema.shape`, so there is no parallel hand-maintained list. const cohortSchema = z.enum(['fresh_install', 'upgrade_backfill']).optional() +const nestedRepoTelemetrySurfaceSchema = z.enum(NESTED_REPO_TELEMETRY_SURFACES) +const nestedRepoTelemetryRuntimeKindSchema = z.enum(NESTED_REPO_TELEMETRY_RUNTIME_KINDS) +const nestedRepoCountSchema = z.number().int().min(0).max(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT) +const nestedRepoCountBucketSchema = z.enum(NESTED_REPO_COUNT_BUCKETS) +const nestedRepoScanResultSchema = z.enum(NESTED_REPO_SCAN_RESULTS) +const nestedRepoImportActionSchema = z.enum(NESTED_REPO_IMPORT_ACTIONS) +const nestedRepoImportOutcomeSchema = z.enum(NESTED_REPO_IMPORT_OUTCOMES) +const nestedRepoScanPathKindSchema = z.enum(['git_repo', 'non_git_folder']) +const nestedRepoImportModeSchema = z.enum(['group', 'separate']) +const nestedRepoAttemptIdSchema = z.string().uuid() + +function validateNestedRepoCountBucket( + props: Record, + countKey: string, + bucketKey: string, + ctx: z.RefinementCtx +): void { + const count = props[countKey] + const bucket = props[bucketKey] + if (typeof count !== 'number' || typeof bucket !== 'string') { + return + } + if (bucketNestedRepoTelemetryCount(count) !== bucket) { + ctx.addIssue({ + code: 'custom', + path: [bucketKey], + message: `${bucketKey} must match ${countKey}` + }) + } +} + +function validateNestedRepoCountBuckets( + props: Record, + ctx: z.RefinementCtx +): void { + validateNestedRepoCountBucket(props, 'found_count', 'found_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'selected_count', 'selected_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'imported_count', 'imported_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'already_known_count', 'already_known_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'failed_count', 'failed_count_bucket', ctx) +} + +const nestedRepoTelemetryBaseSchema = { + // Why: high-cardinality by design, but random and non-persistent. It lets + // dashboards count scan -> action -> result attempts without path-derived IDs. + attempt_id: nestedRepoAttemptIdSchema, + surface: nestedRepoTelemetrySurfaceSchema, + runtime_kind: nestedRepoTelemetryRuntimeKindSchema, + nth_repo_added: nthRepoAddedSchema +} as const + +const addRepoNestedScanResultSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + result: nestedRepoScanResultSchema, + selected_path_kind: nestedRepoScanPathKindSchema.optional(), + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + truncated: z.boolean(), + timed_out: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + +const addRepoNestedImportActionSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + action: nestedRepoImportActionSchema, + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + selected_count: nestedRepoCountSchema, + selected_count_bucket: nestedRepoCountBucketSchema, + all_selected: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + +const addRepoNestedImportResultSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + mode: nestedRepoImportModeSchema, + outcome: nestedRepoImportOutcomeSchema, + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + selected_count: nestedRepoCountSchema, + selected_count_bucket: nestedRepoCountBucketSchema, + imported_count: nestedRepoCountSchema, + imported_count_bucket: nestedRepoCountBucketSchema, + already_known_count: nestedRepoCountSchema, + already_known_count_bucket: nestedRepoCountBucketSchema, + failed_count: nestedRepoCountSchema, + failed_count_bucket: nestedRepoCountBucketSchema, + all_selected: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + // `'button' | 'keyboard'` records whether the user advanced via a footer // button click, Cmd/Ctrl+Enter, or an equivalent keyboard exit like Escape. // The uniform shape lets keyboard skip/dismiss paths arrive without a @@ -900,6 +1008,9 @@ export const eventSchemas = { repo_added: repoAddedSchema, add_repo_setup_step_action: addRepoSetupStepActionEventSchema, add_repo_existing_workspaces_detected: addRepoExistingWorkspacesDetectedSchema, + add_repo_nested_scan_result: addRepoNestedScanResultSchema, + add_repo_nested_import_action: addRepoNestedImportActionSchema, + add_repo_nested_import_result: addRepoNestedImportResultSchema, workspace_created: workspaceCreatedSchema, workspace_create_failed: workspaceCreateFailedSchema, setup_script_prompt_shown: setupScriptPromptShownSchema, @@ -988,6 +1099,9 @@ type _CohortExtendedRoster = | 'repo_added' | 'add_repo_setup_step_action' | 'add_repo_existing_workspaces_detected' + | 'add_repo_nested_scan_result' + | 'add_repo_nested_import_action' + | 'add_repo_nested_import_result' | 'workspace_created' | 'workspace_create_failed' | 'setup_script_prompt_shown' diff --git a/src/shared/types.ts b/src/shared/types.ts index 81ad5c5076a..0b50a74094b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1903,6 +1903,10 @@ export type GlobalSettings = { /** One-shot migration guard for defaulting the Agents view off for all * users. Once set, later explicit opt-ins persist normally. */ experimentalActivityDefaultedOffForAllUsers?: boolean + /** Experimental: persistent terminal pane attention ring for terminal bell + * and agent-completion events. Opt-in while the signal/noise balance is + * being tested. */ + experimentalTerminalAttention: boolean /** Experimental: when creating a worktree, automatically symlink a * user-configured set of files/folders from the primary checkout (e.g. * `.env`, `node_modules`) into the new worktree. Opt-in while the diff --git a/src/shared/updater-renderer-events.ts b/src/shared/updater-renderer-events.ts index a67b08e1a2f..49097bad6ae 100644 --- a/src/shared/updater-renderer-events.ts +++ b/src/shared/updater-renderer-events.ts @@ -1,2 +1,4 @@ export const ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT = 'orca:updater-quit-and-install-started' export const ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT = 'orca:updater-quit-and-install-aborted' +export const ORCA_APP_RESTART_STARTED_EVENT = 'orca:app-restart-started' +export const ORCA_APP_RESTART_ABORTED_EVENT = 'orca:app-restart-aborted' diff --git a/tests/e2e/droid-notification.spec.ts b/tests/e2e/droid-notification.spec.ts index 7cfa6f5bdf5..70ceb31a3a4 100644 --- a/tests/e2e/droid-notification.spec.ts +++ b/tests/e2e/droid-notification.spec.ts @@ -21,6 +21,14 @@ type NotificationDispatch = { agentLastAssistantMessage?: string } +type AgentStatusSummary = { + paneKey: string + state: string + agentType?: string + prompt?: string + lastAssistantMessage?: string +} + async function emitOscTitle(page: Page, ptyId: string, title: string) { await sendToTerminal(page, ptyId, `printf '\\033]0;${title}\\007'\r`) } @@ -93,15 +101,7 @@ async function switchToOtherExistingWorktree(page: Page): Promise { }) } -async function getAgentStatuses(page: Page): Promise< - { - paneKey: string - state: string - agentType?: string - prompt?: string - lastAssistantMessage?: string - }[] -> { +async function getAgentStatuses(page: Page): Promise { return page.evaluate(() => { const store = window.__store if (!store) { @@ -117,6 +117,27 @@ async function getAgentStatuses(page: Page): Promise< }) } +async function getCachedAgentStatuses(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.agentStatus.getSnapshot() + return snapshot.map((entry) => ({ + paneKey: entry.paneKey, + state: entry.state, + agentType: entry.agentType, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage + })) + }) +} + +async function getRendererOrCachedAgentStatuses(page: Page): Promise { + const [rendererStatuses, cachedStatuses] = await Promise.all([ + getAgentStatuses(page), + getCachedAgentStatuses(page) + ]) + return [...rendererStatuses, ...cachedStatuses] +} + test.describe('Droid notifications', () => { test('Codex hook completion dispatches while its worktree is inactive', async ({ orcaPage, @@ -147,13 +168,15 @@ test.describe('Droid notifications', () => { await expect .poll( async () => - (await getAgentStatuses(orcaPage)).some( + (await getRendererOrCachedAgentStatuses(orcaPage)).some( (status) => status.agentType === 'codex' && status.state === 'working' && status.prompt === prompt ), { - timeout: 10_000, - message: 'Codex UserPromptSubmit hook did not reach renderer agent status' + timeout: 30_000, + // Why: this synthetic hook posts directly to main; main's cache is + // the durable source used to recover renderer startup/listener races. + message: 'Codex UserPromptSubmit hook did not reach agent status cache' } ) .toBe(true) @@ -179,7 +202,7 @@ test.describe('Droid notifications', () => { status.lastAssistantMessage === finalMessage ), { - timeout: 10_000, + timeout: 30_000, message: 'Codex Stop hook did not reach renderer agent status' } ) @@ -192,7 +215,7 @@ test.describe('Droid notifications', () => { return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete') }, { - timeout: 10_000, + timeout: 30_000, message: 'Codex hook Stop did not dispatch task-complete while worktree was inactive' } )