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. */}
-
onUse(workItem)}
- className="gap-1.5 whitespace-nowrap"
- aria-label="Start workspace from PR"
- >
- Start workspace from PR
-
-
+
+
+
+ {attachedWorkspace ? 'Open workspace' : 'Start workspace from PR'}
+
+
+
+
+
+
+
+
+
+ {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
// because it holds nested interactive elements
@@ -5226,6 +5260,12 @@ export default function TaskPage(): React.JSX.Element {
{formatPRDelta(item)}
) : null}
+ {attachedWorkspaceLabel ? (
+
+
+ {attachedWorkspaceLabel}
+
+ ) : null}
{item.labels.slice(0, 3).map((label) => (
-
{
- event.stopPropagation()
- handleUseWorkItem(item)
- }}
- className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
- >
- Start
-
-
-
-
-
+
+ {
+ event.stopPropagation()
+ handleOpenOrUseGitHubPR(item)
+ }}
+ className="bg-background/80"
+ aria-label={
+ attachedWorkspace
+ ? 'Open workspace attached to PR'
+ : 'Start workspace from PR'
+ }
+ >
+ {attachedWorkspace ? 'Open' : 'Start'}
+
+
+
+ event.stopPropagation()}
+ className="bg-background/80"
+ aria-label="More PR actions"
+ >
+
+
+
+
+ event.stopPropagation()}
+ >
+ {attachedWorkspace ? (
+ handleUseWorkItem(item)}>
+
+ Start new workspace
+
+ ) : null}
+ window.api.shell.openUrl(item.url)}
+ >
+
+ Open in browser
+
+
+
+ ) : (
+
{
+ event.stopPropagation()
+ handleUseWorkItem(item)
+ }}
+ className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
+ >
+ Start
+
+
+ )}
+ {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()}
- 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)
}}
>
-
- {headerCopyState.pathLabel}
-
+ {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}
+ />
+ ) : (
+
+ {headerCopyState.pathLabel}
+
+ )}
-
+ {
+ 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 ? (
+
+
+
+
Terminal attention
+
+ 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.
+
+
+
+ updateSettings({
+ experimentalTerminalAttention: !settings.experimentalTerminalAttention
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.experimentalTerminalAttention ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
+
+ ) : 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) && (
+
+ )}
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onClose()
+ }}
+ >
+
+
+
+ )
+
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) && (
-
- )}
- e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation()
- onClose()
- }}
- >
-
-
-
+ {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 && (
+
+ )}
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onClose()
+ }}
+ >
+
+
+
+
+ )
+
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 && (
-
- )}
- e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation()
- onClose()
- }}
+ {isRenaming || menuOpen ? (
+ tabRoot
+ ) : (
+
+ {tabRoot}
+
-
-
-
-
+ {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 && (
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onToggleExpand(tab.id)
+ }}
+ title="Collapse pane"
+ aria-label="Collapse pane"
+ >
+
+
+ )}
+ {!isEditing && (
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onClose(tab.id)
+ }}
+ >
+
+
+ )}
+
+ )
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 && (
- e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation()
- onToggleExpand(tab.id)
- }}
- title="Collapse pane"
- aria-label="Collapse pane"
- >
-
-
- )}
- {!isEditing && (
- e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation()
- onClose(tab.id)
- }}
- >
-
-
- )}
-
+ {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'
}
)