From c9391e203f701937438615eba9d4a0d64fdb7dc7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:21:31 -0700 Subject: [PATCH 001/259] feat(orchestration): add inter-agent orchestration system (#1188) Co-authored-by: Orca --- .github/workflows/pr.yml | 6 + bin/orca-dev | 28 + config/scripts/install-dev-cli.mjs | 50 ++ config/scripts/run-electron-vite-dev.mjs | 48 ++ package.json | 9 +- pnpm-lock.yaml | 165 +++++ scripts/rebuild-native-deps.mjs | 14 + skills/orca-cli/SKILL.md | 10 +- src/cli/args.ts | 3 +- src/cli/dispatch.ts | 4 +- src/cli/handlers/orchestration.ts | 263 +++++++ src/cli/help.ts | 17 + src/cli/index.test.ts | 73 ++ src/cli/specs/index.ts | 4 +- src/cli/specs/orchestration.ts | 119 ++++ src/main/ipc/pty.test.ts | 57 +- src/main/ipc/pty.ts | 55 +- src/main/ipc/ssh.test.ts | 39 ++ src/main/ipc/ssh.ts | 5 +- src/main/runtime/orca-runtime.test.ts | 169 ++++- src/main/runtime/orca-runtime.ts | 501 ++++++++++++- .../runtime/orchestration/coordinator.test.ts | 379 ++++++++++ src/main/runtime/orchestration/coordinator.ts | 491 +++++++++++++ src/main/runtime/orchestration/db.test.ts | 457 ++++++++++++ src/main/runtime/orchestration/db.ts | 561 +++++++++++++++ .../runtime/orchestration/formatter.test.ts | 104 +++ src/main/runtime/orchestration/formatter.ts | 43 ++ src/main/runtime/orchestration/groups.test.ts | 129 ++++ src/main/runtime/orchestration/groups.ts | 71 ++ .../runtime/orchestration/preamble.test.ts | 82 +++ src/main/runtime/orchestration/preamble.ts | 41 ++ src/main/runtime/orchestration/types.ts | 77 ++ src/main/runtime/rpc/methods/index.ts | 4 +- .../rpc/methods/orchestration-gates.ts | 154 ++++ .../runtime/rpc/methods/orchestration.test.ts | 656 ++++++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 402 +++++++++++ src/main/runtime/runtime-rpc.test.ts | 5 +- src/main/ssh/ssh-relay-session.ts | 4 + .../startup/run-electron-vite-dev.test.ts | 1 + .../window/attach-main-window-services.ts | 2 +- .../components/settings/ExperimentalPane.tsx | 119 +++- .../settings/experimental-search.ts | 18 + src/renderer/src/lib/tui-agent-startup.ts | 15 +- .../src/runtime/sync-runtime-graph.ts | 4 +- src/shared/agent-detection.ts | 20 + src/shared/runtime-types.ts | 1 + 46 files changed, 5407 insertions(+), 72 deletions(-) create mode 100755 bin/orca-dev create mode 100644 config/scripts/install-dev-cli.mjs create mode 100644 src/cli/handlers/orchestration.ts create mode 100644 src/cli/specs/orchestration.ts create mode 100644 src/main/runtime/orchestration/coordinator.test.ts create mode 100644 src/main/runtime/orchestration/coordinator.ts create mode 100644 src/main/runtime/orchestration/db.test.ts create mode 100644 src/main/runtime/orchestration/db.ts create mode 100644 src/main/runtime/orchestration/formatter.test.ts create mode 100644 src/main/runtime/orchestration/formatter.ts create mode 100644 src/main/runtime/orchestration/groups.test.ts create mode 100644 src/main/runtime/orchestration/groups.ts create mode 100644 src/main/runtime/orchestration/preamble.test.ts create mode 100644 src/main/runtime/orchestration/preamble.ts create mode 100644 src/main/runtime/orchestration/types.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-gates.ts create mode 100644 src/main/runtime/rpc/methods/orchestration.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05e9310f074..678f7974689 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -55,6 +55,12 @@ jobs: - name: Typecheck run: pnpm typecheck + # Why: postinstall rebuilds better-sqlite3 for Electron's ABI via + # @electron/rebuild, but vitest runs under system Node.js. Rebuild + # it for Node so orchestration tests can load the native module. + - name: Rebuild better-sqlite3 for Node + run: pnpm rebuild better-sqlite3 + - name: Test run: pnpm test diff --git a/bin/orca-dev b/bin/orca-dev new file mode 100755 index 00000000000..97acbf9f1f7 --- /dev/null +++ b/bin/orca-dev @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Dev CLI wrapper — uses a separate userData path to avoid clobbering production Orca data. +set -euo pipefail + +# Resolve through symlinks so this works when called via /usr/local/bin/orca-dev +SOURCE="$0" +while [ -L "$SOURCE" ]; do + DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" +done +SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI_ENTRY="$REPO_ROOT/out/cli/index.js" + +if [ ! -f "$CLI_ENTRY" ]; then + echo "orca-dev: CLI not built yet. Run 'pnpm run build:cli' first." >&2 + exit 1 +fi + +case "$(uname -s)" in + Darwin) default_data="$HOME/Library/Application Support/orca-dev" ;; + MINGW*|MSYS*) default_data="${APPDATA:-$HOME/AppData/Roaming}/orca-dev" ;; + *) default_data="${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev" ;; +esac + +export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}" +exec node "$CLI_ENTRY" "$@" diff --git a/config/scripts/install-dev-cli.mjs b/config/scripts/install-dev-cli.mjs new file mode 100644 index 00000000000..1c74e797d71 --- /dev/null +++ b/config/scripts/install-dev-cli.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Symlinks the orca-dev wrapper into /usr/local/bin so the dev CLI is +// available globally after `pnpm run build:cli`. +import { existsSync, lstatSync, readlinkSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const source = path.join(repoRoot, 'bin', 'orca-dev') + +const commandPath = + process.platform === 'darwin' || process.platform === 'linux' + ? '/usr/local/bin/orca-dev' + : null + +if (!commandPath) { + console.log('[orca-dev] Skipping global symlink (unsupported platform).') + process.exit(0) +} + +function isOwnedByUs(target) { + try { + if (!lstatSync(target).isSymbolicLink()) { return false } + return readlinkSync(target) === source + } catch { + return false + } +} + +if (existsSync(commandPath)) { + if (isOwnedByUs(commandPath)) { + console.log(`[orca-dev] ${commandPath} already points to dev CLI.`) + process.exit(0) + } + console.error( + `[orca-dev] ${commandPath} exists but is not our symlink. Remove it manually if you want the dev CLI installed globally.` + ) + process.exit(0) +} + +try { + execFileSync('ln', ['-s', source, commandPath], { stdio: 'inherit' }) + console.log(`[orca-dev] Symlinked ${commandPath} → ${source}`) +} catch { + console.log( + `[orca-dev] Could not create ${commandPath} (permission denied). Run once with:\n` + + ` sudo ln -s ${source} ${commandPath}` + ) +} diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 8cc21ebe112..1bfcb87c6de 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -1,6 +1,8 @@ import { spawn } from 'node:child_process' +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' +import { fileURLToPath } from 'node:url' // Why: Electron-based hosts (e.g. Claude Code, VS Code) set // ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into @@ -9,6 +11,51 @@ import path from 'node:path' delete process.env.ELECTRON_RUN_AS_NODE const require = createRequire(import.meta.url) +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +function getDevUserDataPath() { + if (process.env.ORCA_DEV_USER_DATA_PATH) { + return process.env.ORCA_DEV_USER_DATA_PATH + } + if (process.platform === 'darwin') { + return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev') + } + if (process.platform === 'win32') { + return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev') + } + return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev') +} + +function prepareDevCliWrapper() { + const binDir = path.join(repoRoot, 'out', 'bin') + mkdirSync(binDir, { recursive: true }) + const userDataPath = getDevUserDataPath() + const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js') + + if (process.platform === 'win32') { + writeFileSync( + path.join(binDir, 'orca-dev.cmd'), + `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`, + 'utf8' + ) + } else { + const wrapperPath = path.join(binDir, 'orca-dev') + writeFileSync( + wrapperPath, + `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`, + 'utf8' + ) + chmodSync(wrapperPath, 0o755) + } + + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}` + console.log(`[orca-dev] Prepared wrapper in ${binDir}`) +} + +if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') { + prepareDevCliWrapper() +} + // Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down // the full child tree without depending on a real electron-vite install. const electronViteCli = @@ -17,6 +64,7 @@ const electronViteCli = const forwardedArgs = ['dev', ...process.argv.slice(2)] const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], { stdio: 'inherit', + env: process.env, // Why: electron-vite launches Electron as a descendant process. Giving the // dev runner its own process group lets Ctrl+C kill the whole tree on macOS // instead of leaving the Electron app alive after the terminal exits. diff --git a/package.json b/package.json index 92f55da5dc8..70960a91471 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "homepage": "https://github.com/stablyai/orca", "author": "stablyai", "bin": { - "orca": "./out/cli/index.js" + "orca": "./out/cli/index.js", + "orca-dev": "./bin/orca-dev" }, "main": "./out/main/index.js", "scripts": { @@ -24,11 +25,12 @@ "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", "build:relay": "node scripts/build-relay.mjs", - "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false", + "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", "build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", "postinstall": "pnpm rebuild electron && node scripts/rebuild-native-deps.mjs", + "rebuild:electron": "node scripts/rebuild-native-deps.mjs", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", @@ -72,6 +74,7 @@ "@xterm/headless": "6.1.0-beta.198", "@xterm/xterm": "6.1.0-beta.198", "agent-browser": "~0.24.1", + "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -114,6 +117,7 @@ "@playwright/test": "^1.59.1", "@stablyai/playwright-test": "^2.1.13", "@tailwindcss/vite": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -149,6 +153,7 @@ "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", + "better-sqlite3", "cpu-features", "electron", "esbuild", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04678674d1a..95bfd66b061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: agent-browser: specifier: ~0.24.1 version: 0.24.1 + better-sqlite3: + specifier: ^12.9.0 + version: 12.9.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -236,6 +239,9 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -2672,6 +2678,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3126,6 +3135,13 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bippy@0.5.32: resolution: {integrity: sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw==} peerDependencies: @@ -3256,6 +3272,9 @@ packages: resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} engines: {node: '>=22.0.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3627,6 +3646,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3898,6 +3921,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3970,6 +3997,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -4005,6 +4035,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4088,6 +4121,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4293,6 +4329,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4950,6 +4989,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -4985,10 +5027,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + node-abi@4.28.0: resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} engines: {node: '>=22.12.0'} @@ -5258,6 +5307,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5363,6 +5418,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -5649,6 +5708,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.33.0: resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} @@ -5795,6 +5860,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -5826,6 +5895,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@7.5.12: resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==} engines: {node: '>=18'} @@ -5917,6 +5993,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -8484,6 +8563,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.5.0 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -8980,6 +9063,15 @@ snapshots: dependencies: tweetnacl: 0.14.5 + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bippy@0.5.32(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/react-reconciler': 0.28.9(@types/react@19.2.14) @@ -9156,6 +9248,8 @@ snapshots: '@chevrotain/types': 12.0.0 '@chevrotain/utils': 12.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -9515,6 +9609,8 @@ snapshots: dedent@1.7.2: {} + deep-extend@0.6.0: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -9887,6 +9983,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -9985,6 +10083,8 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -10027,6 +10127,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10113,6 +10215,8 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -10401,6 +10505,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -11224,6 +11330,8 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -11274,8 +11382,14 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@1.0.0: {} + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + node-abi@4.28.0: dependencies: semver: 7.7.4 @@ -11581,6 +11695,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11768,6 +11897,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -12200,6 +12336,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-git@3.33.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -12353,6 +12497,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -12381,6 +12527,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.5.12: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -12467,6 +12628,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} diff --git a/scripts/rebuild-native-deps.mjs b/scripts/rebuild-native-deps.mjs index 6fdb6b7b3ef..ecfe6dfcd76 100644 --- a/scripts/rebuild-native-deps.mjs +++ b/scripts/rebuild-native-deps.mjs @@ -34,11 +34,25 @@ if (ignoreModules.length > 0) { console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`) } +// Why: @electron/rebuild's default module walker doesn't reliably find native +// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to +// rebuild via `onlyModules` ensures they're recompiled against Electron's Node +// ABI regardless of the package manager's store layout. +const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] +const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) + try { await rebuild({ buildPath: projectDir, electronVersion, ignoreModules, + onlyModules, + // Why: without force, @electron/rebuild skips modules it considers + // "already built" — even when they were compiled for the wrong ABI + // (e.g., system Node instead of Electron's embedded Node). This is + // common after pnpm install, which compiles native modules for system + // Node before postinstall runs this script. + force: true, }) } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 2e3305f9557..c9d51287e07 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -16,7 +16,6 @@ Use `orca` for: - reading and replying to Orca-managed terminals - stopping or waiting on Orca-managed terminals - accessing repos known to Orca - Do not use `orca` when plain shell tools are simpler and Orca state does not matter. Examples: @@ -179,7 +178,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea - Use `terminal read` before `terminal send` unless the next input is obvious. - Use `terminal wait --terminal --for exit` only when the task actually depends on process completion. - Use `terminal wait --terminal --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout. -- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup and `--title` for labeling. +- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. - Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane. - Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists. - If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals. @@ -556,10 +555,11 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta ## Important Constraints - Orca CLI only talks to a running Orca editor. -- Terminal handles are ephemeral and tied to the current Orca runtime. -- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. -- Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions. +- Terminal handles are ephemeral and tied to the current Orca runtime. If Orca restarts, handles change. +- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. Real coding tasks routinely take 15-60 minutes — always pass `--timeout-ms` explicitly. +- Orca is the source of truth for worktree/terminal state; do not duplicate that state with manual assumptions. - The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints. +- The 120-line terminal output buffer (`terminal read`) is for status monitoring, not result extraction. ## References diff --git a/src/cli/args.ts b/src/cli/args.ts index e65c0b29081..f61cfb751a1 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -82,7 +82,8 @@ export function isCommandGroup(commandPath: string[]): boolean { 'set', 'clipboard', 'dialog', - 'storage' + 'storage', + 'orchestration' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'storage' && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index fdde0baef8f..5b245c99230 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -11,6 +11,7 @@ import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie' import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture' import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' +import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' export type HandlerContext = { flags: Map @@ -34,7 +35,8 @@ function buildHandlers(): Map { BROWSER_COOKIE_HANDLERS, BROWSER_CAPTURE_HANDLERS, BROWSER_ENV_HANDLERS, - BROWSER_STORAGE_HANDLERS + BROWSER_STORAGE_HANDLERS, + ORCHESTRATION_HANDLERS ] for (const group of groups) { for (const [key, handler] of Object.entries(group)) { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts new file mode 100644 index 00000000000..be807c28a2f --- /dev/null +++ b/src/cli/handlers/orchestration.ts @@ -0,0 +1,263 @@ +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { getTerminalHandle } from '../selectors' + +type MessageSummary = { + id: string + from_handle: string + to_handle?: string + subject: string + type?: string +} + +async function resolveOrchestrationTerminalHandle( + flags: Map, + cwd: string, + client: Parameters[0]['client'], + flagName: 'from' | 'terminal' +): Promise { + const explicit = getOptionalStringFlag(flags, flagName) + if (explicit) { + return explicit + } + const envHandle = process.env.ORCA_TERMINAL_HANDLE + if (envHandle && envHandle.length > 0) { + return envHandle + } + return await getTerminalHandle(flags, cwd, client) +} + +function isDevCliInvocation(): boolean { + return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false +} + +export const ORCHESTRATION_HANDLERS: Record = { + 'orchestration send': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await client.call< + { message: { id: string } } | { messages: { id: string }[]; recipients: number } + >('orchestration.send', { + from, + to: getRequiredStringFlag(flags, 'to'), + subject: getRequiredStringFlag(flags, 'subject'), + body: getOptionalStringFlag(flags, 'body'), + type: getOptionalStringFlag(flags, 'type'), + priority: getOptionalStringFlag(flags, 'priority'), + threadId: getOptionalStringFlag(flags, 'thread-id'), + payload: getOptionalStringFlag(flags, 'payload'), + devMode: isDevCliInvocation() + }) + printResult(result, json, (r) => { + if ('message' in r) { + return `Sent ${r.message.id}` + } + return `Sent ${r.messages.length} messages to ${r.recipients} recipients` + }) + }, + + 'orchestration check': async ({ flags, client, cwd, json }) => { + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + const result = await client.call<{ + messages: MessageSummary[] + count: number + formatted?: string + }>('orchestration.check', { + terminal, + unread: flags.has('unread') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + inject: flags.has('inject') ? true : undefined, + wait: flags.has('wait') ? true : undefined, + timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined + }) + printResult(result, json, (r) => { + if (r.formatted) { + return r.formatted + } + if (r.count === 0) { + return 'No messages.' + } + return r.messages + .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .join('\n') + }) + }, + + 'orchestration reply': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await client.call<{ message: { id: string } }>('orchestration.reply', { + id: getRequiredStringFlag(flags, 'id'), + body: getRequiredStringFlag(flags, 'body'), + from + }) + printResult(result, json, (r) => `Replied ${r.message.id}`) + }, + + 'orchestration inbox': async ({ flags, client, json }) => { + const result = await client.call<{ + messages: MessageSummary[] + count: number + }>('orchestration.inbox', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, (r) => { + if (r.count === 0) { + return 'No messages.' + } + return r.messages + .map((m) => `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`) + .join('\n') + }) + }, + + 'orchestration task-create': async ({ flags, client, json }) => { + const result = await client.call<{ task: { id: string; status: string } }>( + 'orchestration.taskCreate', + { + spec: getRequiredStringFlag(flags, 'spec'), + deps: getOptionalStringFlag(flags, 'deps'), + parent: getOptionalStringFlag(flags, 'parent') + } + ) + printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) + }, + + 'orchestration task-list': async ({ flags, client, json }) => { + const result = await client.call<{ + tasks: { id: string; spec: string; status: string }[] + count: number + }>('orchestration.taskList', { + status: getOptionalStringFlag(flags, 'status'), + ready: flags.has('ready') ? true : undefined + }) + printResult(result, json, (r) => { + if (r.count === 0) { + return 'No tasks.' + } + return r.tasks.map((t) => `${t.id} [${t.status}] ${t.spec.slice(0, 60)}`).join('\n') + }) + }, + + 'orchestration task-update': async ({ flags, client, json }) => { + const result = await client.call<{ task: { id: string; status: string } }>( + 'orchestration.taskUpdate', + { + id: getRequiredStringFlag(flags, 'id'), + status: getRequiredStringFlag(flags, 'status'), + result: getOptionalStringFlag(flags, 'result') + } + ) + printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) + }, + + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } + }>('orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + to: getRequiredStringFlag(flags, 'to'), + from, + inject: flags.has('inject') ? true : undefined, + devMode: isDevCliInvocation() + }) + printResult( + result, + json, + (r) => `Dispatched ${r.dispatch.task_id} -> ${r.dispatch.id} [${r.dispatch.status}]` + ) + }, + + 'orchestration dispatch-show': async ({ flags, client, json }) => { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task') + }) + printResult(result, json, (r) => { + if (!r.dispatch) { + return 'No dispatch context found.' + } + return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]` + }) + }, + + 'orchestration run': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await client.call<{ + runId: string + status: string + }>('orchestration.run', { + spec: getRequiredStringFlag(flags, 'spec'), + from, + pollIntervalMs: getOptionalPositiveIntegerFlag(flags, 'poll-interval-ms'), + maxConcurrent: getOptionalPositiveIntegerFlag(flags, 'max-concurrent'), + worktree: getOptionalStringFlag(flags, 'worktree') + }) + printResult(result, json, (r) => `Run ${r.runId} started (${r.status})`) + }, + + 'orchestration run-stop': async ({ client, json }) => { + const result = await client.call<{ + runId: string + stopped: boolean + }>('orchestration.runStop', {}) + printResult(result, json, (r) => `Run ${r.runId} stopped`) + }, + + 'orchestration gate-create': async ({ flags, client, json }) => { + const result = await client.call<{ + gate: { id: string; task_id: string; status: string } + }>('orchestration.gateCreate', { + task: getRequiredStringFlag(flags, 'task'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options') + }) + printResult( + result, + json, + (r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]` + ) + }, + + 'orchestration gate-resolve': async ({ flags, client, json }) => { + const result = await client.call<{ + gate: { id: string; task_id: string; status: string; resolution: string } + }>('orchestration.gateResolve', { + id: getRequiredStringFlag(flags, 'id'), + resolution: getRequiredStringFlag(flags, 'resolution') + }) + printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`) + }, + + 'orchestration gate-list': async ({ flags, client, json }) => { + const result = await client.call<{ + gates: { id: string; task_id: string; question: string; status: string }[] + count: number + }>('orchestration.gateList', { + task: getOptionalStringFlag(flags, 'task'), + status: getOptionalStringFlag(flags, 'status') + }) + printResult(result, json, (r) => { + if (r.gates.length === 0) { + return 'No gates found.' + } + return r.gates + .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) + .join('\n') + }) + }, + + 'orchestration reset': async ({ flags, client, json }) => { + const result = await client.call<{ reset: string }>('orchestration.reset', { + all: flags.has('all') ? true : undefined, + tasks: flags.has('tasks') ? true : undefined, + messages: flags.has('messages') ? true : undefined + }) + printResult(result, json, (r) => `Reset: ${r.reset}`) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 2028b9ffdb5..864d5e5267e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -39,6 +39,23 @@ Terminals: terminal focus Alias for terminal switch terminal close Close a terminal pane (or tab if last pane) +Orchestration: + orchestration send Send an inter-agent message + orchestration check Check messages for a terminal + orchestration reply Reply to a message + orchestration inbox Show all messages across recipients + orchestration task-create Create an orchestration task + orchestration task-list List orchestration tasks + orchestration task-update Update a task status + orchestration dispatch Dispatch a task to a terminal + orchestration dispatch-show Show dispatch context for a task + orchestration run Start the coordinator loop + orchestration run-stop Stop the active coordinator run + orchestration gate-create Create a decision gate blocking a task + orchestration gate-resolve Resolve a pending decision gate + orchestration gate-list List decision gates + orchestration reset Reset orchestration state + Browser Automation: tab create Create a new browser tab (navigates to --url) tab list List open browser tabs diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 579556bac4b..8783968bf24 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -55,12 +55,25 @@ describe('COMMAND_SPECS collision check', () => { }) describe('orca cli worktree awareness', () => { + const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE + const originalUserDataPath = process.env.ORCA_USER_DATA_PATH + beforeEach(() => { callMock.mockReset() }) afterEach(() => { vi.restoreAllMocks() + if (originalTerminalHandle === undefined) { + delete process.env.ORCA_TERMINAL_HANDLE + } else { + process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle + } + if (originalUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = originalUserDataPath + } }) it('builds the current worktree selector from cwd', () => { @@ -153,6 +166,66 @@ describe('orca cli worktree awareness', () => { }) }) + it('formats group orchestration sends in text mode', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_sender' + callMock.mockResolvedValueOnce({ + id: 'req_send', + ok: true, + result: { + messages: [{ id: 'msg_1' }, { id: 'msg_2' }], + recipients: 2 + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'send', '--to', '@all', '--subject', 'hello'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.send', { + from: 'term_sender', + to: '@all', + subject: 'hello', + body: undefined, + type: undefined, + priority: undefined, + threadId: undefined, + payload: undefined, + devMode: false + }) + expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') + }) + + it('passes dev mode to injected orchestration dispatches', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_sender' + process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev' + callMock.mockResolvedValueOnce({ + id: 'req_dispatch', + ok: true, + result: { + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('orchestration.dispatch', { + task: 'task_1', + to: 'term_worker', + from: 'term_sender', + inject: true, + devMode: true + }) + }) + it('uses the resolved enclosing worktree for terminal consumers', async () => { queueFixtures( callMock, diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index dfa343aed31..a29a48b6abf 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -2,9 +2,11 @@ import type { CommandSpec } from '../args' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' +import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, - ...BROWSER_ADVANCED_COMMAND_SPECS + ...BROWSER_ADVANCED_COMMAND_SPECS, + ...ORCHESTRATION_COMMAND_SPECS ] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts new file mode 100644 index 00000000000..e527fe8b953 --- /dev/null +++ b/src/cli/specs/orchestration.ts @@ -0,0 +1,119 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['orchestration', 'send'], + summary: 'Send an inter-agent message', + usage: + 'orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'to', + 'from', + 'subject', + 'body', + 'type', + 'priority', + 'thread-id', + 'payload' + ] + }, + { + path: ['orchestration', 'check'], + summary: 'Check messages for a terminal', + usage: + 'orca orchestration check [--terminal ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'unread', 'types', 'inject', 'wait', 'timeout-ms'] + }, + { + path: ['orchestration', 'reply'], + summary: 'Reply to a message', + usage: 'orca orchestration reply --id --body [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from'] + }, + { + path: ['orchestration', 'inbox'], + summary: 'Show all messages across recipients', + usage: 'orca orchestration inbox [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit'] + }, + { + path: ['orchestration', 'task-create'], + summary: 'Create an orchestration task', + usage: + 'orca orchestration task-create --spec [--deps ] [--parent ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'spec', 'deps', 'parent'] + }, + { + path: ['orchestration', 'task-list'], + summary: 'List orchestration tasks', + usage: 'orca orchestration task-list [--status ] [--ready] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready'] + }, + { + path: ['orchestration', 'task-update'], + summary: 'Update a task status', + usage: + 'orca orchestration task-update --id --status [--result ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'] + }, + { + path: ['orchestration', 'dispatch'], + summary: 'Dispatch a task to a terminal', + usage: + 'orca orchestration dispatch --task --to [--from ] [--inject] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject'] + }, + { + path: ['orchestration', 'dispatch-show'], + summary: 'Show dispatch context for a task', + usage: 'orca orchestration dispatch-show --task [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task'] + }, + { + path: ['orchestration', 'run'], + summary: 'Start the coordinator loop', + usage: + 'orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'spec', + 'from', + 'poll-interval-ms', + 'max-concurrent', + 'worktree' + ] + }, + { + path: ['orchestration', 'run-stop'], + summary: 'Stop the active coordinator run', + usage: 'orca orchestration run-stop [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['orchestration', 'gate-create'], + summary: 'Create a decision gate blocking a task', + usage: + 'orca orchestration gate-create --task --question [--options ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options'] + }, + { + path: ['orchestration', 'gate-resolve'], + summary: 'Resolve a pending decision gate', + usage: 'orca orchestration gate-resolve --id --resolution [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution'] + }, + { + path: ['orchestration', 'gate-list'], + summary: 'List decision gates', + usage: 'orca orchestration gate-list [--task ] [--status ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'status'] + }, + { + path: ['orchestration', 'reset'], + summary: 'Reset orchestration state', + usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages'] + } +] diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6c78e375389..91fdfe1b78e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -801,6 +801,57 @@ describe('registerPtyHandlers', () => { expect(sshShutdown).toHaveBeenCalledWith('remote-pty', true) }) + it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { + const spawn = vi.fn(async () => ({ id: 'remote-pty' })) + registerSshPtyProvider('ssh-1', { + spawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + acknowledgeDataEvent: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), + registerPreAllocatedHandleForPty: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-1', + env: { EXISTING: '1' } + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + EXISTING: '1', + ORCA_TERMINAL_HANDLE: 'term_remote' + }) + }) + ) + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + 'remote-pty', + 'term_remote' + ) + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined @@ -1295,7 +1346,8 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() } spawnMock.mockReturnValue(proc) @@ -1333,7 +1385,8 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + preAllocateHandleForPty: vi.fn() } spawnMock.mockReturnValue(proc) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ee334f25c74..0945d9d72b1 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -188,6 +188,13 @@ export function buildPtyHostEnv( // Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps // the behavior local to Orca instead of rewriting user git config or // touching external shells. + if (!opts.githubAttributionEnabled) { + delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION + delete baseEnv.ORCA_GIT_COMMIT_TRAILER + delete baseEnv.ORCA_GH_PR_FOOTER + delete baseEnv.ORCA_GH_ISSUE_FOOTER + delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR + } applyTerminalAttributionEnv(baseEnv, { enabled: opts.githubAttributionEnabled, userDataPath: opts.userDataPath @@ -339,13 +346,21 @@ export function registerPtyHandlers( localProvider.configure({ isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - buildSpawnEnv: (id, baseEnv) => - buildPtyHostEnv(id, baseEnv, { + buildSpawnEnv: (id, baseEnv) => { + const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false - }), + }) + // Why: agents need their own terminal handle at process start so they + // can self-identify in orchestration messages without an extra RPC. + const preAllocatedHandle = runtime?.preAllocateHandleForPty(id) + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + return env + }, onSpawned: (id) => runtime?.onPtySpawned(id), onExit: (id, code) => { clearProviderPtyState(id) @@ -380,6 +395,12 @@ export function registerPtyHandlers( pendingData.clear() } + // Why: LocalPtyProvider routes data to the runtime via configure().onData, + // but daemon-backed providers don't have configure(). Without this, daemon + // PTY data never reaches the runtime's tail buffer, so terminal.read returns + // empty and agent-detection from raw data never fires. + const isLocalProvider = localProvider instanceof LocalPtyProvider + localDataUnsub = localProvider.onData((payload) => { if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window @@ -392,6 +413,9 @@ export function registerPtyHandlers( pendingData.clear() return } + if (!isLocalProvider) { + runtime?.onPtyData(payload.id, payload.data, Date.now()) + } const existing = pendingData.get(payload.id) pendingData.set(payload.id, existing ? existing + payload.data : payload.data) if (!flushTimer) { @@ -399,6 +423,12 @@ export function registerPtyHandlers( } }) localExitUnsub = localProvider.onExit((payload) => { + if (!isLocalProvider) { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + runtime?.onPtyExit(payload.id, payload.code) + } if (!mainWindow.isDestroyed()) { // Why: flush any batched data for this PTY before sending the exit event, // otherwise the last ≤8ms of output is silently lost because the renderer @@ -456,6 +486,13 @@ export function registerPtyHandlers( markClaudePtyExited(ptyId) runtime?.onPtyExit(ptyId, -1) return true + }, + getForegroundProcess: async (ptyId) => { + try { + return await getProviderForPty(ptyId).getForegroundProcess(ptyId) + } catch { + return null + } } }) @@ -528,6 +565,10 @@ export function registerPtyHandlers( args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env let env: Record | undefined = baseEnv + const preAllocatedHandle = + runtime && !(provider instanceof LocalPtyProvider) + ? runtime.createPreAllocatedTerminalHandle() + : null if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -569,6 +610,9 @@ export function registerPtyHandlers( throw err } } + const spawnEnv = preAllocatedHandle + ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } + : env const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined @@ -576,7 +620,7 @@ export function registerPtyHandlers( cols: args.cols, rows: args.rows, cwd: args.cwd, - env + env: spawnEnv } if (envToDelete) { spawnOptions.envToDelete = envToDelete @@ -625,6 +669,9 @@ export function registerPtyHandlers( throw err } ptyOwnership.set(result.id, args.connectionId ?? null) + if (preAllocatedHandle) { + runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + } if (isClaudeLaunch) { markClaudePtySpawned(result.id) } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index afa4563054a..1308ceec379 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -109,6 +109,8 @@ vi.mock('./pty', () => ({ registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), getSshPtyProvider: vi.fn(), getPtyIdsForConnection: vi.fn().mockReturnValue([]) })) @@ -268,6 +270,43 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('forwards remote PTY events into the runtime', async () => { + const runtime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + registerSshHandlers(mockStore, () => mockWindow as never, runtime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + 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 + + onData?.({ id: 'remote-pty', data: 'hello' }) + onExit?.({ id: 'remote-pty', code: 7 }) + + expect(runtime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) + }) + 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 4aac9e51744..91bba2f0487 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -17,6 +17,7 @@ import type { import { isAuthError } from '../ssh/ssh-connection-utils' import { registerSshBrowseHandler } from './ssh-browse' import { requestCredential, registerCredentialHandler } from './ssh-passphrase' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -146,7 +147,8 @@ async function restorePortForwards( export function registerSshHandlers( store: Store, - getMainWindow: () => BrowserWindow | null + 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, @@ -297,6 +299,7 @@ export function registerSshHandlers( getMainWindow, store, portForwardManager!, + runtime, (tid, ports, _platform) => { broadcastDetectedPorts(getMainWindow, tid, ports) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e4e44b932a7..310a20c76c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees } from '../git/worktree' import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' const { @@ -78,6 +79,31 @@ afterEach(() => { invalidateAuthorizedRootsCacheMock.mockReset() }) +function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId, + paneTitle: null + } + ] + }) +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -300,7 +326,8 @@ describe('OrcaRuntimeService', () => { writes.push(data) return true }, - kill: () => true + kill: () => true, + getForegroundProcess: async () => null }) runtime.attachWindow(1) @@ -344,7 +371,7 @@ describe('OrcaRuntimeService', () => { handle: terminal.handle, accepted: true }) - expect(writes).toEqual(['continue\r']) + expect(writes).toEqual(['continue', '\r']) }) it('waits for terminal exit and resolves with the exit status', async () => { @@ -438,6 +465,136 @@ describe('OrcaRuntimeService', () => { expect(thirdRead.nextCursor).toBe('2') }) + it('delivers pending orchestration messages to an already-idle agent', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) + // Why: markAsRead is deferred until the 500ms delayed Enter is confirmed, + // so we must advance timers past the split-write delay. + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(db.getUnreadMessages(terminal.handle)).toHaveLength(0) + db.close() + vi.useRealTimers() + }) + + it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.onPtyData('pty-1', 'ready\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.handle).toBe(handle) + expect(read.tail).toEqual(['ready']) + }) + + it('keeps preallocated terminal handles valid across renderer reloads', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markRendererReloading(1) + syncSinglePty(runtime, null) + runtime.onPtyData('pty-1', 'after reload\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after reload']) + }) + + it('keeps preallocated terminal handles valid when a reload graph omits the live leaf', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markRendererReloading(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [] + }) + runtime.onPtyData('pty-1', 'after omitted leaf\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after omitted leaf']) + }) + + it('keeps preallocated terminal handles valid after graph unavailable during reload', async () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-1') + + syncSinglePty(runtime) + runtime.markGraphUnavailable(1) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [] + }) + runtime.onPtyData('pty-1', 'after unavailable\n', 100) + + const read = await runtime.readTerminal(handle) + expect(read.tail).toEqual(['after unavailable']) + }) + + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new OrchestrationDb(':memory:') + const write = vi.fn().mockReturnValue(true) + runtime.setOrchestrationDb(db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'after wait' }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + db.close() + }) + + it('resolves message waiters when notifyMessageArrived is called', async () => { + const runtime = new OrcaRuntimeService(store) + + const waitPromise = runtime.waitForMessage('term_abc', { timeoutMs: 5000 }) + runtime.notifyMessageArrived('term_abc') + await waitPromise + }) + + it('resolves message waiters on timeout when no message arrives', async () => { + const runtime = new OrcaRuntimeService(store) + + const start = Date.now() + await runtime.waitForMessage('term_abc', { timeoutMs: 100 }) + const elapsed = Date.now() - start + expect(elapsed).toBeGreaterThanOrEqual(90) + expect(elapsed).toBeLessThan(500) + }) + it('fails terminal waits closed when the handle goes stale during reload', async () => { const runtime = new OrcaRuntimeService(store) @@ -481,7 +638,7 @@ describe('OrcaRuntimeService', () => { { tabId: 'tab-1', worktreeId: 'repo-1::/tmp/worktree-a', - title: 'Claude', + title: 'Terminal 1', activeLeafId: 'pane:1', layout: null } @@ -611,7 +768,8 @@ describe('OrcaRuntimeService', () => { kill: () => { killed = true return true - } + }, + getForegroundProcess: async () => null }) runtime.attachWindow(1) @@ -691,7 +849,8 @@ describe('OrcaRuntimeService', () => { kill: () => { killed = true return true - } + }, + getForegroundProcess: async () => null }) runtime.attachWindow(1) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a9fa0024cf0..b221afcff76 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,13 +1,19 @@ /* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection' +import { + extractLastOscTitle, + detectAgentStatusFromTitle, + isShellProcess +} from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' import { gitExecFileAsync, gitExecFileSync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' import { join } from 'path' import { rm } from 'fs/promises' +import { OrchestrationDb } from './orchestration/db' +import { formatMessagesForInjection } from './orchestration/formatter' import type { CreateWorktreeResult, Repo } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import type { @@ -135,6 +141,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean + getForegroundProcess(ptyId: string): Promise } type RuntimeNotifier = { @@ -169,6 +176,14 @@ type TerminalWaiter = { resolve: (result: RuntimeTerminalWait) => void reject: (error: Error) => void timeout: NodeJS.Timeout | null + pollInterval: NodeJS.Timeout | null +} + +type MessageWaiter = { + handle: string + typeFilter: string[] | undefined + resolve: (result: void) => void + timeout: NodeJS.Timeout | null } type ResolvedWorktree = { @@ -214,6 +229,8 @@ export class OrcaRuntimeService { private leaves = new Map() private handles = new Map() private handleByLeafKey = new Map() + private handleByPtyId = new Map() + private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() private ptyController: RuntimePtyController | null = null @@ -221,6 +238,8 @@ export class OrcaRuntimeService { private agentBrowserBridge: AgentBrowserBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private agentDetector: AgentDetector | null = null + private _orchestrationDb: OrchestrationDb | null = null + private messageWaitersByHandle = new Map>() constructor(store: RuntimeStore | null = null, stats?: StatsCollector) { this.store = store @@ -229,6 +248,22 @@ export class OrcaRuntimeService { } } + // Why: lazy initialization — the DB path depends on Electron's userData + // which may not be finalized until after app.ready. Also allows unit tests + // to inject an in-memory DB without touching the filesystem. + getOrchestrationDb(): OrchestrationDb { + if (!this._orchestrationDb) { + const { app } = require('electron') + const dbPath = join(app.getPath('userData'), 'orchestration.db') + this._orchestrationDb = new OrchestrationDb(dbPath) + } + return this._orchestrationDb + } + + setOrchestrationDb(db: OrchestrationDb): void { + this._orchestrationDb = db + } + getRuntimeId(): string { return this.runtimeId } @@ -284,43 +319,77 @@ export class OrcaRuntimeService { this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const nextLeaves = new Map() + // Why: renderer reloads can briefly republish the same leaf with no ptyId; + // keep live CLI handles usable while the UI graph rebuilds. + const preserveLivePtysDuringReload = this.graphStatus === 'reloading' for (const leaf of graph.leaves) { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existing = this.leaves.get(leafKey) + const ptyId = + preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId + ? existing.ptyId + : leaf.ptyId const ptyGeneration = - existing && existing.ptyId !== leaf.ptyId + existing && existing.ptyId !== ptyId ? existing.ptyGeneration + 1 : (existing?.ptyGeneration ?? 0) nextLeaves.set(leafKey, { ...leaf, + ptyId, ptyGeneration, - connected: leaf.ptyId !== null, - writable: this.graphStatus === 'ready' && leaf.ptyId !== null, - lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, - lastExitCode: existing?.ptyId === leaf.ptyId ? existing.lastExitCode : null, - tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [], - tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '', - tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false, - tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0, - preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', - lastAgentStatus: existing?.ptyId === leaf.ptyId ? existing.lastAgentStatus : null + connected: ptyId !== null, + writable: this.graphStatus === 'ready' && ptyId !== null, + lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null, + lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null, + tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [], + tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '', + tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false, + tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, + preview: existing?.ptyId === ptyId ? existing.preview : '', + lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null }) - if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) { + if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { this.invalidateLeafHandle(leafKey) } } for (const oldLeafKey of this.leaves.keys()) { if (!nextLeaves.has(oldLeafKey)) { - this.invalidateLeafHandle(oldLeafKey) + const oldLeaf = this.leaves.get(oldLeafKey) + if ( + preserveLivePtysDuringReload && + oldLeaf?.ptyId && + this.handleByPtyId.has(oldLeaf.ptyId) + ) { + // Why: a CLI-created agent keeps using its exported handle even if + // the reloaded renderer has not rebound the pane yet. + nextLeaves.set(oldLeafKey, oldLeaf) + } else { + this.invalidateLeafHandle(oldLeafKey) + } } } + const nextPtyIds = new Set( + [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) + ) + for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { + if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { + this.detachedPreAllocatedLeaves.delete(ptyId) + continue + } + nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf) + nextPtyIds.add(ptyId) + } + this.leaves = nextLeaves this.graphStatus = 'ready' this.refreshWritableFlags() + for (const leaf of this.leaves.values()) { + this.adoptPreAllocatedHandle(leaf) + } // Why: createTerminal waits for the renderer's graph sync to populate the // new leaf so it can return a handle. Drain callbacks after leaves update. @@ -331,11 +400,39 @@ export class OrcaRuntimeService { return this.getStatus() } + // Why: terminal handles are normally created lazily when first referenced via + // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE + // env var) so they can self-identify in orchestration messages without an + // extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it. + preAllocateHandleForPty(ptyId: string): string { + const existing = this.handleByPtyId.get(ptyId) + if (existing) { + return existing + } + const handle = this.createPreAllocatedTerminalHandle() + this.handleByPtyId.set(ptyId, handle) + return handle + } + + createPreAllocatedTerminalHandle(): string { + return `term_${randomUUID()}` + } + + registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { + this.handleByPtyId.set(ptyId, handle) + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === ptyId) { + this.adoptPreAllocatedHandle(leaf) + } + } + } + onPtySpawned(ptyId: string): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId === ptyId) { leaf.connected = true leaf.writable = this.graphStatus === 'ready' + this.adoptPreAllocatedHandle(leaf) } } } @@ -369,12 +466,15 @@ export class OrcaRuntimeService { if (agentStatus !== null) { const prevStatus = leaf.lastAgentStatus leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle waiters only on working→idle, not working→permission. - // Permission means the agent is blocked on user approval (e.g. Gemini's - // "y/n" prompt) — it hasn't finished its task. Resolving tui-idle here - // would cause the CLI consumer to proceed while the agent is still waiting. - if (prevStatus === 'working' && agentStatus === 'idle') { + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) } } } @@ -387,10 +487,54 @@ export class OrcaRuntimeService { if (leaf.ptyId !== ptyId) { continue } + this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode this.resolveExitWaiters(leaf) + this.failActiveDispatchOnExit(leaf, exitCode) + } + } + + // Why: Section 7.2 — the runtime detects agent exit directly and updates + // dispatch contexts immediately, rather than waiting for the coordinator's + // next poll cycle. This catches agent crashes and unexpected exits within + // milliseconds. The task is set back to 'pending' so it can be re-dispatched. + private failActiveDispatchOnExit(leaf: RuntimeLeafRecord, exitCode: number): void { + if (!this._orchestrationDb) { + return + } + + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + + const dispatch = this._orchestrationDb.getActiveDispatchForTerminal(handle) + if (!dispatch) { + return + } + + const errorContext = `Agent exited with code ${exitCode}` + this._orchestrationDb.failDispatch(dispatch.id, errorContext) + + // Why: create an escalation message so the coordinator is notified about + // the unexpected exit on its next check cycle, even if the circuit breaker + // hasn't tripped yet. + const run = this._orchestrationDb.getActiveCoordinatorRun() + if (run) { + this._orchestrationDb.insertMessage({ + from: handle, + to: run.coordinator_handle, + subject: `Agent exited unexpectedly (code ${exitCode})`, + type: 'escalation', + priority: 'high', + payload: JSON.stringify({ + taskId: dispatch.task_id, + exitCode, + handle + }) + }) } } @@ -527,10 +671,33 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } - const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') + + // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste + // event. If \r is included in the same write as multi-line text, the TUI + // interprets it as part of the paste rather than a discrete Enter keypress. + // Splitting the text and the trailing control characters into separate + // writes with a small delay ensures the TUI processes the paste first, + // then receives Enter as a distinct input event. + const hasText = typeof action.text === 'string' && action.text.length > 0 + const hasSuffix = action.enter || action.interrupt + if (hasText && hasSuffix) { + const textWrote = this.ptyController?.write(leaf.ptyId, action.text!) ?? false + if (!textWrote) { + throw new Error('terminal_not_writable') + } + const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + await new Promise((resolve) => setTimeout(resolve, 500)) + const suffixWrote = this.ptyController?.write(leaf.ptyId, suffix) ?? false + if (!suffixWrote) { + throw new Error('terminal_not_writable') + } + } else { + const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } } + return { handle, accepted: true, @@ -558,10 +725,6 @@ export class OrcaRuntimeService { // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the // agent is blocked on user approval, not finished with its task. if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { - // Why: reset so the next `wait --for tui-idle` blocks until a fresh - // working→idle transition instead of resolving instantly from a stale - // status left over from a previous agent session. - leaf.lastAgentStatus = null return buildTerminalWaitResult(handle, condition, leaf) } @@ -581,7 +744,8 @@ export class OrcaRuntimeService { condition, resolve, reject, - timeout: null + timeout: null, + pollInterval: null } if (effectiveTimeoutMs > 0) { @@ -606,8 +770,21 @@ export class OrcaRuntimeService { if (getTerminalState(live.leaf) === 'exited') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') { - live.leaf.lastAgentStatus = null + // Why: don't clear lastAgentStatus here. It's a factual record of the + // last detected OSC state, not a one-shot signal. Clearing it causes + // subsequent tui-idle waiters to hang even though the agent is idle — + // the first waiter consumes the status and all later ones see null. this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === null) { + // Why: for daemon-hosted terminals, lastAgentStatus stays null because + // PTY data doesn't flow through onPtyData. Check the renderer-synced + // title as a fast path before falling back to polling. + const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title + if (fastPathTitle && detectAgentStatusFromTitle(fastPathTitle) === 'idle') { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else { + this.startTuiIdleFallbackPoll(waiter, live.leaf) + } } } catch (error) { this.removeWaiter(waiter) @@ -1218,6 +1395,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() this.rejectAllWaiters('terminal_handle_stale') @@ -1243,6 +1421,7 @@ export class OrcaRuntimeService { } this.graphStatus = 'unavailable' this.authoritativeWindowId = null + this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() this.leaves.clear() this.handles.clear() @@ -1409,6 +1588,123 @@ export class OrcaRuntimeService { } } + // Why: group address resolution (Section 4.5) needs to query per-handle agent + // status without throwing on stale handles, so this returns null on any error. + getAgentStatusForHandle(handle: string): string | null { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + return leaf.lastAgentStatus + } catch { + return null + } + } + + // Why: OSC title detection via onPtyData is the tightest signal for agent + // presence, but the runtime may not see PTY data for daemon-hosted terminals + // (the daemon adapter stubs getForegroundProcess). This checks three signals + // in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer- + // synced tab title (which reflects OSC titles from the xterm instance), and + // (3) the PTY foreground process. Returns true if any signal indicates a + // non-shell agent is running. + async isTerminalRunningAgent(handle: string): Promise { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.lastAgentStatus !== null) { + return true + } + // Why: check both the leaf-level pane title (synced from the renderer's + // runtimePaneTitlesByTabId) and the tab-level title. The tab title already + // includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the + // renderer's xterm instance. + const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) { + return true + } + if (!leaf.ptyId || !this.ptyController) { + return false + } + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + if (!fg) { + return false + } + return !isShellProcess(fg) + } catch { + return false + } + } + + deliverPendingMessagesForHandle(handle: string): void { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + if (leaf.lastAgentStatus === 'idle') { + this.deliverPendingMessages(leaf) + } + } catch { + // Unknown or stale handles cannot be pushed immediately; the persisted + // message remains available via explicit check or future idle delivery. + } + } + + // Why: after a message is inserted for a recipient, any blocking + // orchestration.check --wait calls watching that handle must be woken + // so they can return the new message immediately instead of polling. + notifyMessageArrived(handle: string): void { + const waiters = this.messageWaitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + this.resolveMessageWaiter(waiter) + } + } + + waitForMessage( + handle: string, + options?: { typeFilter?: string[]; timeoutMs?: number } + ): Promise { + return new Promise((resolve) => { + const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS + + const waiter: MessageWaiter = { + handle, + typeFilter: options?.typeFilter, + resolve, + timeout: null + } + + waiter.timeout = setTimeout(() => { + this.removeMessageWaiter(waiter) + resolve() + }, timeoutMs) + + let waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.messageWaitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + }) + } + + private resolveMessageWaiter(waiter: MessageWaiter): void { + this.removeMessageWaiter(waiter) + waiter.resolve() + } + + private removeMessageWaiter(waiter: MessageWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + waiter.timeout = null + } + const waiters = this.messageWaitersByHandle.get(waiter.handle) + if (waiters) { + waiters.delete(waiter) + if (waiters.size === 0) { + this.messageWaitersByHandle.delete(waiter.handle) + } + } + } + private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord @@ -1444,7 +1740,10 @@ export class OrcaRuntimeService { } } - const handle = `term_${randomUUID()}` + const handle = this.adoptPreAllocatedHandle(leaf) ?? `term_${randomUUID()}` + if (this.handles.has(handle)) { + return handle + } this.handles.set(handle, { handle, runtimeId: this.runtimeId, @@ -1459,6 +1758,29 @@ export class OrcaRuntimeService { return handle } + private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null { + if (!leaf.ptyId) { + return null + } + const preAllocated = this.handleByPtyId.get(leaf.ptyId) + if (!preAllocated) { + return null + } + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + this.handles.set(preAllocated, { + handle: preAllocated, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + ptyId: leaf.ptyId, + ptyGeneration: leaf.ptyGeneration + }) + this.handleByLeafKey.set(leafKey, preAllocated) + return preAllocated + } + private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -1475,8 +1797,18 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private rememberDetachedPreAllocatedLeaves(): void { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { + // Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control should + // survive renderer graph loss as long as the underlying PTY is alive. + this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf) + } + } + } + private resolveExitWaiters(leaf: RuntimeLeafRecord): void { - const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + const handle = this.issueHandle(leaf) if (!handle) { return } @@ -1513,6 +1845,109 @@ export class OrcaRuntimeService { } } + // Why: OSC title detection via onPtyData is the primary signal for tui-idle, + // but daemon-hosted terminals don't flow PTY data through the runtime, and + // some agents don't emit recognized titles on startup. This fallback polls + // two signals: (1) the renderer-synced tab title (reflects xterm's OSC title + // handler, works even for daemon terminals), and (2) the PTY foreground process + // + output quiescence. The poll self-cancels when the primary OSC path fires. + private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void { + waiter.pollInterval = setInterval(async () => { + try { + // If OSC detection via onPtyData kicked in, stop — the primary path + // will handle (or has already handled) resolution. + if (leaf.lastAgentStatus !== null) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + return + } + // Why: check the renderer-synced title. For daemon-hosted terminals, + // this is the only path where OSC titles are visible to the runtime. + const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (pollTitle) { + const titleStatus = detectAgentStatusFromTitle(pollTitle) + if (titleStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } + } + // Foreground process fallback: if the daemon/local provider can report + // the process and it's a non-shell with quiet output, treat as idle. + if (leaf.ptyId && this.ptyController) { + const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) + if (fg && !isShellProcess(fg)) { + const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 + if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + } + } + } + } catch { + // Swallow transient PTY inspection errors and keep polling. + } + }, TUI_IDLE_POLL_INTERVAL_MS) + } + + // Why: push-on-idle delivery — when an agent transitions working→idle, check + // for unread orchestration messages addressed to that terminal and inject them + // into the PTY. This is event-driven (no polling) because the runtime owns + // both the message store and terminal status detection. + private deliverPendingMessages(leaf: RuntimeLeafRecord): void { + if (!this._orchestrationDb) { + return + } + + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (!handle) { + return + } + + const unread = this._orchestrationDb.getUnreadMessages(handle) + if (unread.length === 0) { + return + } + + if (!leaf.writable || !leaf.ptyId) { + return + } + + const payload = formatMessagesForInjection(unread) + const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false + if (!wrote) { + return + } + + // Why: Claude Code treats large single PTY writes as paste events and + // swallows a \r included in the same write. Send Enter separately after + // a delay so the agent processes the pasted message first. Mark messages + // as read only after \r is confirmed, so failed deliveries stay queued. + const ptyId = leaf.ptyId + setTimeout(() => { + try { + if (!leaf.writable) { + return + } + const submitted = this.ptyController?.write(ptyId, '\r') ?? false + if (submitted) { + this._orchestrationDb?.markAsRead(unread.map((m) => m.id)) + } + } catch { + // Terminal may have closed during the delay — messages stay unread + // and will be re-delivered on the next idle transition. + } + }, 500) + } + private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { this.removeWaiter(waiter) waiter.resolve(result) @@ -1539,6 +1974,9 @@ export class OrcaRuntimeService { if (waiter.timeout) { clearTimeout(waiter.timeout) } + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + } const waiters = this.waitersByHandle.get(waiter.handle) if (!waiters) { return @@ -2715,6 +3153,9 @@ function buildSendPayload(action: { // will ever fire. A 5-minute ceiling prevents indefinite hangs while still // giving real agent tasks plenty of time to complete. const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 +const TUI_IDLE_POLL_INTERVAL_MS = 2000 +const TUI_IDLE_QUIESCENCE_MS = 3000 +const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 function buildTerminalWaitResult( handle: string, diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts new file mode 100644 index 00000000000..ac0ebbb4974 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -0,0 +1,379 @@ +/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import { Coordinator, type CoordinatorRuntime } from './coordinator' + +function createMockRuntime(): CoordinatorRuntime & { + sentMessages: { handle: string; text: string }[] + terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] + createdTerminals: string[] +} { + const mock = { + sentMessages: [] as { handle: string; text: string }[], + terminals: [] as { + handle: string + worktreeId: string + connected: boolean + writable: boolean + }[], + createdTerminals: [] as string[], + async sendTerminal(handle: string, action: { text?: string }) { + mock.sentMessages.push({ handle, text: action.text ?? '' }) + return { handle, accepted: true, bytesWritten: 0 } + }, + async listTerminals() { + return { terminals: mock.terminals } + }, + async createTerminal(_worktree?: string, opts?: { title?: string }) { + const handle = `term_worker_${mock.createdTerminals.length}` + mock.createdTerminals.push(handle) + mock.terminals.push({ handle, worktreeId: 'wt1', connected: true, writable: true }) + return { handle, worktreeId: 'wt1', title: opts?.title ?? '' } + }, + async waitForTerminal(handle: string) { + return { handle, condition: 'exit' } + } + } + return mock +} + +describe('Coordinator', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('throws if no tasks exist', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + const coordinator = new Coordinator(db, runtime, { + spec: 'do stuff', + coordinatorHandle: 'coord' + }) + await expect(coordinator.run()).rejects.toThrow('No tasks found') + }) + + it('dispatches a ready task to an available terminal', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'implement feature' }) + + // Simulate worker_done arriving after dispatch + const coordinator = new Coordinator(db, runtime, { + spec: 'build it', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + // Run coordinator in background, then simulate completion + const runPromise = coordinator.run() + + // Wait for dispatch to happen + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Simulate the worker completing + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, filesModified: ['a.ts'] }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(task.id) + expect(runtime.sentMessages.length).toBeGreaterThan(0) + }) + + it('creates a terminal when none are available', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + + const task = db.createTask({ spec: 'work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + + expect(runtime.createdTerminals.length).toBe(1) + + // Complete the task + db.insertMessage({ + from: runtime.createdTerminals[0], + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + }) + + it('handles escalation and circuit breaker', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [ + { handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true } + ] + + const task = db.createTask({ spec: 'risky work' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Send 3 escalations to trigger circuit breaker + for (let i = 0; i < 3; i++) { + await new Promise((r) => { + setTimeout(r, 100) + }) + db.insertMessage({ + from: `term_${i === 0 ? 'a' : 'b'}`, + to: 'coord', + subject: `Failed attempt ${i + 1}`, + type: 'escalation', + payload: JSON.stringify({ taskId: task.id }) + }) + } + + const result = await runPromise + expect(result.status).toBe('failed') + expect(result.failedTasks).toContain(task.id) + }) + + it('reports failed when dispatch send failures circuit-break in the DB', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + runtime.sendTerminal = async () => { + throw new Error('terminal_not_writable') + } + + const task = db.createTask({ spec: 'cannot dispatch' }) + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 10 + }) + + const result = await coordinator.run() + + expect(result.status).toBe('failed') + expect(result.failedTasks).toContain(task.id) + expect(db.getTask(task.id)?.status).toBe('failed') + }) + + it('handles decision gate blocking and resolution', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const task = db.createTask({ spec: 'needs approval' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Wait for dispatch + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Worker sends decision gate + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Need approval', + type: 'decision_gate', + payload: JSON.stringify({ + taskId: task.id, + question: 'Proceed with destructive migration?', + options: ['yes', 'no'] + }) + }) + + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Verify task is blocked + const blocked = db.getTask(task.id) + expect(blocked?.status).toBe('blocked') + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + + // Resolve the gate + const gates = db.listGates({ taskId: task.id, status: 'pending' }) + expect(gates.length).toBe(1) + db.resolveGate(gates[0].id, 'yes') + + // Wait for re-dispatch and simulate completion + await new Promise((r) => { + setTimeout(r, 200) + }) + + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(task.id) + }) + + it('respects task DAG ordering', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + + const t1 = db.createTask({ spec: 'first' }) + const t2 = db.createTask({ spec: 'second', deps: [t1.id] }) + + expect(t2.status).toBe('pending') + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + // Wait for t1 dispatch + await new Promise((r) => { + setTimeout(r, 100) + }) + + // t2 should still be pending + expect(db.getTask(t2.id)?.status).toBe('pending') + + // Complete t1 + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: t1.id }) + }) + + // Wait for t2 to be promoted and dispatched + await new Promise((r) => { + setTimeout(r, 200) + }) + + // t2 should now be dispatched + const t2Status = db.getTask(t2.id)?.status + expect(t2Status === 'dispatched' || t2Status === 'ready').toBe(true) + + // Complete t2 + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: t2.id }) + }) + + const result = await runPromise + expect(result.status).toBe('completed') + expect(result.completedTasks).toContain(t1.id) + expect(result.completedTasks).toContain(t2.id) + }) + + it('respects maxConcurrent limit', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [ + { handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true }, + { handle: 'term_c', worktreeId: 'wt1', connected: true, writable: true } + ] + + const t1 = db.createTask({ spec: 'one' }) + const t2 = db.createTask({ spec: 'two' }) + const t3 = db.createTask({ spec: 'three' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50, + maxConcurrent: 2 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + + // Only 2 should be dispatched + const dispatched = db.listTasks({ status: 'dispatched' }) + expect(dispatched.length).toBe(2) + + // Complete all tasks + for (const task of [t1, t2, t3]) { + db.insertMessage({ + from: 'term_a', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id }) + }) + await new Promise((r) => { + setTimeout(r, 100) + }) + } + + const result = await runPromise + expect(result.status).toBe('completed') + }) + + it('can be stopped', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + db.createTask({ spec: 'never finishes' }) + + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + + const runPromise = coordinator.run() + + await new Promise((r) => { + setTimeout(r, 100) + }) + coordinator.stop() + + const result = await runPromise + expect(result.status).toBe('failed') + }) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts new file mode 100644 index 00000000000..541102f4ae5 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.ts @@ -0,0 +1,491 @@ +/* eslint-disable max-lines -- Why: the coordinator keeps message processing, task dispatch, gate handling, escalation, and convergence checking in one class so the polling loop can make atomic decisions across all these concerns without split-brain behavior. */ +import type { OrchestrationDb } from './db' +import type { MessageRow, TaskRow, CoordinatorStatus } from './types' +import { buildDispatchPreamble } from './preamble' + +export type CoordinatorRuntime = { + sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise + listTerminals( + worktreeSelector?: string, + limit?: number + ): Promise<{ + terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[] + }> + createTerminal( + worktreeSelector?: string, + opts?: { command?: string; title?: string } + ): Promise<{ handle: string; worktreeId: string }> + waitForTerminal( + handle: string, + options?: { condition?: string; timeoutMs?: number } + ): Promise<{ handle: string; condition: string }> +} + +export type CoordinatorOptions = { + spec: string + coordinatorHandle: string + pollIntervalMs?: number + maxConcurrent?: number + worktree?: string + onLog?: (msg: string) => void +} + +type CoordinatorState = { + runId: string + phase: 'decomposing' | 'dispatching' | 'monitoring' | 'merging' | 'done' + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] +} + +const DEFAULT_POLL_MS = 2000 +const MAX_CONCURRENT_DEFAULT = 4 + +export class Coordinator { + private db: OrchestrationDb + private runtime: CoordinatorRuntime + private state: CoordinatorState + private stopped = false + private opts: Required> & { + onLog: (msg: string) => void + worktree?: string + } + + constructor(db: OrchestrationDb, runtime: CoordinatorRuntime, options: CoordinatorOptions) { + this.db = db + this.runtime = runtime + this.opts = { + spec: options.spec, + coordinatorHandle: options.coordinatorHandle, + pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_MS, + maxConcurrent: options.maxConcurrent ?? MAX_CONCURRENT_DEFAULT, + worktree: options.worktree, + onLog: options.onLog ?? (() => {}) + } + this.state = { + runId: '', + phase: 'decomposing', + completedTasks: [], + failedTasks: [], + escalations: [] + } + } + + async run(): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + const run = this.db.createCoordinatorRun({ + spec: this.opts.spec, + coordinatorHandle: this.opts.coordinatorHandle, + pollIntervalMs: this.opts.pollIntervalMs + }) + return this.executeLoop(run.id) + } + + // Why: the RPC handler creates the coordinator_runs record itself so it can + // return the run ID immediately, then starts the loop in the background. + // This method skips the DB insert and uses the pre-created run ID. + async runFromExistingRun(runId: string): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + return this.executeLoop(runId) + } + + private async executeLoop(runId: string): Promise<{ + runId: string + status: CoordinatorStatus + completedTasks: string[] + failedTasks: string[] + escalations: MessageRow[] + }> { + this.state.runId = runId + this.opts.onLog(`Coordinator run ${runId} started`) + + try { + await this.decompose() + + while (!this.stopped) { + const converged = await this.tick() + if (converged) { + break + } + await this.sleep(this.opts.pollIntervalMs) + } + + // Why: if stopped early, treat it as failed since tasks are incomplete. + // Also failed if any task explicitly failed. + const tasks = this.db.listTasks() + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + const failedTasks = [ + ...new Set([ + ...this.state.failedTasks, + ...tasks.filter((task) => task.status === 'failed').map((task) => task.id) + ]) + ] + const finalStatus = + this.stopped || failedTasks.length > 0 || !allDone ? 'failed' : 'completed' + this.db.updateCoordinatorRun(runId, finalStatus) + this.opts.onLog(`Coordinator run ${runId} ${finalStatus}`) + + return { + runId, + status: finalStatus, + completedTasks: this.state.completedTasks, + failedTasks, + escalations: this.state.escalations + } + } catch (err) { + this.db.updateCoordinatorRun(runId, 'failed') + throw err + } + } + + stop(): void { + this.stopped = true + } + + // Why: the coordinator decomposes the top-level spec into a task DAG. + // For now, tasks must be pre-created before calling run(). The spec is + // stored for context but decomposition is the caller's responsibility — + // AI-driven decomposition belongs in a future phase where the coordinator + // itself is an LLM agent. + private async decompose(): Promise { + this.state.phase = 'decomposing' + const existing = this.db.listTasks() + if (existing.length === 0) { + throw new Error( + 'No tasks found. Create tasks with orchestration.taskCreate before running the coordinator.' + ) + } + this.opts.onLog(`Found ${existing.length} tasks in DAG`) + this.state.phase = 'dispatching' + } + + private async tick(): Promise { + this.processMessages() + this.processEscalations() + this.processDecisionGates() + await this.dispatchReadyTasks() + return this.checkConvergence() + } + + private processMessages(): void { + const messages = this.db.getUnreadMessages(this.opts.coordinatorHandle) + if (messages.length === 0) { + return + } + + for (const msg of messages) { + switch (msg.type) { + case 'worker_done': + this.handleWorkerDone(msg) + break + case 'escalation': + this.handleEscalation(msg) + break + case 'decision_gate': + this.handleDecisionGateMessage(msg) + break + case 'status': + this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`) + break + default: + break + } + } + + this.db.markAsRead(messages.map((m) => m.id)) + } + + private handleWorkerDone(msg: MessageRow): void { + this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) + + let payload: { taskId?: string; filesModified?: string[] } = {} + if (msg.payload) { + try { + payload = JSON.parse(msg.payload) + } catch { + this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) + } + } + + const taskId = payload.taskId + if (!taskId) { + this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) + return + } + + const task = this.db.getTask(taskId) + if (!task) { + this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`) + return + } + + const result = JSON.stringify({ + completedBy: msg.from_handle, + filesModified: payload.filesModified ?? [], + completedAt: new Date().toISOString() + }) + this.db.updateTaskStatus(taskId, 'completed', result) + this.state.completedTasks.push(taskId) + + // Why: complete the dispatch context so the terminal is freed for + // subsequent task assignments. + const dispatch = this.db.getDispatchContext(taskId) + if (dispatch) { + this.db.completeDispatch(dispatch.id) + } + + this.opts.onLog(`Task ${taskId} completed`) + } + + private handleEscalation(msg: MessageRow): void { + this.opts.onLog(`Escalation from ${msg.from_handle}: ${msg.subject}`) + this.state.escalations.push(msg) + + let taskId: string | undefined + if (msg.payload) { + try { + const payload = JSON.parse(msg.payload) + taskId = payload.taskId + } catch { + // Escalation without structured payload — log subject as context + } + } + + if (!taskId) { + return + } + + const task = this.db.getTask(taskId) + if (!task || task.status === 'completed' || task.status === 'failed') { + return + } + + const dispatch = this.db.getDispatchContext(taskId) + if (!dispatch) { + return + } + + // Why: fail the dispatch so the circuit breaker increments. If under + // the threshold, the task returns to 'pending' and will be re-dispatched + // to a (potentially different) terminal on the next tick. + const updated = this.db.failDispatch(dispatch.id, msg.subject) + if (updated?.status === 'circuit_broken') { + this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`) + this.db.updateTaskStatus(taskId, 'failed', `Circuit broken: ${msg.subject}`) + this.state.failedTasks.push(taskId) + } else { + this.opts.onLog(`Task ${taskId} will be retried (failure ${updated?.failure_count ?? 0}/3)`) + } + } + + private handleDecisionGateMessage(msg: MessageRow): void { + this.opts.onLog(`Decision gate from ${msg.from_handle}: ${msg.subject}`) + + let payload: { taskId?: string; question?: string; options?: string[] } = {} + if (msg.payload) { + try { + payload = JSON.parse(msg.payload) + } catch { + return + } + } + + if (!payload.taskId || !payload.question) { + this.opts.onLog(`Warning: decision_gate missing taskId or question`) + return + } + + this.db.createGate({ + taskId: payload.taskId, + question: payload.question, + options: payload.options + }) + + this.opts.onLog(`Task ${payload.taskId} blocked on decision gate`) + } + + private processEscalations(): void { + // Why: escalation processing is handled inline in processMessages via + // handleEscalation. This method exists as a hook for future escalation + // policies (e.g., auto-reassign after N minutes, notify external systems). + } + + private processDecisionGates(): void { + // Why: pending gates that haven't been resolved externally are surfaced + // here. In production, the coordinator UI or a human operator resolves + // gates via orchestration.gateResolve. The coordinator does not auto- + // resolve gates — that would defeat their purpose as approval checkpoints. + const pendingGates = this.db.listGates({ status: 'pending' }) + for (const gate of pendingGates) { + const task = this.db.getTask(gate.task_id) + if (task && task.status !== 'blocked') { + // Why: gate exists but task isn't blocked — inconsistent state. + // Re-block the task to maintain the invariant. + this.db.updateTaskStatus(gate.task_id, 'blocked') + } + } + } + + private async dispatchReadyTasks(): Promise { + this.state.phase = 'dispatching' + const readyTasks = this.db.listTasks({ ready: true }) + if (readyTasks.length === 0) { + return + } + + // Why: count currently dispatched tasks to enforce concurrency limit. + const dispatched = this.db.listTasks({ status: 'dispatched' }) + let slotsAvailable = this.opts.maxConcurrent - dispatched.length + if (slotsAvailable <= 0) { + return + } + + const terminals = await this.getAvailableTerminals() + if (terminals.length === 0 && slotsAvailable > 0) { + // Why: no idle terminals exist — create one for the next task. + // Only create one per tick to avoid spawning many terminals at once. + try { + const created = await this.runtime.createTerminal(this.opts.worktree, { + title: `Worker: ${readyTasks[0].spec.slice(0, 40)}` + }) + terminals.push(created.handle) + this.opts.onLog(`Created worker terminal ${created.handle}`) + } catch (err) { + this.opts.onLog(`Failed to create terminal: ${err}`) + return + } + } + + for (const task of readyTasks) { + if (slotsAvailable <= 0 || terminals.length === 0) { + break + } + + const targetHandle = terminals.shift()! + slotsAvailable-- + + try { + await this.dispatchTask(task, targetHandle) + } catch (err) { + this.opts.onLog(`Failed to dispatch task ${task.id}: ${err}`) + } + } + } + + private async dispatchTask(task: TaskRow, targetHandle: string): Promise { + const dispatch = this.db.createDispatchContext(task.id, targetHandle) + + // Why: agents dispatched by the coordinator must use orca-dev in dev mode + // so they talk to the dev runtime's socket, not production (Section 6.4). + const preamble = buildDispatchPreamble({ + taskId: task.id, + taskSpec: task.spec, + coordinatorHandle: this.opts.coordinatorHandle, + devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') + }) + + // Why: check if the task was previously blocked by a decision gate that + // has since been resolved. Include the resolution in the preamble so the + // worker knows the decision outcome. + const gates = this.db.listGates({ taskId: task.id, status: 'resolved' }) + let gateContext = '' + if (gates.length > 0) { + const latest = gates.at(-1)! + gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` + } + + try { + await this.runtime.sendTerminal(targetHandle, { + text: preamble + gateContext, + enter: true + }) + } catch (err) { + const updated = this.db.failDispatch( + dispatch.id, + err instanceof Error ? err.message : String(err) + ) + if (updated?.status === 'circuit_broken') { + this.state.failedTasks.push(task.id) + } + throw err + } + + this.opts.onLog(`Dispatched task ${task.id} to ${targetHandle}`) + this.state.phase = 'monitoring' + } + + private async getAvailableTerminals(): Promise { + try { + const result = await this.runtime.listTerminals(this.opts.worktree) + const dispatched = this.db.listTasks({ status: 'dispatched' }) + const busyHandles = new Set() + + for (const task of dispatched) { + const ctx = this.db.getDispatchContext(task.id) + if (ctx?.assignee_handle) { + busyHandles.add(ctx.assignee_handle) + } + } + + // Why: exclude the coordinator's own terminal, terminals with active + // dispatches, and disconnected terminals. The dispatch-lock in + // createDispatchContext prevents double-dispatch even if a terminal + // looks available here — this filter is an optimization, not a + // correctness constraint. + return result.terminals + .filter( + (t) => + t.handle !== this.opts.coordinatorHandle && + !busyHandles.has(t.handle) && + t.connected && + t.writable + ) + .map((t) => t.handle) + } catch { + return [] + } + } + + private checkConvergence(): boolean { + const tasks = this.db.listTasks() + if (tasks.length === 0) { + return true + } + + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + if (allDone) { + this.state.phase = 'done' + return true + } + + // Why: detect stuck state — no ready or dispatched tasks, but some are + // still pending/blocked. This means deps can never be satisfied. + const active = tasks.filter( + (t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending' + ) + const blocked = tasks.filter((t) => t.status === 'blocked') + if (active.length === 0 && blocked.length > 0) { + this.opts.onLog( + `Stuck: ${blocked.length} tasks blocked with no active tasks. Resolve decision gates to continue.` + ) + } + + return false + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) + } +} diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts new file mode 100644 index 00000000000..d412255fdae --- /dev/null +++ b/src/main/runtime/orchestration/db.test.ts @@ -0,0 +1,457 @@ +/* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import type { MessageType } from './db' + +describe('OrchestrationDb', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + describe('messages', () => { + it('inserts and retrieves a message', () => { + const d = createDb() + const msg = d.insertMessage({ + from: 'term_a', + to: 'term_b', + subject: 'hello', + body: 'world' + }) + expect(msg.id).toMatch(/^msg_/) + expect(msg.from_handle).toBe('term_a') + expect(msg.to_handle).toBe('term_b') + expect(msg.subject).toBe('hello') + expect(msg.body).toBe('world') + expect(msg.type).toBe('status') + expect(msg.priority).toBe('normal') + expect(msg.read).toBe(0) + expect(msg.sequence).toBeGreaterThan(0) + }) + + it('returns unread messages in sequence order', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'first' }) + d.insertMessage({ from: 'a', to: 'b', subject: 'second' }) + d.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const unread = d.getUnreadMessages('b') + expect(unread).toHaveLength(2) + expect(unread[0].subject).toBe('first') + expect(unread[1].subject).toBe('second') + }) + + it('filters unread by type', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'status msg', type: 'status' }) + d.insertMessage({ from: 'a', to: 'b', subject: 'done msg', type: 'worker_done' }) + + const filtered = d.getUnreadMessages('b', ['worker_done']) + expect(filtered).toHaveLength(1) + expect(filtered[0].type).toBe('worker_done') + }) + + it('marks messages as read', () => { + const d = createDb() + const m1 = d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + const m2 = d.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + + d.markAsRead([m1.id]) + + const unread = d.getUnreadMessages('b') + expect(unread).toHaveLength(1) + expect(unread[0].id).toBe(m2.id) + }) + + it('stores typed payload and thread_id', () => { + const d = createDb() + const payload = JSON.stringify({ taskId: 'task_abc', filesModified: ['src/a.ts'] }) + const msg = d.insertMessage({ + from: 'a', + to: 'b', + subject: 'done', + type: 'worker_done', + priority: 'high', + threadId: 'thread_1', + payload + }) + + expect(msg.type).toBe('worker_done') + expect(msg.priority).toBe('high') + expect(msg.thread_id).toBe('thread_1') + expect(msg.payload).toBe(payload) + }) + + it('rejects invalid message type', () => { + const d = createDb() + expect(() => + d.insertMessage({ + from: 'a', + to: 'b', + subject: 'bad', + type: 'invalid' as MessageType + }) + ).toThrow() + }) + + it('getInbox returns all messages across recipients', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + d.insertMessage({ from: 'a', to: 'c', subject: 'two' }) + d.insertMessage({ from: 'b', to: 'a', subject: 'three' }) + + const inbox = d.getInbox(10) + expect(inbox).toHaveLength(3) + }) + + it('getMessageById returns the correct message', () => { + const d = createDb() + const msg = d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + const found = d.getMessageById(msg.id) + expect(found?.subject).toBe('test') + expect(d.getMessageById('msg_nonexistent')).toBeUndefined() + }) + }) + + describe('tasks', () => { + it('creates a task with no deps as ready', () => { + const d = createDb() + const task = d.createTask({ spec: 'do something' }) + expect(task.id).toMatch(/^task_/) + expect(task.status).toBe('ready') + expect(task.deps).toBe('[]') + }) + + it('creates a task with deps as pending', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', deps: [parent.id] }) + expect(child.status).toBe('pending') + expect(JSON.parse(child.deps)).toEqual([parent.id]) + }) + + it('promotes pending tasks when deps complete', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second', deps: [t1.id] }) + + expect(d.getTask(t2.id)?.status).toBe('pending') + + d.updateTaskStatus(t1.id, 'completed') + + expect(d.getTask(t2.id)?.status).toBe('ready') + }) + + it('does not promote task until ALL deps complete', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'a' }) + const t2 = d.createTask({ spec: 'b' }) + const t3 = d.createTask({ spec: 'c', deps: [t1.id, t2.id] }) + + d.updateTaskStatus(t1.id, 'completed') + expect(d.getTask(t3.id)?.status).toBe('pending') + + d.updateTaskStatus(t2.id, 'completed') + expect(d.getTask(t3.id)?.status).toBe('ready') + }) + + it('sets completed_at on completion', () => { + const d = createDb() + const task = d.createTask({ spec: 'do it' }) + const updated = d.updateTaskStatus(task.id, 'completed', '{"result": true}') + expect(updated?.completed_at).toBeTruthy() + expect(updated?.result).toBe('{"result": true}') + }) + + it('completing a task frees its active dispatch context', () => { + const d = createDb() + const task = d.createTask({ spec: 'do it' }) + d.createDispatchContext(task.id, 'term_a') + + d.updateTaskStatus(task.id, 'completed') + + expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined() + expect(d.getDispatchContext(task.id)?.status).toBe('completed') + }) + + it('listTasks filters by status', () => { + const d = createDb() + d.createTask({ spec: 'ready task' }) + const t2 = d.createTask({ spec: 'another' }) + d.updateTaskStatus(t2.id, 'completed') + + expect(d.listTasks({ status: 'ready' })).toHaveLength(1) + expect(d.listTasks({ status: 'completed' })).toHaveLength(1) + expect(d.listTasks({ ready: true })).toHaveLength(1) + }) + + it('listTasks returns all when no filter', () => { + const d = createDb() + d.createTask({ spec: 'one' }) + d.createTask({ spec: 'two' }) + expect(d.listTasks()).toHaveLength(2) + }) + + it('supports parent_id for task decomposition', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', parentId: parent.id }) + expect(child.parent_id).toBe(parent.id) + }) + }) + + describe('dispatch contexts', () => { + it('creates a dispatch context and marks task as dispatched', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_worker') + + expect(ctx.id).toMatch(/^ctx_/) + expect(ctx.task_id).toBe(task.id) + expect(ctx.assignee_handle).toBe('term_worker') + expect(ctx.status).toBe('dispatched') + expect(d.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects dispatch for non-ready tasks', () => { + const d = createDb() + const parent = d.createTask({ spec: 'parent' }) + const child = d.createTask({ spec: 'child', deps: [parent.id] }) + + expect(() => d.createDispatchContext(child.id, 'term_worker')).toThrow( + /only ready tasks can be dispatched/ + ) + }) + + it('rejects dispatch to an occupied terminal', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + d.createDispatchContext(t1.id, 'term_worker') + + expect(() => d.createDispatchContext(t2.id, 'term_worker')).toThrow( + /already has an active dispatch/ + ) + }) + + it('allows dispatch to a terminal after previous dispatch completes', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'first' }) + const t2 = d.createTask({ spec: 'second' }) + const ctx1 = d.createDispatchContext(t1.id, 'term_worker') + + d.completeDispatch(ctx1.id) + + expect(() => d.createDispatchContext(t2.id, 'term_worker')).not.toThrow() + }) + + it('getDispatchContext returns latest for a task', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + const found = d.getDispatchContext(task.id) + expect(found?.id).toBe(ctx.id) + }) + + it('getDispatchContext uses insertion order when timestamps tie', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx1 = d.createDispatchContext(task.id, 'term_a') + d.failDispatch(ctx1.id, 'retry') + const ctx2 = d.createDispatchContext(task.id, 'term_a') + + expect(d.getDispatchContext(task.id)?.id).toBe(ctx2.id) + }) + + it('getActiveDispatchForTerminal returns active dispatch', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_a') + + const active = d.getActiveDispatchForTerminal('term_a') + expect(active?.task_id).toBe(task.id) + expect(d.getActiveDispatchForTerminal('term_b')).toBeUndefined() + }) + + it('circuit breaker trips after 3 failures', () => { + const d = createDb() + const task = d.createTask({ spec: 'flaky' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + + const after1 = d.failDispatch(ctx.id, 'timeout') + expect(after1?.failure_count).toBe(1) + expect(after1?.status).toBe('failed') + expect(d.getTask(task.id)?.status).toBe('ready') + + const ctx2 = d.createDispatchContext(task.id, 'term_a') + const after2 = d.failDispatch(ctx2.id, 'timeout') + expect(after2?.failure_count).toBe(2) + expect(after2?.status).toBe('failed') + + const ctx3 = d.createDispatchContext(task.id, 'term_a') + const after3 = d.failDispatch(ctx3.id, 'timeout') + expect(after3?.failure_count).toBe(3) + expect(after3?.status).toBe('circuit_broken') + expect(d.getTask(task.id)?.status).toBe('failed') + }) + + it('completeDispatch sets completed_at', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const ctx = d.createDispatchContext(task.id, 'term_a') + d.completeDispatch(ctx.id) + + const updated = d.getDispatchContext(task.id) + expect(updated?.status).toBe('completed') + expect(updated?.completed_at).toBeTruthy() + }) + }) + + describe('decision gates', () => { + it('creates a gate and blocks the task', () => { + const d = createDb() + const task = d.createTask({ spec: 'needs approval' }) + d.createDispatchContext(task.id, 'term_a') + const gate = d.createGate({ + taskId: task.id, + question: 'Proceed?', + options: ['yes', 'no'] + }) + + expect(gate.id).toMatch(/^gate_/) + expect(gate.task_id).toBe(task.id) + expect(gate.status).toBe('pending') + expect(JSON.parse(gate.options)).toEqual(['yes', 'no']) + + const updated = d.getTask(task.id) + expect(updated?.status).toBe('blocked') + expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('resolves a gate and unblocks the task', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const gate = d.createGate({ taskId: task.id, question: 'ok?' }) + + const resolved = d.resolveGate(gate.id, 'yes') + expect(resolved?.status).toBe('resolved') + expect(resolved?.resolution).toBe('yes') + + const updated = d.getTask(task.id) + expect(updated?.status).toBe('ready') + }) + + it('times out a gate', () => { + const d = createDb() + const task = d.createTask({ spec: 'work' }) + const gate = d.createGate({ taskId: task.id, question: 'ok?' }) + + const timedOut = d.timeoutGate(gate.id) + expect(timedOut?.status).toBe('timeout') + }) + + it('lists gates with filters', () => { + const d = createDb() + const t1 = d.createTask({ spec: 'a' }) + const t2 = d.createTask({ spec: 'b' }) + d.createGate({ taskId: t1.id, question: 'q1' }) + const g2 = d.createGate({ taskId: t2.id, question: 'q2' }) + d.resolveGate(g2.id, 'done') + + expect(d.listGates()).toHaveLength(2) + expect(d.listGates({ status: 'pending' })).toHaveLength(1) + expect(d.listGates({ taskId: t1.id })).toHaveLength(1) + expect(d.listGates({ taskId: t2.id, status: 'resolved' })).toHaveLength(1) + }) + + it('returns undefined for nonexistent gate', () => { + const d = createDb() + expect(d.resolveGate('gate_fake', 'yes')).toBeUndefined() + }) + }) + + describe('coordinator runs', () => { + it('creates and retrieves a coordinator run', () => { + const d = createDb() + const run = d.createCoordinatorRun({ + spec: 'build feature', + coordinatorHandle: 'coord', + pollIntervalMs: 1000 + }) + + expect(run.id).toMatch(/^run_/) + expect(run.status).toBe('running') + expect(run.coordinator_handle).toBe('coord') + expect(run.poll_interval_ms).toBe(1000) + }) + + it('updates coordinator run status', () => { + const d = createDb() + const run = d.createCoordinatorRun({ + spec: 'work', + coordinatorHandle: 'coord' + }) + + const updated = d.updateCoordinatorRun(run.id, 'completed') + expect(updated?.status).toBe('completed') + expect(updated?.completed_at).not.toBeNull() + }) + + it('finds active coordinator run', () => { + const d = createDb() + expect(d.getActiveCoordinatorRun()).toBeUndefined() + + const run = d.createCoordinatorRun({ + spec: 'work', + coordinatorHandle: 'coord' + }) + + expect(d.getActiveCoordinatorRun()?.id).toBe(run.id) + + d.updateCoordinatorRun(run.id, 'completed') + expect(d.getActiveCoordinatorRun()).toBeUndefined() + }) + }) + + describe('lifecycle', () => { + it('resetAll clears all tables', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + d.createTask({ spec: 'work' }) + + d.resetAll() + + expect(d.getInbox()).toHaveLength(0) + expect(d.listTasks()).toHaveLength(0) + }) + + it('resetMessages clears only messages', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + d.createTask({ spec: 'work' }) + + d.resetMessages() + + expect(d.getInbox()).toHaveLength(0) + expect(d.listTasks()).toHaveLength(1) + }) + + it('resetTasks clears tasks and dispatch contexts', () => { + const d = createDb() + d.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + const task = d.createTask({ spec: 'work' }) + d.createDispatchContext(task.id, 'term_a') + + d.resetTasks() + + expect(d.getInbox()).toHaveLength(1) + expect(d.listTasks()).toHaveLength(0) + }) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts new file mode 100644 index 00000000000..b7c74bee72e --- /dev/null +++ b/src/main/runtime/orchestration/db.ts @@ -0,0 +1,561 @@ +/* eslint-disable max-lines -- Why: the orchestration DB keeps schema creation, message CRUD, task DAG resolution, and dispatch context management in one class so transactional invariants (e.g. promoteReadyTasks running inside the same writer as updateTaskStatus) are enforced by locality. */ +import Database from 'better-sqlite3' +import { randomBytes } from 'crypto' +import type { + MessageType, + MessagePriority, + TaskStatus, + DispatchStatus, + GateStatus, + CoordinatorStatus, + MessageRow, + TaskRow, + DispatchContextRow, + DecisionGateRow, + CoordinatorRun +} from './types' + +export type { + MessageType, + MessagePriority, + TaskStatus, + DispatchStatus, + GateStatus, + CoordinatorStatus, + MessageRow, + TaskRow, + DispatchContextRow, + DecisionGateRow, + CoordinatorRun +} + +function generateId(prefix: string): string { + return `${prefix}_${randomBytes(6).toString('hex')}` +} + +export class OrchestrationDb { + private db: Database.Database + + constructor(dbPath: string | ':memory:') { + this.db = new Database(dbPath) + this.db.pragma('journal_mode = WAL') + this.db.pragma('synchronous = NORMAL') + this.db.pragma('busy_timeout = 5000') + this.createTables() + } + + private createTables(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id); + CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_handle, read); + CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + parent_id TEXT, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ( + 'pending', 'ready', 'dispatched', + 'completed', 'failed', 'blocked' + )), + deps TEXT NOT NULL DEFAULT '[]', + result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); + + CREATE TABLE IF NOT EXISTS dispatch_contexts ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + assignee_handle TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, + last_failure TEXT, + dispatched_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); + CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status); + + CREATE TABLE IF NOT EXISTS decision_gates ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'resolved', 'timeout')), + resolution TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_gates_task ON decision_gates(task_id); + CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status); + + CREATE TABLE IF NOT EXISTS coordinator_runs ( + id TEXT PRIMARY KEY, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle' + CHECK(status IN ('idle', 'running', 'completed', 'failed')), + coordinator_handle TEXT NOT NULL, + poll_interval_ms INTEGER NOT NULL DEFAULT 2000, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + `) + } + + // ── Messages ── + + insertMessage(msg: { + from: string + to: string + subject: string + body?: string + type?: MessageType + priority?: MessagePriority + threadId?: string + payload?: string + }): MessageRow { + const id = generateId('msg') + const stmt = this.db.prepare(` + INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + stmt.run( + id, + msg.from, + msg.to, + msg.subject, + msg.body ?? '', + msg.type ?? 'status', + msg.priority ?? 'normal', + msg.threadId ?? null, + msg.payload ?? null + ) + return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow + } + + getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + } + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') + .all(toHandle) as MessageRow[] + } + + getAllMessages(toHandle: string, limit = 20): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] + } + + getMessageById(id: string): MessageRow | undefined { + return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow | undefined + } + + markAsRead(ids: string[]): void { + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids) + } + + getInbox(limit = 20): MessageRow[] { + return this.db + .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') + .all(limit) as MessageRow[] + } + + // ── Tasks ── + + createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { + const id = generateId('task') + const depsJson = JSON.stringify(task.deps ?? []) + const hasDeps = (task.deps ?? []).length > 0 + const status: TaskStatus = hasDeps ? 'pending' : 'ready' + this.db + .prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)') + .run(id, task.parentId ?? null, task.spec, status, depsJson) + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow + } + + getTask(id: string): TaskRow | undefined { + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + } + + listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] { + if (filter?.ready) { + return this.db + .prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at") + .all() as TaskRow[] + } + if (filter?.status) { + return this.db + .prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at') + .all(filter.status) as TaskRow[] + } + return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] + } + + updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined { + const completedAt = + status === 'completed' || status === 'failed' ? new Date().toISOString() : null + this.db + .prepare( + 'UPDATE tasks SET status = ?, result = COALESCE(?, result), completed_at = COALESCE(?, completed_at) WHERE id = ?' + ) + .run(status, result ?? null, completedAt, id) + + if (status === 'completed') { + this.promoteReadyTasks(id) + this.completeActiveDispatchForTask(id) + } + + return this.getTask(id) + } + + // Why: when a task completes, check if any pending tasks that depended on it + // now have all deps satisfied. If so, promote them to 'ready'. This is the + // DAG resolution step — it runs synchronously inside the same transaction as + // the status update, so there's no window where a task is completable but its + // children haven't been promoted. + private promoteReadyTasks(completedTaskId: string): void { + const candidates = this.db + .prepare("SELECT * FROM tasks WHERE status = 'pending'") + .all() as TaskRow[] + + for (const task of candidates) { + const deps: string[] = JSON.parse(task.deps) + if (!deps.includes(completedTaskId)) { + continue + } + + const allDepsCompleted = deps.every((depId) => { + const dep = this.getTask(depId) + return dep?.status === 'completed' + }) + if (allDepsCompleted) { + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) + } + } + } + + // ── Dispatch Contexts ── + + createDispatchContext(taskId: string, assigneeHandle: string): DispatchContextRow { + const task = this.getTask(taskId) + if (!task) { + throw new Error(`Task not found: ${taskId}`) + } + if (task.status !== 'ready') { + throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`) + } + + const existing = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')" + ) + .get(assigneeHandle) as DispatchContextRow | undefined + + if (existing) { + throw new Error( + `Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})` + ) + } + + // Carry forward failure_count from prior contexts so the circuit breaker + // accumulates across retries for the same task. + const prior = this.db + .prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?') + .get(taskId) as { max_failures: number | null } | undefined + const priorFailures = prior?.max_failures ?? 0 + + const id = generateId('ctx') + this.db + .prepare( + `INSERT INTO dispatch_contexts (id, task_id, assignee_handle, status, failure_count, dispatched_at) + VALUES (?, ?, ?, 'dispatched', ?, datetime('now'))` + ) + .run(id, taskId, assigneeHandle, priorFailures) + + this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) + + return this.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(id) as DispatchContextRow + } + + getDispatchContext(taskId: string): DispatchContextRow | undefined { + return this.db + .prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1') + .get(taskId) as DispatchContextRow | undefined + } + + getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { + return this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1" + ) + .get(handle) as DispatchContextRow | undefined + } + + completeDispatch(ctxId: string): void { + this.db + .prepare( + "UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now') WHERE id = ?" + ) + .run(ctxId) + } + + completeActiveDispatchForTask(taskId: string): void { + const active = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + ) + .get(taskId) as DispatchContextRow | undefined + if (active) { + this.completeDispatch(active.id) + } + } + + failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined { + const active = this.db + .prepare( + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + ) + .get(taskId) as DispatchContextRow | undefined + return active ? this.failDispatch(active.id, error) : undefined + } + + failDispatch(ctxId: string, error: string): DispatchContextRow | undefined { + const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined + if (!ctx) { + return undefined + } + + const newFailureCount = ctx.failure_count + 1 + const newStatus: DispatchStatus = newFailureCount >= 3 ? 'circuit_broken' : 'failed' + + this.db + .prepare( + 'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?' + ) + .run(newStatus, newFailureCount, error, ctxId) + + // Why: set the task back to 'ready' (not 'pending') so the coordinator can + // re-dispatch it on the next tick. The task's deps are already satisfied — + // setting it to 'pending' would strand it since promoteReadyTasks only runs + // when a dep completes. + const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready' + this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id) + + return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined + } + + // ── Decision Gates ── + + createGate(gate: { taskId: string; question: string; options?: string[] }): DecisionGateRow { + const id = generateId('gate') + const optionsJson = JSON.stringify(gate.options ?? []) + this.db + .prepare('INSERT INTO decision_gates (id, task_id, question, options) VALUES (?, ?, ?, ?)') + .run(id, gate.taskId, gate.question, optionsJson) + + this.completeActiveDispatchForTask(gate.taskId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) + + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow + } + + resolveGate(gateId: string, resolution: string): DecisionGateRow | undefined { + const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + if (!gate) { + return undefined + } + + this.db + .prepare( + "UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?" + ) + .run(resolution, gateId) + + // Why: unblock the task so the coordinator can re-dispatch it with the + // resolution context. Setting to 'ready' rather than the previous status + // because the worker needs to be re-engaged with the decision outcome. + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id) + + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + } + + timeoutGate(gateId: string): DecisionGateRow | undefined { + this.db + .prepare( + "UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?" + ) + .run(gateId) + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined + } + + listGates(filter?: { taskId?: string; status?: GateStatus }): DecisionGateRow[] { + if (filter?.taskId && filter?.status) { + return this.db + .prepare( + 'SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at' + ) + .all(filter.taskId, filter.status) as DecisionGateRow[] + } + if (filter?.taskId) { + return this.db + .prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at') + .all(filter.taskId) as DecisionGateRow[] + } + if (filter?.status) { + return this.db + .prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at') + .all(filter.status) as DecisionGateRow[] + } + return this.db + .prepare('SELECT * FROM decision_gates ORDER BY created_at') + .all() as DecisionGateRow[] + } + + getGate(id: string): DecisionGateRow | undefined { + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as + | DecisionGateRow + | undefined + } + + // ── Coordinator Runs ── + + createCoordinatorRun(run: { + spec: string + coordinatorHandle: string + pollIntervalMs?: number + }): CoordinatorRun { + const id = generateId('run') + this.db + .prepare( + "INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)" + ) + .run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000) + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun + } + + getCoordinatorRun(id: string): CoordinatorRun | undefined { + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as + | CoordinatorRun + | undefined + } + + updateCoordinatorRun(id: string, status: CoordinatorStatus): CoordinatorRun | undefined { + const completedAt = + status === 'completed' || status === 'failed' ? new Date().toISOString() : null + this.db + .prepare( + 'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?' + ) + .run(status, completedAt, id) + return this.getCoordinatorRun(id) + } + + getActiveCoordinatorRun(): CoordinatorRun | undefined { + return this.db + .prepare( + "SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1" + ) + .get() as CoordinatorRun | undefined + } + + // ── Queries for Coordinator ── + + getIdleTerminals(excludeHandles: string[] = []): string[] { + // Why: returns terminal handles that have no active dispatch, so the + // coordinator knows which terminals are available for new task assignments. + const active = this.db + .prepare( + "SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')" + ) + .all() as { assignee_handle: string }[] + const busyHandles = new Set(active.map((r) => r.assignee_handle)) + for (const h of excludeHandles) { + busyHandles.add(h) + } + // Return handles from message history that aren't busy + const allHandles = this.db + .prepare( + 'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages' + ) + .all() as { to_handle: string }[] + return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h)) + } + + // ── Lifecycle ── + + resetAll(): void { + this.db.exec('DELETE FROM coordinator_runs') + this.db.exec('DELETE FROM decision_gates') + this.db.exec('DELETE FROM dispatch_contexts') + this.db.exec('DELETE FROM tasks') + this.db.exec('DELETE FROM messages') + } + + resetTasks(): void { + this.db.exec('DELETE FROM coordinator_runs') + this.db.exec('DELETE FROM decision_gates') + this.db.exec('DELETE FROM dispatch_contexts') + this.db.exec('DELETE FROM tasks') + } + + resetMessages(): void { + this.db.exec('DELETE FROM messages') + } + + close(): void { + this.db.close() + } +} diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts new file mode 100644 index 00000000000..2b47d40273e --- /dev/null +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { formatMessageBanner, formatMessagesForInjection } from './formatter' +import type { MessageRow } from './types' + +function makeMessage(overrides: Partial = {}): MessageRow { + return { + id: 'msg_test1', + from_handle: 'term_abc123', + to_handle: 'term_coord', + subject: 'Auth API implementation complete', + body: 'All endpoints implemented. Tests passing.', + type: 'worker_done', + priority: 'normal', + thread_id: null, + payload: null, + read: 0, + sequence: 1, + created_at: '2026-01-01T00:00:00Z', + ...overrides + } +} + +describe('formatMessageBanner', () => { + it('formats a normal priority message without a priority tag', () => { + const banner = formatMessageBanner(makeMessage()) + expect(banner).toContain('From: TERM_ABC123 (term_abc123)') + expect(banner).toContain('(worker_done)') + expect(banner).not.toContain('[URGENT]') + expect(banner).not.toContain('[HIGH]') + }) + + it('includes [HIGH] tag for high priority', () => { + const banner = formatMessageBanner(makeMessage({ priority: 'high' })) + expect(banner).toContain('[HIGH]') + }) + + it('includes [URGENT] tag for urgent priority', () => { + const banner = formatMessageBanner(makeMessage({ priority: 'urgent' })) + expect(banner).toContain('[URGENT]') + }) + + it('includes body after subject', () => { + const banner = formatMessageBanner(makeMessage({ body: 'Some details here' })) + const lines = banner.split('\n') + const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:')) + const bodyIdx = lines.indexOf('Some details here') + expect(subjectIdx).toBeGreaterThanOrEqual(0) + expect(bodyIdx).toBeGreaterThan(subjectIdx) + }) + + it('omits body line when body is empty', () => { + const banner = formatMessageBanner(makeMessage({ body: '' })) + const lines = banner.split('\n') + // Subject line should be immediately followed by Reply hint (no empty body line) + const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:')) + expect(lines[subjectIdx + 1]).toMatch(/^\[Reply:/) + }) + + it('includes payload when present', () => { + const payload = '{"taskId":"task_1","exitCode":0}' + const banner = formatMessageBanner(makeMessage({ payload })) + expect(banner).toContain(`[Payload: ${payload}]`) + }) + + it('omits payload line when payload is null', () => { + const banner = formatMessageBanner(makeMessage({ payload: null })) + expect(banner).not.toContain('[Payload:') + }) + + it('includes reply hint with message ID', () => { + const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' })) + expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]') + }) + + it('ends with a separator line', () => { + const banner = formatMessageBanner(makeMessage()) + const lines = banner.split('\n') + expect(lines.at(-1)).toMatch(/^─+$/) + }) +}) + +describe('formatMessagesForInjection', () => { + it('returns empty string for empty array', () => { + expect(formatMessagesForInjection([])).toBe('') + }) + + it('wraps multiple banners with orchestration messages header', () => { + const messages = [makeMessage({ id: 'msg_1' }), makeMessage({ id: 'msg_2' })] + const result = formatMessagesForInjection(messages) + expect(result).toContain('--- Orchestration Messages (2) ---') + expect(result).toContain('msg_1') + expect(result).toContain('msg_2') + expect(result).toMatch(/\n---\n$/) + }) + + it('separates multiple banners with blank lines', () => { + const messages = [makeMessage({ id: 'msg_a' }), makeMessage({ id: 'msg_b' })] + const result = formatMessagesForInjection(messages) + // Two banners should be separated by \n\n + const bannerA = formatMessageBanner(messages[0]) + const bannerB = formatMessageBanner(messages[1]) + expect(result).toContain(`${bannerA}\n\n${bannerB}`) + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts new file mode 100644 index 00000000000..f5526553d3b --- /dev/null +++ b/src/main/runtime/orchestration/formatter.ts @@ -0,0 +1,43 @@ +import type { MessageRow } from './types' + +const BANNER_WIDTH = 60 +const SEPARATOR = '─'.repeat(BANNER_WIDTH) + +// Why: rich message banners help agents (and humans reading terminal output) +// quickly parse message metadata. Priority indicators surface urgent messages +// visually. The reply hint reduces friction for agent-to-agent responses +// (Section 4.8). +export function formatMessageBanner(msg: MessageRow): string { + const priorityTag = + msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : '' + const senderName = msg.from_handle.toUpperCase() + + const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag} (${msg.type}) ────` + + const lines: string[] = [header] + lines.push(`Subject: ${msg.subject}`) + + if (msg.body) { + lines.push(msg.body) + } + + if (msg.payload) { + lines.push(`[Payload: ${msg.payload}]`) + } + + lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`) + lines.push(SEPARATOR) + + return lines.join('\n') +} + +// Why: grouping multiple banners under a single wrapper line lets agents detect +// the message block boundary and parse each banner individually. +export function formatMessagesForInjection(messages: MessageRow[]): string { + if (messages.length === 0) { + return '' + } + + const banners = messages.map(formatMessageBanner).join('\n\n') + return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n` +} diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts new file mode 100644 index 00000000000..61794da9eb1 --- /dev/null +++ b/src/main/runtime/orchestration/groups.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { isGroupAddress, resolveGroupAddress } from './groups' +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' + +function makeSummary( + handle: string, + opts: Partial = {} +): RuntimeTerminalSummary { + return { + handle, + worktreeId: opts.worktreeId ?? 'wt_default', + worktreePath: opts.worktreePath ?? '/tmp/wt', + branch: opts.branch ?? 'main', + tabId: opts.tabId ?? 'tab_1', + leafId: opts.leafId ?? handle, + title: opts.title ?? null, + connected: opts.connected ?? true, + writable: opts.writable ?? true, + lastOutputAt: opts.lastOutputAt ?? null, + preview: opts.preview ?? '' + } +} + +const noStatus = () => null + +describe('isGroupAddress', () => { + it('returns true for @-prefixed addresses', () => { + expect(isGroupAddress('@all')).toBe(true) + expect(isGroupAddress('@idle')).toBe(true) + expect(isGroupAddress('@claude')).toBe(true) + expect(isGroupAddress('@worktree:wt_1')).toBe(true) + }) + + it('returns false for regular handles', () => { + expect(isGroupAddress('term_abc')).toBe(false) + expect(isGroupAddress('coordinator')).toBe(false) + expect(isGroupAddress('')).toBe(false) + }) +}) + +describe('resolveGroupAddress', () => { + it('returns the address as-is for non-group addresses', () => { + const result = resolveGroupAddress('term_b', 'term_a', [], noStatus) + expect(result).toEqual(['term_b']) + }) + + describe('@all', () => { + it('returns all terminals except sender', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')] + const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b', 'term_c']) + }) + + it('returns empty when sender is the only terminal', () => { + const terminals = [makeSummary('term_a')] + const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) + + describe('@idle', () => { + it('returns only idle terminals', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')] + const getStatus = (h: string) => (h === 'term_b' ? 'idle' : 'busy') + const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus) + expect(result).toEqual(['term_b']) + }) + + it('excludes sender even if idle', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b')] + const getStatus = () => 'idle' + const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus) + expect(result).toEqual(['term_b']) + }) + }) + + describe('@worktree:', () => { + it('returns terminals in the specified worktree', () => { + const terminals = [ + makeSummary('term_a', { worktreeId: 'wt_1' }), + makeSummary('term_b', { worktreeId: 'wt_1' }), + makeSummary('term_c', { worktreeId: 'wt_2' }) + ] + const result = resolveGroupAddress('@worktree:wt_1', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('returns empty for nonexistent worktree', () => { + const terminals = [makeSummary('term_a', { worktreeId: 'wt_1' })] + const result = resolveGroupAddress('@worktree:wt_99', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) + + describe('agent name groups', () => { + it('matches @claude by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'Claude Code' }), + makeSummary('term_c', { title: 'Codex CLI' }) + ] + const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('matches @codex by terminal title', () => { + const terminals = [ + makeSummary('term_a', { title: 'Codex CLI' }), + makeSummary('term_b', { title: 'Codex CLI' }) + ] + const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + + it('is case-insensitive for group address', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })] + const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) + expect(result).toEqual(['term_b']) + }) + }) + + describe('unknown groups', () => { + it('returns empty for unrecognized group', () => { + const terminals = [makeSummary('term_a'), makeSummary('term_b')] + const result = resolveGroupAddress('@unknown', 'term_a', terminals, noStatus) + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts new file mode 100644 index 00000000000..8c94759250b --- /dev/null +++ b/src/main/runtime/orchestration/groups.ts @@ -0,0 +1,71 @@ +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' + +// Why: group addresses enable broadcast messaging to logical groups of agents. +// Resolution is done at send-time: one message record per recipient, same thread_id, +// so each recipient gets their own read-tracking (Section 4.5). + +const AGENT_NAME_GROUPS = ['claude', 'codex', 'opencode', 'gemini'] as const + +export type GroupAddress = + | '@all' + | '@idle' + | `@${(typeof AGENT_NAME_GROUPS)[number]}` + | `@worktree:${string}` + +export function isGroupAddress(to: string): boolean { + return to.startsWith('@') +} + +export function resolveGroupAddress( + to: string, + senderHandle: string, + terminals: RuntimeTerminalSummary[], + getAgentStatus: (handle: string) => string | null +): string[] { + if (!isGroupAddress(to)) { + return [to] + } + + const group = to.toLowerCase() + + if (group === '@all') { + // Why: @all broadcasts to every terminal except the sender to avoid self-delivery loops. + return terminals.map((t) => t.handle).filter((h) => h !== senderHandle) + } + + if (group === '@idle') { + // Why: @idle targets only agents whose TUI reports idle status, useful for + // dispatching work to available agents without interrupting busy ones. + return terminals + .filter((t) => t.handle !== senderHandle && getAgentStatus(t.handle) === 'idle') + .map((t) => t.handle) + } + + // @worktree: — all handles in a specific worktree + if (group.startsWith('@worktree:')) { + const worktreeId = to.slice('@worktree:'.length) + return terminals + .filter((t) => t.handle !== senderHandle && t.worktreeId === worktreeId) + .map((t) => t.handle) + } + + // Why: agent-name groups (@claude, @codex, etc.) match by terminal title so + // the sender can address all instances of a particular agent type without + // knowing their handles. + const agentName = group.slice(1) // remove @ + if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) { + return terminals + .filter((t) => { + if (t.handle === senderHandle) { + return false + } + const title = (t.title ?? '').toLowerCase() + return title.includes(agentName) + }) + .map((t) => t.handle) + } + + // Why: unknown groups resolve to empty rather than throwing so callers can + // distinguish "valid group, no current members" from programming errors. + return [] +} diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts new file mode 100644 index 00000000000..71c27d50f66 --- /dev/null +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { buildDispatchPreamble } from './preamble' + +describe('buildDispatchPreamble', () => { + it('substitutes template variables', () => { + const result = buildDispatchPreamble({ + taskId: 'task_abc123', + taskSpec: 'Implement the login form', + coordinatorHandle: 'term_coord' + }) + + expect(result).toContain('task_abc123') + expect(result).toContain('term_coord') + expect(result).toContain('Implement the login form') + expect(result).not.toContain('{{') + }) + + it('includes worker_done command', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c' + }) + + expect(result).toContain('worker_done') + expect(result).toContain('orchestration send') + expect(result).toContain('orchestration check') + }) + + it('includes the task spec after separator', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + taskSpec: 'refactor the auth module', + coordinatorHandle: 'term_c' + }) + + expect(result).toContain('--- TASK ---') + expect(result).toContain('refactor the auth module') + }) + + it('uses orca CLI by default when devMode is not set', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c' + }) + + expect(result).toContain('orca orchestration send') + expect(result).toContain('orca orchestration check') + }) + + it('uses orca-dev CLI when devMode is true', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + devMode: true + }) + + expect(result).toContain('orca-dev orchestration send') + expect(result).toContain('orca-dev orchestration check') + // Ensure no bare "orca " (without -dev) appears as a CLI command. + // We split on "orca-dev" first so those occurrences don't produce + // false positives, then check the remaining fragments. + const fragments = result.split('orca-dev') + for (const fragment of fragments) { + expect(fragment).not.toMatch(/orca orchestration/) + } + }) + + it('uses orca CLI when devMode is false', () => { + const result = buildDispatchPreamble({ + taskId: 'task_x', + taskSpec: 'do stuff', + coordinatorHandle: 'term_c', + devMode: false + }) + + expect(result).toContain('orca orchestration send') + expect(result).toContain('orca orchestration check') + }) +}) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts new file mode 100644 index 00000000000..3c61f0be56a --- /dev/null +++ b/src/main/runtime/orchestration/preamble.ts @@ -0,0 +1,41 @@ +export type PreambleParams = { + taskId: string + taskSpec: string + coordinatorHandle: string + devMode?: boolean +} + +// Why: the dispatch preamble teaches agents about Orca's CLI commands for +// structured communication. Agents don't need prior knowledge of Orca — they +// treat these as shell tools the same way they use git or npm. +export function buildDispatchPreamble(params: PreambleParams): string { + // Why: in dev mode, agents must use orca-dev to connect to the dev runtime's + // socket. Without this, agents inside the dev Electron app would call the + // production CLI and talk to the wrong Orca instance (Section 6.4). + const cli = params.devMode ? 'orca-dev' : 'orca' + + return `You are working inside Orca, a multi-agent IDE. You have access to these +CLI commands for communicating with the coordinator: + + # Report task completion (REQUIRED when done): + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type worker_done --subject "Done" \\ + --payload '{"taskId":"${params.taskId}","filesModified":[...]}' + + # Report a blocker or failure: + ${cli} orchestration send --to ${params.coordinatorHandle} \\ + --type escalation --subject "Blocked: " \\ + --body "
" + + # Check for messages from the coordinator or other agents: + ${cli} orchestration check + +Your assigned task ID is: ${params.taskId} + +When you finish your task, run the worker_done command above with the +list of files you modified. If you are blocked or need help, send an +escalation. Do not exit the session. + +--- TASK --- +${params.taskSpec}` +} diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts new file mode 100644 index 00000000000..1d0ae9a3914 --- /dev/null +++ b/src/main/runtime/orchestration/types.ts @@ -0,0 +1,77 @@ +export type MessageType = + | 'status' + | 'dispatch' + | 'worker_done' + | 'merge_ready' + | 'escalation' + | 'handoff' + | 'decision_gate' + +export type MessagePriority = 'normal' | 'high' | 'urgent' + +export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked' + +export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' + +export type GateStatus = 'pending' | 'resolved' | 'timeout' + +export type CoordinatorStatus = 'idle' | 'running' | 'completed' | 'failed' + +export type MessageRow = { + id: string + from_handle: string + to_handle: string + subject: string + body: string + type: MessageType + priority: MessagePriority + thread_id: string | null + payload: string | null + read: number + sequence: number + created_at: string +} + +export type TaskRow = { + id: string + parent_id: string | null + spec: string + status: TaskStatus + deps: string + result: string | null + created_at: string + completed_at: string | null +} + +export type DispatchContextRow = { + id: string + task_id: string + assignee_handle: string | null + status: DispatchStatus + failure_count: number + last_failure: string | null + dispatched_at: string | null + completed_at: string | null + created_at: string +} + +export type DecisionGateRow = { + id: string + task_id: string + question: string + options: string + status: GateStatus + resolution: string | null + created_at: string + resolved_at: string | null +} + +export type CoordinatorRun = { + id: string + spec: string + status: CoordinatorStatus + coordinator_handle: string + poll_interval_ms: number + created_at: string + completed_at: string | null +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bff8a215bc2..9bdcc514c26 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -5,6 +5,7 @@ import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' +import { ORCHESTRATION_METHODS } from './orchestration' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -15,5 +16,6 @@ export const ALL_RPC_METHODS: readonly RpcMethod[] = [ ...WORKTREE_METHODS, ...TERMINAL_METHODS, ...BROWSER_CORE_METHODS, - ...BROWSER_EXTRA_METHODS + ...BROWSER_EXTRA_METHODS, + ...ORCHESTRATION_METHODS ] diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts new file mode 100644 index 00000000000..ca16b9719f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import type { GateStatus } from '../../orchestration/db' +import { Coordinator } from '../../orchestration/coordinator' + +// Why: the coordinator instance is stored at module scope so orchestration.runStop +// can signal it to halt. Only one coordinator can run at a time (enforced by +// the DB's active-run check), so a single reference suffices. +let activeCoordinator: Coordinator | null = null + +const RunParams = z.object({ + spec: requiredString('Missing --spec'), + from: OptionalString, + pollIntervalMs: OptionalFiniteNumber, + maxConcurrent: OptionalFiniteNumber, + worktree: OptionalString +}) + +const RunStopParams = z.object({}) + +const GateCreateParams = z.object({ + task: requiredString('Missing --task'), + question: requiredString('Missing --question'), + options: OptionalString +}) + +const GateResolveParams = z.object({ + id: requiredString('Missing --id'), + resolution: requiredString('Missing --resolution') +}) + +const GateListParams = z.object({ + task: OptionalString, + status: z.enum(['pending', 'resolved', 'timeout']).optional() +}) + +export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ + // Why: Section 4.12 — orchestration.run returns immediately with a run ID. + // The coordinator loop runs in the background; progress is queried via + // orchestration.taskList. This prevents the RPC call from blocking the + // CLI (or any caller) for the entire duration of the pipeline. + defineMethod({ + name: 'orchestration.run', + params: RunParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + + const existing = db.getActiveCoordinatorRun() + if (existing) { + throw new Error(`Coordinator already running: ${existing.id}`) + } + + const coordinatorHandle = params.from ?? 'coordinator' + const coordinator = new Coordinator(db, runtime, { + spec: params.spec, + coordinatorHandle, + pollIntervalMs: params.pollIntervalMs, + maxConcurrent: params.maxConcurrent, + worktree: params.worktree + }) + + activeCoordinator = coordinator + + const run = db.createCoordinatorRun({ + spec: params.spec, + coordinatorHandle, + pollIntervalMs: params.pollIntervalMs + }) + + // Why: fire-and-forget — the coordinator loop runs in the event loop + // background. Results are persisted to the DB; callers query via + // orchestration.taskList or orchestration.runStatus. + coordinator.runFromExistingRun(run.id).finally(() => { + if (activeCoordinator === coordinator) { + activeCoordinator = null + } + }) + + return { runId: run.id, status: 'running' } + } + }), + + defineMethod({ + name: 'orchestration.runStop', + params: RunStopParams, + handler: (_params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const run = db.getActiveCoordinatorRun() + if (!run) { + throw new Error('No active coordinator run') + } + + if (activeCoordinator) { + activeCoordinator.stop() + activeCoordinator = null + } + + return { runId: run.id, stopped: true } + } + }), + + defineMethod({ + name: 'orchestration.gateCreate', + params: GateCreateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + let options: string[] | undefined + if (params.options) { + try { + const parsed = JSON.parse(params.options) + if (!Array.isArray(parsed) || !parsed.every((option) => typeof option === 'string')) { + throw new Error('not an array of strings') + } + options = parsed + } catch { + throw new Error('Invalid --options: must be a JSON array of strings') + } + } + const gate = db.createGate({ + taskId: params.task, + question: params.question, + options + }) + return { gate } + } + }), + + defineMethod({ + name: 'orchestration.gateResolve', + params: GateResolveParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const gate = db.resolveGate(params.id, params.resolution) + if (!gate) { + throw new Error(`Gate not found: ${params.id}`) + } + return { gate } + } + }), + + defineMethod({ + name: 'orchestration.gateList', + params: GateListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const gates = db.listGates({ + taskId: params.task, + status: params.status as GateStatus + }) + return { gates, count: gates.length } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts new file mode 100644 index 00000000000..64a2507d0f1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -0,0 +1,656 @@ +/* eslint-disable max-lines -- Why: orchestration tests share a mock runtime factory; splitting by method would duplicate 40 lines of setup per file without improving clarity. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { buildRegistry, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' + +describe('orchestration RPC methods', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + + function setup(): void { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + ctx = { runtime } + } + + afterEach(() => { + db?.close() + }) + + function findMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + it('registers all expected methods', () => { + const registry = buildRegistry(ORCHESTRATION_METHODS) + expect(registry.size).toBe(15) + expect(registry.has('orchestration.send')).toBe(true) + expect(registry.has('orchestration.check')).toBe(true) + expect(registry.has('orchestration.reply')).toBe(true) + expect(registry.has('orchestration.inbox')).toBe(true) + expect(registry.has('orchestration.taskCreate')).toBe(true) + expect(registry.has('orchestration.taskList')).toBe(true) + expect(registry.has('orchestration.taskUpdate')).toBe(true) + expect(registry.has('orchestration.dispatch')).toBe(true) + expect(registry.has('orchestration.dispatchShow')).toBe(true) + expect(registry.has('orchestration.run')).toBe(true) + expect(registry.has('orchestration.runStop')).toBe(true) + expect(registry.has('orchestration.gateCreate')).toBe(true) + expect(registry.has('orchestration.gateResolve')).toBe(true) + expect(registry.has('orchestration.gateList')).toBe(true) + expect(registry.has('orchestration.reset')).toBe(true) + }) + + describe('orchestration.send', () => { + it('sends a message', async () => { + setup() + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + const result = (await call('orchestration.send', { + from: 'term_a', + to: 'term_b', + subject: 'hello' + })) as { message: { id: string; from_handle: string } } + + expect(result.message.id).toMatch(/^msg_/) + expect(result.message.from_handle).toBe('term_a') + expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalledWith('term_b') + }) + + it('rejects missing --to', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ subject: 'hi' })).toThrow() + }) + + it('rejects missing --subject', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ to: 'b' })).toThrow() + }) + + it('rejects invalid enum values', () => { + const method = findMethod('orchestration.send') + expect(() => method.params!.parse({ to: 'b', subject: 'hi', type: 'typo' })).toThrow() + expect(() => method.params!.parse({ to: 'b', subject: 'hi', priority: 'medium' })).toThrow() + }) + + function makeSummary( + handle: string, + opts: Partial = {} + ): RuntimeTerminalSummary { + return { + handle, + worktreeId: opts.worktreeId ?? 'wt_default', + worktreePath: opts.worktreePath ?? '/tmp/wt', + branch: opts.branch ?? 'main', + tabId: opts.tabId ?? 'tab_1', + leafId: opts.leafId ?? handle, + title: opts.title ?? null, + connected: opts.connected ?? true, + writable: opts.writable ?? true, + lastOutputAt: opts.lastOutputAt ?? null, + preview: opts.preview ?? '' + } + } + + function setupWithTerminals( + terminals: RuntimeTerminalSummary[], + agentStatuses?: Record + ): void { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( + (handle: string) => agentStatuses?.[handle] ?? null + ) + } + + it('fans out @all to all terminals except sender', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'broadcast' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(2) + expect(result.messages).toHaveLength(2) + const recipients = result.messages.map((m) => m.to_handle).sort() + expect(recipients).toEqual(['term_b', 'term_c']) + }) + + it('fans out @idle to only idle agents', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')], { + term_b: 'idle', + term_c: 'busy' + }) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@idle', + subject: 'idle check' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('fans out agent name group (@claude) by title match', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Claude Code' }), + makeSummary('term_b', { title: 'Claude Code' }), + makeSummary('term_c', { title: 'Codex' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@claude', + subject: 'claude only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('fans out @worktree: to matching worktree', async () => { + setupWithTerminals([ + makeSummary('term_a', { worktreeId: 'wt_1' }), + makeSummary('term_b', { worktreeId: 'wt_1' }), + makeSummary('term_c', { worktreeId: 'wt_2' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@worktree:wt_1', + subject: 'worktree msg' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('shares thread_id across fan-out messages', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'threaded', + threadId: 'my_thread' + })) as { messages: { thread_id: string }[] } + + expect(result.messages[0].thread_id).toBe('my_thread') + expect(result.messages[1].thread_id).toBe('my_thread') + }) + + it('generates a shared thread_id when none provided', async () => { + setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'auto thread' + })) as { messages: { thread_id: string }[] } + + expect(result.messages[0].thread_id).toMatch(/^thread_/) + expect(result.messages[0].thread_id).toBe(result.messages[1].thread_id) + }) + + it('throws when group resolves to no recipients', async () => { + setupWithTerminals([makeSummary('term_a')]) + + await expect( + call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'nobody home' + }) + ).rejects.toThrow('No recipients resolved for group address') + }) + }) + + describe('orchestration.check', () => { + it('returns unread messages for a terminal', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'two' }) + db.insertMessage({ from: 'a', to: 'c', subject: 'other' }) + + const result = (await call('orchestration.check', { + terminal: 'b' + })) as { messages: unknown[]; count: number } + + expect(result.count).toBe(2) + }) + + it('returns formatted output with --inject', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + inject: true + })) as { formatted: string; count: number } + + expect(result.formatted).toContain('Subject: test') + expect(result.count).toBe(1) + }) + + it('filters by type', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'status', type: 'status' }) + db.insertMessage({ from: 'a', to: 'b', subject: 'done', type: 'worker_done' }) + + const result = (await call('orchestration.check', { + terminal: 'b', + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(1) + }) + + it('rejects invalid type filters', async () => { + setup() + await expect( + call('orchestration.check', { + terminal: 'b', + types: 'worker_done,typo' + }) + ).rejects.toThrow('Invalid --types') + }) + }) + + describe('orchestration.reply', () => { + it('replies to a message', async () => { + setup() + const original = db.insertMessage({ from: 'a', to: 'b', subject: 'question' }) + + const result = (await call('orchestration.reply', { + id: original.id, + body: 'answer', + from: 'b' + })) as { message: { to_handle: string; subject: string; thread_id: string } } + + expect(result.message.to_handle).toBe('a') + expect(result.message.subject).toBe('Re: question') + expect(result.message.thread_id).toBe(original.id) + }) + + it('throws on nonexistent message', async () => { + setup() + await expect(call('orchestration.reply', { id: 'msg_fake', body: 'nope' })).rejects.toThrow( + 'Message not found' + ) + }) + }) + + describe('orchestration.inbox', () => { + it('returns all messages', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) + db.insertMessage({ from: 'c', to: 'd', subject: 'two' }) + + const result = (await call('orchestration.inbox', {})) as { count: number } + expect(result.count).toBe(2) + }) + }) + + describe('orchestration.taskCreate', () => { + it('creates a task', async () => { + setup() + const result = (await call('orchestration.taskCreate', { + spec: 'implement feature X' + })) as { task: { id: string; status: string } } + + expect(result.task.id).toMatch(/^task_/) + expect(result.task.status).toBe('ready') + }) + + it('creates a task with deps', async () => { + setup() + const t1 = db.createTask({ spec: 'first' }) + + const result = (await call('orchestration.taskCreate', { + spec: 'second', + deps: JSON.stringify([t1.id]) + })) as { task: { status: string } } + + expect(result.task.status).toBe('pending') + }) + + it('rejects invalid deps JSON', async () => { + setup() + await expect( + call('orchestration.taskCreate', { spec: 'bad', deps: 'not-json' }) + ).rejects.toThrow('Invalid --deps') + }) + }) + + describe('orchestration.taskList', () => { + it('lists all tasks', async () => { + setup() + db.createTask({ spec: 'a' }) + db.createTask({ spec: 'b' }) + + const result = (await call('orchestration.taskList', {})) as { count: number } + expect(result.count).toBe(2) + }) + + it('filters by status', async () => { + setup() + db.createTask({ spec: 'a' }) + const t2 = db.createTask({ spec: 'b' }) + db.updateTaskStatus(t2.id, 'completed') + + const result = (await call('orchestration.taskList', { + status: 'ready' + })) as { count: number } + expect(result.count).toBe(1) + }) + + it('rejects invalid status filters', () => { + const method = findMethod('orchestration.taskList') + expect(() => method.params!.parse({ status: 'done-ish' })).toThrow() + }) + }) + + describe('orchestration.taskUpdate', () => { + it('updates task status', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.taskUpdate', { + id: task.id, + status: 'completed', + result: '{"ok": true}' + })) as { task: { status: string; result: string } } + + expect(result.task.status).toBe('completed') + expect(result.task.result).toBe('{"ok": true}') + }) + + it('completion frees the active dispatch context', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_a') + + await call('orchestration.taskUpdate', { + id: task.id, + status: 'completed' + }) + + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('throws on nonexistent task', async () => { + setup() + await expect( + call('orchestration.taskUpdate', { id: 'task_fake', status: 'completed' }) + ).rejects.toThrow('Task not found') + }) + }) + + describe('orchestration.dispatch', () => { + it('dispatches a task to a terminal', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a' + })) as { dispatch: { task_id: string; status: string } } + + expect(result.dispatch.task_id).toBe(task.id) + expect(result.dispatch.status).toBe('dispatched') + }) + + it('rejects dispatch for a pending task', async () => { + setup() + const parent = db.createTask({ spec: 'parent' }) + const child = db.createTask({ spec: 'child', deps: [parent.id] }) + + await expect( + call('orchestration.dispatch', { + task: child.id, + to: 'term_a' + }) + ).rejects.toThrow('only ready tasks can be dispatched') + }) + + it('rolls back active dispatch when injection fails', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'sendTerminal').mockRejectedValue(new Error('terminal_not_writable')) + + await expect( + call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true + }) + ).rejects.toThrow('terminal_not_writable') + + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined() + }) + + it('uses caller-provided dev mode for injected preamble', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + const send = vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({ + handle: 'term_a', + accepted: true, + bytesWritten: 1 + }) + + await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + devMode: true + }) + + expect(send.mock.calls[0]?.[1].text).toContain('orca-dev orchestration send') + }) + + it('rejects inject to terminal without recognized agent', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(false) + + await expect( + call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true + }) + ).rejects.toThrow('no recognized agent detected') + }) + + it('rejects dispatch to occupied terminal', async () => { + setup() + const t1 = db.createTask({ spec: 'first' }) + const t2 = db.createTask({ spec: 'second' }) + db.createDispatchContext(t1.id, 'term_a') + + await expect(call('orchestration.dispatch', { task: t2.id, to: 'term_a' })).rejects.toThrow( + /already has an active dispatch/ + ) + }) + }) + + describe('orchestration.dispatchShow', () => { + it('shows dispatch context for a task', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + db.createDispatchContext(task.id, 'term_a') + + const result = (await call('orchestration.dispatchShow', { + task: task.id + })) as { dispatch: { task_id: string } | null } + + expect(result.dispatch?.task_id).toBe(task.id) + }) + + it('returns null for unknown task', async () => { + setup() + const result = (await call('orchestration.dispatchShow', { + task: 'task_fake' + })) as { dispatch: null } + + expect(result.dispatch).toBeNull() + }) + }) + + describe('orchestration.gateCreate', () => { + it('creates a decision gate and blocks the task', async () => { + setup() + const task = db.createTask({ spec: 'needs approval' }) + + const result = (await call('orchestration.gateCreate', { + task: task.id, + question: 'Proceed with migration?', + options: JSON.stringify(['yes', 'no', 'defer']) + })) as { gate: { id: string; task_id: string; status: string } } + + expect(result.gate.id).toMatch(/^gate_/) + expect(result.gate.task_id).toBe(task.id) + expect(result.gate.status).toBe('pending') + + const updated = db.getTask(task.id) + expect(updated?.status).toBe('blocked') + }) + + it('rejects invalid options JSON', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + await expect( + call('orchestration.gateCreate', { + task: task.id, + question: 'ok?', + options: 'not-json' + }) + ).rejects.toThrow('Invalid --options') + }) + + it('rejects options that are not string arrays', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + await expect( + call('orchestration.gateCreate', { + task: task.id, + question: 'ok?', + options: JSON.stringify(['yes', 1]) + }) + ).rejects.toThrow('Invalid --options') + }) + }) + + describe('orchestration.gateResolve', () => { + it('resolves a gate and unblocks the task', async () => { + setup() + const task = db.createTask({ spec: 'needs approval' }) + const gate = db.createGate({ taskId: task.id, question: 'Proceed?' }) + + const result = (await call('orchestration.gateResolve', { + id: gate.id, + resolution: 'yes' + })) as { gate: { id: string; status: string; resolution: string } } + + expect(result.gate.status).toBe('resolved') + expect(result.gate.resolution).toBe('yes') + + const updated = db.getTask(task.id) + expect(updated?.status).toBe('ready') + }) + + it('throws on nonexistent gate', async () => { + setup() + await expect( + call('orchestration.gateResolve', { id: 'gate_fake', resolution: 'yes' }) + ).rejects.toThrow('Gate not found') + }) + }) + + describe('orchestration.gateList', () => { + it('lists all gates', async () => { + setup() + const t1 = db.createTask({ spec: 'a' }) + const t2 = db.createTask({ spec: 'b' }) + db.createGate({ taskId: t1.id, question: 'q1' }) + db.createGate({ taskId: t2.id, question: 'q2' }) + + const result = (await call('orchestration.gateList', {})) as { count: number } + expect(result.count).toBe(2) + }) + + it('filters by status', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const gate = db.createGate({ taskId: task.id, question: 'q' }) + db.resolveGate(gate.id, 'yes') + + const result = (await call('orchestration.gateList', { + status: 'resolved' + })) as { count: number } + expect(result.count).toBe(1) + }) + + it('rejects invalid status filters', () => { + const method = findMethod('orchestration.gateList') + expect(() => method.params!.parse({ status: 'closed' })).toThrow() + }) + }) + + describe('orchestration.reset', () => { + it('resets all state', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.reset', { all: true })) as { reset: string } + expect(result.reset).toBe('all') + expect(db.getInbox()).toHaveLength(0) + expect(db.listTasks()).toHaveLength(0) + }) + + it('resets tasks only', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + await call('orchestration.reset', { tasks: true }) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(0) + }) + + it('resets messages only', async () => { + setup() + db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) + db.createTask({ spec: 'work' }) + + await call('orchestration.reset', { messages: true }) + 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 new file mode 100644 index 00000000000..5c467e369b0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -0,0 +1,402 @@ +/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' +import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { formatMessageBanner } from '../../orchestration/formatter' +import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' + +const MESSAGE_TYPES: MessageType[] = [ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate' +] + +const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +const SendParams = z.object({ + to: requiredString('Missing --to'), + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum([ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate' + ]) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + devMode: OptionalBoolean +}) + +const CheckParams = z.object({ + terminal: OptionalString, + unread: OptionalBoolean, + types: OptionalString, + inject: OptionalBoolean, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber +}) + +const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString +}) + +const InboxParams = z.object({ + limit: OptionalFiniteNumber +}) + +const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + deps: OptionalString, + parent: OptionalString +}) + +const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean +}) + +const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString +}) + +const DispatchParams = z.object({ + task: requiredString('Missing --task'), + to: requiredString('Missing --to'), + from: OptionalString, + inject: OptionalBoolean, + devMode: OptionalBoolean +}) + +const DispatchShowParams = z.object({ + task: OptionalString +}) + +const ResetParams = z.object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean +}) + +export const ORCHESTRATION_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + + if (!isGroupAddress(params.to)) { + // Point-to-point — existing single-recipient behavior + const msg = db.insertMessage({ + from, + to: params.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: params.payload + }) + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to) + return { message: msg } + } + + // Why: group addresses fan out to one message per recipient so each gets + // independent read-tracking, but they share a thread_id so the conversation + // can be correlated (Section 4.5). + const { terminals } = await runtime.listTerminals() + const handles = resolveGroupAddress(params.to, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${params.to}`) + } + + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = handles.map((handle) => + db.insertMessage({ + from, + to: handle, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload + }) + ) + for (const handle of handles) { + runtime.deliverPendingMessagesForHandle(handle) + runtime.notifyMessageArrived(handle) + } + + return { messages, recipients: handles.length } + } + }), + + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const handle = params.terminal ?? 'unknown' + const typeFilter = params.types + ? (params.types + .split(',') + .map((t) => t.trim()) + .filter(Boolean) as MessageType[]) + : undefined + const invalidTypes = typeFilter?.filter((t) => !MESSAGE_TYPES.includes(t)) + if (invalidTypes && invalidTypes.length > 0) { + throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) + } + + const showUnread = params.unread !== false + + const readAndReturn = () => { + const messages = showUnread + ? db.getUnreadMessages(handle, typeFilter) + : db.getAllMessages(handle) + + if (showUnread && messages.length > 0) { + db.markAsRead(messages.map((m) => m.id)) + } + + if (params.inject) { + const formatted = messages.map(formatMessageBanner).join('\n\n') + return { messages, formatted, count: messages.length } + } + + return { messages, count: messages.length } + } + + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + + // Why: blocking wait lets coordinators replace sleep+poll loops with a + // single call that resolves when a message arrives or the timeout expires. + await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined + }) + return readAndReturn() + } + }), + + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id + }) + + runtime.notifyMessageArrived(original.from_handle) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const messages = db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + let deps: string[] | undefined + if (params.deps) { + try { + const parsed = JSON.parse(params.deps) + if (!Array.isArray(parsed) || !parsed.every((d) => typeof d === 'string')) { + throw new Error('not an array of strings') + } + deps = parsed + } catch { + throw new Error('Invalid --deps: must be a JSON array of task IDs') + } + } + const task = db.createTask({ + spec: params.spec, + deps, + parentId: params.parent + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const tasks = db.listTasks({ + status: params.status as TaskStatus, + ready: params.ready + }) + return { tasks, count: tasks.length } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }), + + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: dispatching with --inject to a bare shell (zsh/bash) dumps the + // preamble as shell commands, producing gibberish. Check both OSC title + // status and foreground process — Claude Code doesn't emit recognized OSC + // titles on startup, so title-only detection misses freshly spawned agents. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(params.to) + if (!hasAgent) { + throw new Error( + `Cannot dispatch --inject to terminal ${params.to}: no recognized agent detected. ` + + 'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' + + 'or dispatch without --inject and send the prompt manually.' + ) + } + } + + const ctx = db.createDispatchContext(params.task, params.to) + + let injected = false + if (params.inject) { + try { + const preamble = buildDispatchPreamble({ + taskId: task.id, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + devMode: params.devMode + }) + await runtime.sendTerminal(params.to, { text: preamble, enter: true }) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + return { dispatch: ctx ?? null } + } + }), + + ...ORCHESTRATION_GATE_METHODS, + + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + db.resetAll() + return { reset: 'all' } + } + }) +] diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 594a0161bb2..58fff491f44 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -247,7 +247,8 @@ describe('OrcaRuntimeRpcServer', () => { writes.push(data) return true }, - kill: () => true + kill: () => true, + getForegroundProcess: async () => null }) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) @@ -341,7 +342,7 @@ describe('OrcaRuntimeRpcServer', () => { id: 'req_send', ok: true }) - expect(writes).toEqual(['continue\r']) + expect(writes).toEqual(['continue', '\r']) const waitPromise = sendRequest(metadata!.transport!.endpoint, { id: 'req_wait', diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index c5cec06581b..2a972db6af1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -34,6 +34,7 @@ import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import type { DetectedPort } from '../../shared/ssh-types' import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -54,6 +55,7 @@ export class SshRelaySession { private getMainWindow: () => BrowserWindow | null, private store: Store, private portForwardManager: SshPortForwardManager, + private runtime?: OrcaRuntimeService, private onDetectedPortsChanged?: ( targetId: string, ports: DetectedPort[], @@ -462,6 +464,7 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { const getWin = this.getMainWindow ptyProvider.onData((payload) => { + this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:data', payload) @@ -476,6 +479,7 @@ export class SshRelaySession { ptyProvider.onExit((payload) => { clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) + this.runtime?.onPtyExit(payload.id, payload.code) const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index c1fea6eab25..e56e8c79c18 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -63,6 +63,7 @@ describe('run-electron-vite-dev', () => { env: { ...process.env, ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile }, stdio: 'ignore' diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index b1e71587ec2..2fcf6271f05 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -54,7 +54,7 @@ export function attachMainWindowServices( } return ids }) - registerSshHandlers(store, () => mainWindow) + registerSshHandlers(store, () => mainWindow, runtime) registerFileDropRelay(mainWindow) setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 66cac215b40..61c4c894a63 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' -import { RotateCw } from 'lucide-react' +import { Copy, RotateCw } from 'lucide-react' +import { toast } from 'sonner' import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' @@ -10,6 +12,9 @@ import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search' export { EXPERIMENTAL_PANE_SEARCH_ENTRIES } +const ORCHESTRATION_SKILL_INSTALL_COMMAND = + 'npx skills add https://github.com/stablyai/orca --skill orchestration' + type ExperimentalPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -49,6 +54,37 @@ export function ExperimentalPane({ }, []) const showDaemon = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]]) + const showOrchestration = matchesSettingsSearch(searchQuery, [ + EXPERIMENTAL_PANE_SEARCH_ENTRIES[1] + ]) + + const [orchestrationEnabled, setOrchestrationEnabled] = useState(() => { + return localStorage.getItem('orca.orchestration.enabled') === '1' + }) + + const [orchestrationSkillInstalled, setOrchestrationSkillInstalled] = useState(() => { + return localStorage.getItem('orca.orchestration.skillInstalled') === '1' + }) + + const toggleOrchestration = (value: boolean): void => { + setOrchestrationEnabled(value) + localStorage.setItem('orca.orchestration.enabled', value ? '1' : '0') + } + + const markOrchestrationSkillInstalled = (value: boolean): void => { + setOrchestrationSkillInstalled(value) + localStorage.setItem('orca.orchestration.skillInstalled', value ? '1' : '0') + } + + const handleCopyOrchestrationCommand = async (): Promise => { + try { + await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND) + toast.success('Copied install command. Run it in your agent project.') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to copy command.') + } + } + const pendingRestart = daemonEnabledAtStartup !== null && settings.experimentalTerminalDaemon !== daemonEnabledAtStartup @@ -139,6 +175,87 @@ export function ExperimentalPane({ ) : null} + {showOrchestration ? ( + +
+
+ +

+ Coordinate multiple coding agents with messaging, task DAGs, dispatch with preamble + injection, decision gates, and coordinator loops. Experimental — APIs may change. +

+
+ +
+ + {orchestrationEnabled ? ( +
+
+

Install Orchestration Skill

+

+ Run this in your agent project so agents learn to use inter-agent orchestration + commands. +

+
+
+ + {ORCHESTRATION_SKILL_INSTALL_COMMAND} + + + + + + + + Copy + + + +
+
+ + {orchestrationSkillInstalled + ? 'Marked as installed on this machine.' + : "Check off once you've run it in your project."} + + +
+
+ ) : null} +
+ ) : null} + {hiddenExperimentalUnlocked ? : null} ) diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 821ed6ad449..5f44bae1712 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -16,5 +16,23 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'scrollback', 'reattach' ] + }, + { + title: 'Agent Orchestration', + description: + 'Coordinate multiple coding agents via messaging, task DAGs, dispatch, and decision gates.', + keywords: [ + 'experimental', + 'orchestration', + 'multi-agent', + 'agents', + 'coordination', + 'messaging', + 'dispatch', + 'task', + 'DAG', + 'worker', + 'coordinator' + ] } ] diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index 1ba2e70ba13..59c64c0f04d 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -83,17 +83,4 @@ export function buildAgentStartupPlan(args: { } } -export function isShellProcess(processName: string): boolean { - const normalized = processName.trim().toLowerCase() - return ( - normalized === '' || - normalized === 'bash' || - normalized === 'zsh' || - normalized === 'sh' || - normalized === 'fish' || - normalized === 'cmd.exe' || - normalized === 'powershell.exe' || - normalized === 'pwsh.exe' || - normalized === 'nu' - ) -} +export { isShellProcess } from '../../../shared/agent-detection' diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3ebe8fb08a5..f7502bdeb74 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -98,12 +98,14 @@ async function syncRuntimeGraph(): Promise { ptyId: savedPtyId }) } + const paneTitles = state.runtimePaneTitlesByTabId[tabId] ?? {} graph.leaves.push({ tabId, worktreeId: registeredTab.worktreeId, leafId, paneRuntimeId: pane.id, - ptyId + ptyId, + paneTitle: paneTitles[pane.id] ?? null }) } } diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index a3f4416d572..caba0ead381 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -434,3 +434,23 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return null } + +// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the +// renderer (agent-ready-wait, new-workspace). A bare shell is the only process +// type that garbles injected preambles, so this is the negative signal for +// "is an agent running". +const SHELL_NAMES = new Set([ + '', + 'bash', + 'zsh', + 'sh', + 'fish', + 'cmd.exe', + 'powershell.exe', + 'pwsh.exe', + 'nu' +]) + +export function isShellProcess(processName: string): boolean { + return SHELL_NAMES.has(processName.trim().toLowerCase()) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 1d0835c8086..adba1447e6e 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -49,6 +49,7 @@ export type RuntimeSyncedLeaf = { leafId: string paneRuntimeId: number ptyId: string | null + paneTitle?: string | null } export type RuntimeSyncWindowGraph = { From 1559cd290f3366033a6ec70ad4b5c42fdc886292 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:37:50 -0700 Subject: [PATCH 002/259] fix(agents): detect Kiro under its real binary name `kiro-cli` (#1223) Co-authored-by: Orca --- src/renderer/src/lib/agent-catalog.tsx | 6 +++++- src/shared/tui-agent-config.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index dae7b134430..041764edbb1 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -85,7 +85,11 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [ { id: 'kiro', label: 'Kiro', - cmd: 'kiro', + // Why: the Kiro installer (https://cli.kiro.dev/install) ships a binary + // named `kiro-cli`, not `kiro`. Match TUI_AGENT_CONFIG.kiro.detectCmd so + // the settings pane's "default command" hint aligns with what Orca + // actually looks for on PATH. + cmd: 'kiro-cli', faviconDomain: 'kiro.dev', homepageUrl: 'https://kiro.dev/docs/cli/' }, diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 7a579cd25c1..a5ed20950a2 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -76,9 +76,13 @@ export const TUI_AGENT_CONFIG: Record = { promptInjectionMode: 'stdin-after-start' }, kiro: { - detectCmd: 'kiro', - launchCmd: 'kiro', - expectedProcess: 'kiro', + // Why: the official Kiro installer (https://cli.kiro.dev/install) places a + // binary named `kiro-cli` on PATH — there is no `kiro` binary. Keep the + // TuiAgent id as 'kiro' for stored preferences, but detect/launch/identify + // the real binary name so the agent is recognized as active. + detectCmd: 'kiro-cli', + launchCmd: 'kiro-cli', + expectedProcess: 'kiro-cli', promptInjectionMode: 'stdin-after-start' }, crush: { From 8e5d19eae3ba01c5b6f8ca1d6b4058d9411828e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:38:17 -0700 Subject: [PATCH 003/259] fix(agents): use Kilocode brand logo instead of illegible favicon (#1224) Co-authored-by: Orca --- README.md | 2 +- docs/README.es.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-CN.md | 2 +- src/renderer/src/lib/agent-catalog.tsx | 29 +++++++++++++++++++++++++- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 13db28e0da7..5c78431ac98 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Orca supports any CLI agent (_not just this list_). Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   diff --git a/docs/README.es.md b/docs/README.es.md index 10f9b4799da..a539a2f81d7 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -49,7 +49,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   diff --git a/docs/README.ja.md b/docs/README.ja.md index b04a4803010..85944223717 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -50,7 +50,7 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定 Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index d756f57955b..bac00fad59a 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -50,7 +50,7 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。 Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 041764edbb1..183bb52303b 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -79,7 +79,6 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [ id: 'kilo', label: 'Kilocode', cmd: 'kilo', - faviconDomain: 'kilo.ai', homepageUrl: 'https://kilo.ai/docs/cli' }, { @@ -202,6 +201,31 @@ function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element { ) } +function KiloIcon({ size = 14 }: { size?: number }): React.JSX.Element { + // SVG sourced from Kilo-Org/kilocode:packages/kilo-vscode/assets/icons/kilo-light.svg. + // Why: the Google favicon for kilo.ai is black-on-black at small sizes and + // is illegible. Inlining the brand mark (yellow on black) keeps it readable + // on both light and dark app themes without using currentColor — this logo + // is intentionally brand-colored, not theme-colored. + return ( + + + + + ) +} + function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element { // SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg. // Why: className="text-current" opts out of shadcn's Select rule that forces @@ -281,6 +305,9 @@ export function AgentIcon({ if (agent === 'aider') { return } + if (agent === 'kilo') { + return + } const catalogEntry = AGENT_CATALOG.find((a) => a.id === agent) if (catalogEntry?.faviconDomain) { // Why: agents without a published SVG icon use their site favicon via From 56d2386b5e639634130abfb7b7a4599b4317d8f0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:57:49 -0700 Subject: [PATCH 004/259] fix: stop file explorer edge auto-scroll on drag leave (#1225) - fix: stop file explorer edge auto-scroll on drag leave Co-authored-by: Orca --- .../right-sidebar/useFileExplorerDragDrop.ts | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 7fb36136564..190bd2ef0fb 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -288,19 +288,30 @@ export function useFileExplorerDragDrop({ setIsNativeDragOver(true) } }, []), - onDragLeave: useCallback((_e: React.DragEvent) => { - // Decrement both counters since we cannot inspect types on dragleave - rootDragCounterRef.current -= 1 - if (rootDragCounterRef.current <= 0) { - rootDragCounterRef.current = 0 - setIsRootDragOver(false) - } - nativeRootDragCounterRef.current -= 1 - if (nativeRootDragCounterRef.current <= 0) { - nativeRootDragCounterRef.current = 0 - setIsNativeDragOver(false) - } - }, []), + onDragLeave: useCallback( + (_e: React.DragEvent) => { + // Decrement both counters since we cannot inspect types on dragleave + rootDragCounterRef.current -= 1 + if (rootDragCounterRef.current <= 0) { + rootDragCounterRef.current = 0 + setIsRootDragOver(false) + } + nativeRootDragCounterRef.current -= 1 + if (nativeRootDragCounterRef.current <= 0) { + nativeRootDragCounterRef.current = 0 + setIsNativeDragOver(false) + } + // Why: the edge auto-scroll rAF loop re-schedules itself as long as the + // last recorded cursor Y sits in an edge zone. If the drag leaves the + // explorer (dragged out of window, ESC-cancelled, or dropped elsewhere) + // neither onDrop nor onDragEnd fires here, so without this stop the + // loop keeps scrolling the viewport down and fights manual scroll-up. + if (rootDragCounterRef.current === 0 && nativeRootDragCounterRef.current === 0) { + stopDragEdgeScroll() + } + }, + [stopDragEdgeScroll] + ), onDrop: useCallback( (e: React.DragEvent) => { e.preventDefault() From 1937d481a0b08c90505fd6c4e8852d531831540b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:09:22 -0700 Subject: [PATCH 005/259] test(notifications): guard agent-task-complete producer at IPC boundary (#1218) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) 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 15c01966de1..be93fc0b21d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ +import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_FOCUS_REPORTING_RESET, POST_REPLAY_MODE_RESET } from './layout-serialization' +import type * as UseNotificationDispatchModule from './use-notification-dispatch' const toastInfo = vi.fn() @@ -81,6 +83,24 @@ vi.mock('sonner', () => ({ } })) +// Why: the working→idle test imports the real useNotificationDispatch to +// verify producer → IPC end-to-end. useCallback is pure memoization for +// that hook, so pass-through here lets it be invoked outside React. +// +// Scope note: this mock applies to every test in this file, not just the +// working→idle test. It is safe today because no other test in this file +// depends on useCallback identity stability — the suite does not render +// React components. If that ever changes, either narrow this with +// vi.doMock inside the it() block or extract the hook body into a plain +// non-hook function so the test does not need to bypass React at all. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + vi.mock('./pty-transport', () => ({ createIpcPtyTransport: vi.fn((options: Record) => { createdTransportOptions.push(options) @@ -217,6 +237,9 @@ describe('connectPanePty', () => { pty: { signal: vi.fn(), ackColdRestore: vi.fn() + }, + notifications: { + dispatch: vi.fn() } } } @@ -983,14 +1006,33 @@ describe('connectPanePty', () => { // 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. + // + // This test deliberately wires the real useNotificationDispatch hook into + // connectPanePty instead of a vi.fn() stub. A stub would let the producer + // be silently deleted and the test still pass by asserting "not called"; + // 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 () => { const { connectPanePty } = await import('./pty-connection') + const { useNotificationDispatch } = await vi.importActual( + './use-notification-dispatch' + ) const transport = createMockTransport() transportFactoryQueue.push(transport) + // Why: useNotificationDispatch uses useCallback internally; bypass the + // React machinery by invoking its body directly through a module call. + // Safe here because useCallback is pure memoization — the returned + // function has the same behavior as the callback passed in. + // Depends on the file-level vi.mock('react', ...) near the top of this + // file that replaces useCallback with a pass-through. Removing that + // mock breaks this test with a rules-of-hooks error. + const dispatchNotification = useNotificationDispatch('wt-1') + const pane = createPane(1) const manager = createManager(1) - const deps = createDeps() + const deps = createDeps({ dispatchNotification }) connectPanePty(pane as never, manager as never, deps as never) @@ -1005,10 +1047,13 @@ describe('connectPanePty', () => { expect(deps.markWorktreeUnread).not.toHaveBeenCalled() expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() - expect(deps.dispatchNotification).toHaveBeenCalledWith({ - source: 'agent-task-complete', - terminalTitle: '* Claude done' - }) + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'agent-task-complete', + worktreeId: 'wt-1', + terminalTitle: '* Claude done' + }) + ) }) // Why: onAgentExited must clear any running prompt-cache countdown so the From 910c5719d10ba2c1ff08f5397228e1101caac0dc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:12:14 -0700 Subject: [PATCH 006/259] fix(terminal): restore cursor visibility on daemon snapshot reattach (#1217) Co-authored-by: Orca --- .../terminal-pane/layout-serialization.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 143d67827c1..bab1a94dd1d 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -36,12 +36,18 @@ export const POST_REPLAY_MODE_RESET = // 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 -// rely on mouse or bracketed-paste modes. Focus reporting is the exception: -// if xterm preserves `?1004h` across snapshot replay, pane focus/blur emits -// `\e[I` / `\e[O` into the PTY and shells like zsh ring BEL when no TUI is -// actively consuming them. Clearing only 1004 preserves the other live-session -// modes while preventing phantom bells on restored background tabs. -export const POST_REPLAY_FOCUS_REPORTING_RESET = '\x1b[?1004l' +// rely on mouse or bracketed-paste modes. Two exceptions are safe to reset: +// +// 25 — DECTCEM cursor visibility: SerializeAddon bakes `?25l` into the +// snapshot when the cursor was hidden at capture time. Without `?25h` +// here the cursor stays invisible after reattach. If a TUI is still +// running and wants the cursor hidden, the SIGWINCH sent immediately +// after restore triggers a repaint that re-hides it — a brief flash +// that is far less harmful than a permanently invisible cursor. +// 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_FOCUS_REPORTING_RESET = '\x1b[?25h\x1b[?1004l' export function paneLeafId(paneId: number): string { return `pane:${paneId}` From e4f9c2496fe69a2ed865b0a3016d3f6255174fc6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:25:16 -0700 Subject: [PATCH 007/259] feat(skills): add standalone orchestration skill (#1227) Co-authored-by: Orca --- skills/orchestration/SKILL.md | 196 ++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 skills/orchestration/SKILL.md diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md new file mode 100644 index 00000000000..5de16074078 --- /dev/null +++ b/skills/orchestration/SKILL.md @@ -0,0 +1,196 @@ +--- +name: orchestration +description: Use the Orca CLI to coordinate multiple coding agents via inter-agent messaging, task DAGs, dispatch with preamble injection, decision gates, and coordinator loops. Use when an agent needs to send or check inter-agent messages; create, dispatch, or track orchestration tasks; coordinate multi-agent workflows; or act as a coordinator dispatching work across terminals. Triggers include "orchestrate agents", "dispatch task", "send message to agent", "check inbox", "coordinate agents", "multi-agent", "create task DAG", "worker_done", "escalation", or any task involving inter-agent coordination through Orca. +--- + +# Orca Inter-Agent Orchestration + +Use this skill when the task involves coordinating multiple coding agents through Orca's orchestration system. For basic terminal and worktree management, use the `orca-cli` skill instead. + +## When To Use + +- You need to send messages between agent terminals +- You need to decompose a spec into parallel subtasks with dependencies +- You need to dispatch tasks to worker agents with structured feedback +- You need to act as a coordinator managing a multi-agent workflow +- You need to create decision gates for human-in-the-loop checkpoints + +## Preconditions + +- Orca must be running (`orca status --json` should return `runtime: true`). +- The `orca` CLI must be on PATH (installed via Settings > Browser > Enable Orca CLI). +- The orchestration experimental feature must be enabled in Settings > Experimental. +- All `orca orchestration` commands are RPC calls to the running Orca runtime — they require an active Orca session. + +## Command Surface + +### Messaging + +Inter-agent messaging via persistent SQLite-backed mail store. Messages are delivered automatically when the recipient agent goes idle (push-on-idle). + +```bash +orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json] +orca orchestration check [--terminal ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json] +orca orchestration reply --id --body [--from ] [--json] +orca orchestration inbox [--limit ] [--json] +``` + +Why: `--from` auto-resolves via the `ORCA_TERMINAL_HANDLE` environment variable injected into every Orca-managed terminal. Omit it unless impersonating another terminal. + +Why: `--inject` formats messages as readable banners with priority indicators (`[HIGH]`, `[URGENT]`) for agent prompt injection. Use `--json` for machine-readable output. + +Why: `--wait` blocks until a matching message arrives or the timeout expires (default 2 minutes). This replaces sleep+poll loops. If unread messages already exist, returns immediately. Combine with `--types` to wait for specific message types (e.g. `--wait --types worker_done --timeout-ms 120000`). + +**Message types**: `status` (general), `dispatch` (assign work), `worker_done` (signal completion), `merge_ready` (branch ready for merge), `escalation` (issue requiring attention), `handoff` (pass work to another agent), `decision_gate` (human-in-the-loop). + +**Priority levels**: `normal`, `high`, `urgent`. + +**Group addresses** resolve to terminal handles: + +| Group | Resolves To | +|-------|------------| +| `@all` | All terminal handles except sender | +| `@idle` | Handles where the agent is currently idle | +| `@claude` | Handles running Claude Code | +| `@codex` | Handles running Codex | +| `@opencode` | Handles running OpenCode | +| `@gemini` | Handles running Gemini | +| `@worktree:` | All handles in a specific worktree | + +Group messages fan out: one message per recipient, shared `thread_id`, independent read tracking. + +### Tasks + +Task tracking with DAG dependencies. A task becomes `ready` when all tasks in its `deps` array are `completed`. + +```bash +orca orchestration task-create --spec [--deps ] [--parent ] [--json] +orca orchestration task-list [--status ] [--ready] [--json] +orca orchestration task-update --id --status [--result ] [--json] +``` + +**Task statuses**: `pending` (waiting on deps), `ready` (deps met, dispatchable), `dispatched` (assigned to a terminal), `completed`, `failed`, `blocked` (waiting on a decision gate). + +Why: when a task is marked `completed`, the runtime automatically promotes any pending tasks whose deps are now all satisfied to `ready`. This is the DAG resolution step. + +### Dispatch + +Dispatch assigns a ready task to a terminal. Optionally injects the task spec + preamble into the terminal so the agent knows how to communicate back. + +```bash +orca orchestration dispatch --task --to [--from ] [--inject] [--json] +orca orchestration dispatch-show --task [--json] +``` + +Why: `--inject` sends a preamble that teaches the agent how to use `orca orchestration send --type worker_done` to report completion. All agents have `orca` on PATH and can execute shell commands. The preamble maximizes structured feedback but the system works without it (coordinator falls back to idle detection + output reading). + +Why: `--inject` requires a recognized agent CLI (e.g. Claude Code) running in the target terminal. If the terminal is a bare shell, omit `--inject` and send the prompt manually with `terminal send`. + +Why: dispatch contexts are separate from tasks (sling pattern). A task can be dispatched, fail, and be re-dispatched to a different terminal — the task stays clean while dispatch contexts track retry state. + +**Circuit breaker**: After 3 consecutive failures on a task, the dispatch context is marked `circuit_broken`. The task is marked `failed` to prevent infinite retry loops. + +### Decision Gates + +Human-in-the-loop decision points that block a task until resolved. + +```bash +orca orchestration gate-create --task --question [--options ] [--json] +orca orchestration gate-resolve --id --resolution [--json] +orca orchestration gate-list [--task ] [--status ] [--json] +``` + +Why: creating a gate blocks the task and completes its active dispatch. Resolving a gate sets the task back to `ready` with the resolution context included in the next dispatch preamble. + +**Gate statuses**: `pending`, `resolved`, `timeout`. + +### Coordinator + +Start an automated coordinator loop that dispatches ready tasks, processes `worker_done`/`escalation` messages, and advances the task DAG. + +```bash +orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json] +orca orchestration run-stop [--json] +``` + +Why: `run` returns immediately with a run ID. The coordinator loop runs in the background inside the Orca runtime. Query progress via `orca orchestration task-list`. Only one coordinator can run at a time. + +**Coordinator phases**: `decomposing` → `dispatching` → `monitoring` → `merging` → `done`. + +### Lifecycle + +```bash +orca orchestration reset [--all] [--tasks] [--messages] [--json] +``` + +Why: `--all` is the default if no flags provided. `--tasks` clears tasks, dispatch contexts, decision gates, and coordinator runs but preserves messages. + +### Terminal Commands for Coordinators + +Coordinators need these terminal commands to spawn agents, monitor progress, and read output. Full terminal documentation lives in the `orca-cli` skill — this is the subset required for orchestration workflows. + +```bash +orca terminal list [--worktree ] [--json] +orca terminal create [--worktree ] [--title ] [--command ] [--json] +orca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json] +orca terminal read [--terminal ] [--json] +orca terminal send [--terminal ] --text [--enter] [--json] +orca terminal wait [--terminal ] --for [--timeout-ms ] [--json] +orca terminal show --terminal [--json] +orca terminal stop [--terminal ] [--json] +orca terminal close [--terminal ] [--json] +``` + +Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree. + +Why: `--command "claude"` launches Claude Code in the new terminal. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching. + +Why: `--for tui-idle` detects the working→idle OSC title transition for recognized agent CLIs (Claude Code, Gemini, Codex, etc.). Always pass `--timeout-ms` — real coding tasks routinely take 15-60 minutes. + +Why: `--direction horizontal` splits left/right (new pane to the right). `--direction vertical` splits top/bottom (new pane below). Default is horizontal. + +Why: terminal handles are runtime-scoped. If Orca restarts, handles go stale. Re-acquire with `terminal list`. + +Why: the 120-line terminal output buffer (`terminal read`) is for status monitoring, not result extraction. Prefer structured `worker_done` payloads over parsing terminal output. + +## Agent Guidance + +- When dispatched with a preamble, **always run the `worker_done` command when done**. This is the primary feedback mechanism — it keeps the coordinator's context window clean. +- If blocked or unable to complete a task, send an `escalation` message to the coordinator instead of silently stalling. +- Use `orca orchestration check` to read incoming messages from the coordinator or other agents. Messages are delivered automatically when you go idle, but you can also poll explicitly. +- Treat `orca orchestration` commands the same way you treat `git` or `npm` — they are CLI tools available in your shell. +- The coordinator uses `orca orchestration task-list --ready` as its external memory. Prefer querying orchestration state over tracking it in your context window. +- For multi-agent coordination, prefer the **inter-worktree** pattern (each agent in its own worktree) for parallel implementation tasks. Use **intra-worktree** (split panes, shared files) for complementary tasks where agents don't edit the same files. +- When acting as coordinator: discover existing agents with `terminal list`, create tasks with `task-create`, dispatch with `dispatch --inject`, and wait for `worker_done` messages via `check --wait --types worker_done,escalation --timeout-ms 300000`. +- When acting as coordinator: prefer `check --wait` over sleep+poll loops. `--wait` blocks until a message arrives, eliminating wasted time. Always pass `--timeout-ms` as a safety net. If the wait times out with no messages, fall back to `terminal wait --for tui-idle` and then reading terminal output. +- `check --wait` returns one message at a time. If N workers finish near-simultaneously, call `check --wait` N times in a loop to collect all results. After each return, mark the task complete (which auto-promotes dependents) and dispatch the next wave before looping back to wait. +- After receiving `worker_done` from a terminal, that terminal is guaranteed idle — skip the `terminal wait --for tui-idle` round-trip and dispatch the next task immediately. +- Terminal handles are ephemeral and runtime-scoped. If Orca restarts mid-workflow, all handles go stale. Re-acquire them with `terminal list` before continuing. +- Keep dependency chains to 3-4 steps maximum. Prefer parallel waves of independent tasks over deep sequential chains. +- Insert decision gates (`gate-create`) between phases for human oversight on risky operations. + +## Coordinator Worked Example + +Dispatch a task to a fresh Claude Code terminal and wait for completion: + +```bash +# 1. Create a terminal running Claude Code +orca terminal create --worktree active --title "worker-1" --command "claude" --json +# → handle: term_abc123 + +# 2. Wait for Claude Code to boot (tui-idle fires when the prompt appears) +orca terminal wait --terminal term_abc123 --for tui-idle --timeout-ms 60000 --json + +# 3. Create and dispatch a task with preamble injection +orca orchestration task-create --spec "Fix the login button CSS" --json +# → id: task_def456 +orca orchestration dispatch --task task_def456 --to term_abc123 --inject --json + +# 4. Block until the worker reports back (no sleep loops needed) +orca orchestration check --wait --types worker_done,escalation --timeout-ms 300000 --json +# → returns immediately when worker sends worker_done + +# 5. If --wait timed out with no messages, fall back to idle detection +orca terminal wait --terminal term_abc123 --for tui-idle --timeout-ms 60000 --json +orca terminal read --terminal term_abc123 --json +``` From af284dbac7c28e1da93f75be66dbd81c40e4493f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:06:19 -0700 Subject: [PATCH 008/259] fix(sidebar): let heuristic 'working' beat retained 'done' in worktree dot (#1228) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeCard.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e70f7db3d22..07d93a8d676 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -154,10 +154,16 @@ const WorktreeCard = React.memo(function WorktreeCard({ // consulted too so the sky dot keeps glowing after the agent process exits, // matching the dashboard's retention behavior. // - // Priority (highest first): permission (blocked/waiting) > done > heuristic. - // permission wins over done because a newer blocked agent in the same + // Priority (highest first): permission (blocked/waiting) > heuristic + // 'working' > done > other heuristic ('active'/'inactive'). + // permission wins over everything because a newer blocked agent in the same // worktree means the user needs to act now, not admire a previous // completion. + // heuristic 'working' wins over done because a spinner means the user has + // already re-prompted the agent after it reported done — the newer "work + // in progress" signal is more informative than a retained completion dot. + // Only the 'working' heuristic earns this precedence; 'active'/'inactive' + // mean "quiet terminal", which shouldn't drown out a recent done. // Why: collapse live hook entries to booleans inside the selector so the // snapshot is a stable scalar (useShallow compares element identity — an // array of freshly-constructed {state,updatedAt} objects would never hit @@ -217,10 +223,16 @@ const WorktreeCard = React.memo(function WorktreeCard({ if (hasPermission) { return 'permission' } + // Compute the heuristic once so we can let 'working' beat done without + // letting quieter heuristic states ('active'/'inactive') erase a done. + const heuristic = getWorktreeStatus(tabs, browserTabs, runtimePaneTitlesForWorktree) + if (heuristic === 'working') { + return 'working' + } if (hasLiveDone || hasRetainedDone) { return 'done' } - return getWorktreeStatus(tabs, browserTabs, runtimePaneTitlesForWorktree) + return heuristic }, [tabs, browserTabs, runtimePaneTitlesForWorktree, hasPermission, hasLiveDone, hasRetainedDone]) const showPR = cardProps.includes('pr') From b6e736a5b45e01339996b3d50bd1e91e6b7c02c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:38:34 -0700 Subject: [PATCH 009/259] fix(sidebar): top-align worktree status dot with title (#1231) Co-authored-by: Orca --- src/renderer/src/components/sidebar/WorktreeCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 07d93a8d676..a51c39d65e8 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -367,11 +367,17 @@ const WorktreeCard = React.memo(function WorktreeCard({ {/* Why: make the hover trigger keyboard-focusable so keyboard-only users can open the hover panel (Radix HoverCardTrigger asChild does not promote a - non-interactive child to focusable). */} + non-interactive child to focusable). + Why inline-flex: a default `display: inline` span + contains the inline-flex StatusIndicator on the text + baseline, pushing the dot ~2px down inside the card's + flex-column container. `inline-flex` removes the line + box so the dot stays top-aligned with the title. */} From ae994e70323594ed2996292a8f10bb2a7e34db6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:41:37 -0700 Subject: [PATCH 010/259] feat(settings): gate Agent Dashboard behind experimental setting (#1219) Co-authored-by: Orca --- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/index.ts | 43 +++-- src/main/ipc/app.ts | 13 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 6 +- src/renderer/src/App.tsx | 26 +-- .../dashboard/RetainedAgentsSyncGate.tsx | 4 +- .../components/dashboard/useDashboardData.ts | 22 ++- .../components/dashboard/useRetainedAgents.ts | 34 ++-- .../src/components/right-sidebar/index.tsx | 16 +- .../src/components/settings/AgentsPane.tsx | 3 +- .../components/settings/ExperimentalPane.tsx | 152 +++++++++++++++++- .../settings/experimental-search.ts | 18 +++ .../src/components/sidebar/WorktreeCard.tsx | 6 +- .../src/components/sidebar/WorktreeList.tsx | 9 +- .../components/sidebar/visible-worktrees.ts | 7 +- .../terminal-pane/pty-connection.ts | 18 ++- src/renderer/src/hooks/useIpcEvents.test.ts | 24 ++- src/renderer/src/hooks/useIpcEvents.ts | 77 ++++----- src/shared/agent-hook-types.ts | 10 +- src/shared/constants.ts | 13 +- src/shared/types.ts | 9 ++ 23 files changed, 378 insertions(+), 135 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 6b230d61330..5555b64c2d6 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -91,6 +91,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAltMigrated: true, experimentalTerminalDaemon: false, experimentalTerminalDaemonNoticeShown: false, + experimentalAgentDashboard: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, ...overrides diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index e4db05b45de..ce193feb02e 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -85,6 +85,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAltMigrated: true, experimentalTerminalDaemon: false, experimentalTerminalDaemonNoticeShown: false, + experimentalAgentDashboard: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, ...overrides diff --git a/src/main/index.ts b/src/main/index.ts index 6cbc363dca2..bf9092e5568 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,7 +47,6 @@ import { codexHookService } from './codex/hook-service' import { geminiHookService } from './gemini/hook-service' import { cursorHookService } from './cursor/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener } from './ipc/pty' -import { AGENT_DASHBOARD_ENABLED } from '../shared/constants' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' @@ -204,7 +203,11 @@ function openMainWindow(): BrowserWindow { if (mainWindow?.isDestroyed()) { return } - if (AGENT_DASHBOARD_ENABLED) { + // Why: only forward status events to the renderer when the user has + // opted into the experimental dashboard. Reading the current setting + // here (rather than a module-level snapshot) lets the gate flip live + // for the renderer-side surfaces — the hook server itself always runs. + if (store?.getSettings().experimentalAgentDashboard === true) { mainWindow?.webContents.send('agentStatus:set', { paneKey, tabId, @@ -219,7 +222,7 @@ function openMainWindow(): BrowserWindow { // sidebar spinner, unread badge, and Claude prompt-cache timer for every // other agent) lights up for cursor panes too. Braille prefix ⠋ → working // keyword path; "action required" keyword → permission; bare label → idle. - // This runs regardless of AGENT_DASHBOARD_ENABLED because cursor has no + // This runs regardless of the dashboard setting because cursor has no // pre-dashboard title heuristic to fall back to. if (payload.agentType === 'cursor') { driveCursorPaneFromHook(paneKey, payload.state) @@ -341,14 +344,17 @@ app.whenReady().then(async () => { nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why: managed hook installation mutates user-global agent config. // Startup must fail open so a malformed local config never bricks Orca. - // Claude/Codex/Gemini installs are gated behind AGENT_DASHBOARD_ENABLED - // because the surface they feed (the in-progress agent dashboard) isn't - // shippable yet. Cursor installs unconditionally because cursor-agent - // emits no title-based working/idle signal at all (its terminal title - // stays literally "Cursor Agent" across a turn), so the hook channel is - // the only way to drive the sidebar spinner + unread path for it — there - // is no "pre-dashboard" fallback to degrade to the way Claude/Codex have. - if (AGENT_DASHBOARD_ENABLED) { + // Claude/Codex/Gemini installs are gated behind the experimental + // Agent Dashboard setting because the surface they feed (the in-progress + // agent dashboard) isn't shippable yet. Cursor installs unconditionally + // because cursor-agent emits no title-based working/idle signal at all + // (its terminal title stays literally "Cursor Agent" across a turn), so + // the hook channel is the only way to drive the sidebar spinner + unread + // path for it — there is no "pre-dashboard" fallback to degrade to the + // way Claude/Codex have. Toggling the setting takes effect on next launch + // because the hook scripts are installed once per boot. + const agentDashboardEnabled = store.getSettings().experimentalAgentDashboard === true + if (agentDashboardEnabled) { for (const installManagedHooks of [ () => claudeHookService.install(), () => codexHookService.install(), @@ -422,14 +428,17 @@ app.whenReady().then(async () => { console.error('[daemon] Failed to clean up orphaned daemon:', error) } } - setAppRuntimeFlags({ daemonEnabledAtStartup: daemonStarted }) + setAppRuntimeFlags({ + daemonEnabledAtStartup: daemonStarted, + agentDashboardEnabledAtStartup: agentDashboardEnabled + }) // Why: the hook server runs unconditionally so cursor-agent panes can reach - // it. Claude/Codex/Gemini hook scripts stay uninstalled while - // AGENT_DASHBOARD_ENABLED is false, so only cursor events flow in. PTY - // spawn env reads ORCA_AGENT_HOOK_* from the live server state, so the - // server must start before the window opens — otherwise restored terminals - // race ahead without the env on first launch. + // it. Claude/Codex/Gemini hook scripts stay uninstalled while the + // experimentalAgentDashboard setting is off, so only cursor events flow + // in by default. PTY spawn env reads ORCA_AGENT_HOOK_* from the live + // server state, so the server must start before the window opens — + // otherwise restored terminals race ahead without the env on first launch. try { await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1f6b92c6743..4910184bf23 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -10,6 +10,14 @@ export type AppRuntimeFlags = { * The renderer compares this against the current setting to decide whether * a "restart required" banner needs to be shown on the Experimental pane. */ daemonEnabledAtStartup: boolean + /** Whether the experimental agent dashboard setting was enabled when this + * session booted. When true, Claude/Codex/Gemini managed hook installation + * was attempted at startup (individual install failures are logged but do + * not flip this flag — the dashboard UI itself treats missing hooks as + * no-ops). Toggling the setting only affects hook installation on the next + * launch, so the renderer compares this against the current setting to + * decide whether a "restart required" banner needs to be shown. */ + agentDashboardEnabledAtStartup: boolean } export type DaemonTransitionNotice = { @@ -20,7 +28,10 @@ export type DaemonTransitionNotice = { killedCount: number } -let runtimeFlags: AppRuntimeFlags = { daemonEnabledAtStartup: false } +let runtimeFlags: AppRuntimeFlags = { + daemonEnabledAtStartup: false, + agentDashboardEnabledAtStartup: false +} let pendingDaemonTransitionNotice: DaemonTransitionNotice | null = null export function setAppRuntimeFlags(flags: AppRuntimeFlags): void { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index daedae535ad..43b4c0550c7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -268,6 +268,7 @@ export type CodexUsageApi = { export type AppRuntimeFlags = { daemonEnabledAtStartup: boolean + agentDashboardEnabledAtStartup: boolean } export type DaemonTransitionNotice = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 07f90b263cf..a472eb96e85 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' +import type { AppRuntimeFlags, DaemonTransitionNotice } from './api-types' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { @@ -174,9 +175,8 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { - getRuntimeFlags: (): Promise<{ daemonEnabledAtStartup: boolean }> => - ipcRenderer.invoke('app:getRuntimeFlags'), - consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> => + getRuntimeFlags: (): Promise => ipcRenderer.invoke('app:getRuntimeFlags'), + consumeDaemonTransitionNotice: (): Promise => ipcRenderer.invoke('app:consumeDaemonTransitionNotice'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 19ed5954fb0..4f691466b70 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,10 +1,6 @@ /* eslint-disable max-lines */ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - AGENT_DASHBOARD_ENABLED, - DEFAULT_STATUS_BAR_ITEMS, - DEFAULT_WORKTREE_CARD_PROPERTIES -} from '../../shared/constants' +import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' import { @@ -162,9 +158,10 @@ function App(): React.JSX.Element { // event frequency), re-rendering the entire app tree on every agent status // update. Hosting the subscriptions in a leaf isolates that churn. // - // The AGENT_DASHBOARD_ENABLED gate still lives inside the hooks themselves - // (useDashboardData early-returns [] from its memo; useRetainedAgentsSync - // early-returns from its effect), so the gate component is cheap when off. + // The experimentalAgentDashboard gate still lives inside the hooks + // themselves (useDashboardData early-returns [] from its memo; + // useRetainedAgentsSync early-returns from its effect), so the gate + // component is cheap when the setting is off. // Why: git conflict-operation state also drives the worktree cards. Polling // cannot live under RightSidebar because App unmounts that subtree when the // sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind @@ -722,7 +719,18 @@ function App(): React.JSX.Element { // mechanism (same pattern as Cmd+Shift+G above). xterm-helper-textarea // is the focus target the terminal handler itself uses to detect // terminal focus (see keyboard-handlers.ts isEditableTarget). - if (AGENT_DASHBOARD_ENABLED && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { + if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { + // Why: read the experimental toggle at keypress time via getState() so + // flipping the setting takes effect immediately without re-binding the + // window-level listener (which would require adding `settings` to the + // effect deps and tearing down/rebinding on every unrelated settings + // change, like theme or font adjustments). Gated behind the key/modifier + // check so getState() isn't invoked on every unrelated keystroke. + const dashboardExperimentEnabled = + useAppStore.getState().settings?.experimentalAgentDashboard === true + if (!dashboardExperimentEnabled) { + return + } const active = document.activeElement as HTMLElement | null if (active?.classList.contains('xterm-helper-textarea')) { return diff --git a/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx b/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx index 4fd6fed3b35..ec05d9755b2 100644 --- a/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx +++ b/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx @@ -8,8 +8,8 @@ import { useRetainedAgentsSync } from './useRetainedAgents' // level — if it only ran when the dashboard is mounted, "done" agents would // vanish from the sidebar hovercard whenever the panel is collapsed. // -// The hooks inside still early-return when AGENT_DASHBOARD_ENABLED is false, -// so this gate is cheap when the feature is off. +// The hooks inside still early-return when the experimentalAgentDashboard +// setting is off, so this gate is cheap when the feature is disabled. export default function RetainedAgentsSyncGate(): null { const dashboardLiveGroups = useDashboardData() useRetainedAgentsSync(dashboardLiveGroups) diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 07763a16144..18c1660eef4 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -7,7 +7,6 @@ import { type AgentStatusState, type AgentType } from '../../../../shared/agent-status-types' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import type { Repo, Worktree, TerminalTab } from '../../../../shared/types' // ─── Dashboard data types ───────────────────────────────────────────────────── @@ -194,6 +193,7 @@ export function useDashboardData(): DashboardRepoGroup[] { // computation itself) so the memo recomputes when freshness boundaries expire, // even if no new PTY data arrives. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const dashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) return useMemo( // Why: Date.now() is read inside the memo (not as a dep) so stale-decay @@ -201,11 +201,12 @@ export function useDashboardData(): DashboardRepoGroup[] { // freshness boundary crosses, driving re-evaluation without coupling to // wall-clock time directly. () => { - // Why: feature flag gate inside the memo avoids the O(repos × worktrees × - // agents) rebuild on every store update when the dashboard is disabled. - // Store selectors still subscribe to keep rules-of-hooks satisfied even - // if the flag becomes runtime-dynamic. - if (!AGENT_DASHBOARD_ENABLED) { + // Why: experimental-setting gate inside the memo avoids the + // O(repos × worktrees × agents) rebuild on every store update when the + // dashboard is disabled. Store selectors still subscribe to keep + // rules-of-hooks satisfied and so flipping the setting re-renders + // consumers. + if (!dashboardEnabled) { return [] } return buildDashboardData( @@ -217,6 +218,13 @@ export function useDashboardData(): DashboardRepoGroup[] { ) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [repos, worktreesByRepo, tabsByWorktree, agentStatusByPaneKey, agentStatusEpoch] + [ + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + agentStatusEpoch, + dashboardEnabled + ] ) } diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 1890f0741a4..631a0f23b9b 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -7,7 +7,6 @@ import { type DashboardWorktreeCard } from './useDashboardData' import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from useDashboardData. Retaining @@ -20,18 +19,29 @@ export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void { const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) + const dashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) const prevAgentsRef = useRef>( new Map() ) useEffect(() => { - // Why: the feature-flag gate lives inside the effect (not around the hook - // declarations above) so rules-of-hooks stays satisfied — the store - // selectors and useRef must always run. When the dashboard is disabled, - // skip all retention work to avoid touching the store for a feature the - // user cannot see. Keeping this check here (rather than in App.tsx) makes - // the hook self-contained and safe to call unconditionally from any site. - if (!AGENT_DASHBOARD_ENABLED) { + // Why: the experimental-setting gate lives inside the effect (not around + // the hook declarations above) so rules-of-hooks stays satisfied — the + // store selectors and useRef must always run. When the dashboard is + // disabled, skip all retention work to avoid touching the store for a + // feature the user cannot see. Keeping this check here (rather than in + // App.tsx) makes the hook self-contained and safe to call unconditionally + // from any site. + if (!dashboardEnabled) { + // Why: reset the previous-agents snapshot while the flag is off so a + // later off->on toggle (same session, no restart) does not resurrect + // pre-disable state. Without this, the first post-re-enable run would + // diff current liveGroups against a stale map captured before the flag + // flipped off, and collectRetainedAgentsOnDisappear could retroactively + // retain agents that finished / had their paneKeys reused during the + // off window — an "agent disappeared while the dashboard was hidden" + // case must NOT produce a retained row on re-enable. + prevAgentsRef.current = new Map() return } const current = new Map() @@ -73,7 +83,13 @@ export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void { if (consumedSuppressedPaneKeys.length > 0) { clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys) } - }, [liveGroups, retainAgents, pruneRetainedAgents, clearRetentionSuppressedPaneKeys]) + }, [ + liveGroups, + retainAgents, + pruneRetainedAgents, + clearRetentionSuppressedPaneKeys, + dashboardEnabled + ]) } export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): { diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 94bb9a1ecf3..d6f4b64c725 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -6,7 +6,6 @@ import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor' import type { CheckStatus } from '../../../../shared/types' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import { isFolderRepo } from '../../../../shared/repo-kind' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' @@ -125,12 +124,15 @@ function RightSidebarInner(): React.JSX.Element { const checksStatus = useAppStore(getActiveChecksStatus) const activityBarPosition = useAppStore((s) => s.activityBarPosition) const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) - // Why: the bottom-docked agent dashboard is opt-out via Settings → Agents. - // Users who prefer a quieter sidebar can hide the panel without losing any - // in-terminal agent status — the per-tab status indicators remain. While - // settings are still loading, render the panel so it doesn't flash in once - // settings arrive. + // Why: the bottom-docked agent dashboard is opt-out via Settings → Agents, + // AND gated behind the experimental opt-in setting. Users who prefer a + // quieter sidebar can hide the panel without losing any in-terminal agent + // status — the per-tab status indicators remain. While settings are still + // loading, render the panel so it doesn't flash in once settings arrive. const showAgentDashboard = useAppStore((s) => s.settings?.showAgentDashboard !== false) + const dashboardExperimentEnabled = useAppStore( + (s) => s.settings?.experimentalAgentDashboard === true + ) // Why: source control and checks are meaningless for non-git folders. // Hide those tabs so the activity bar only shows relevant actions. @@ -254,7 +256,7 @@ function RightSidebarInner(): React.JSX.Element { {effectiveTab === 'checks' && } {effectiveTab === 'ports' && } - {AGENT_DASHBOARD_ENABLED && showAgentDashboard && } + {dashboardExperimentEnabled && showAgentDashboard && } ) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index aa2b76e7e9e..843c586e39d 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { Button } from '../ui/button' @@ -256,7 +255,7 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React return (
{/* Dashboard visibility */} - {AGENT_DASHBOARD_ENABLED && ( + {settings.experimentalAgentDashboard === true && (
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 61c4c894a63..c0545eb7eb6 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Copy, RotateCw } from 'lucide-react' import { toast } from 'sonner' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings, TuiAgent } from '../../../../shared/types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' @@ -9,6 +9,31 @@ import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search' +import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' + +// Why: agents with a per-agent hook-service module under src/main that posts +// status to the shared agent-hooks server. Keep this list in sync with the +// hook-service.ts files — any agent without one will not light up the +// dashboard even when the experimental setting is on. +const AGENT_DASHBOARD_SUPPORTED_AGENTS: readonly TuiAgent[] = [ + 'claude', + 'codex', + 'gemini', + 'cursor', + 'opencode' +] as const + +// Why: both AGENT_DASHBOARD_SUPPORTED_AGENTS and AGENT_CATALOG are static +// module-level constants, so the resolved {id, label} pairs never change at +// runtime. Computing this inside SupportedAgentsDisclaimer was O(N×M) work on +// every parent re-render — notably on every keystroke in the settings search — +// for a list that can only change at build time. Hoisting it makes the cost +// a one-time module-load expense. +const SUPPORTED_AGENT_ENTRIES: readonly { id: TuiAgent; label: string }[] = + AGENT_DASHBOARD_SUPPORTED_AGENTS.map((id) => { + const entry = AGENT_CATALOG.find((a) => a.id === id) + return { id, label: entry?.label ?? id } + }) export { EXPERIMENTAL_PANE_SEARCH_ENTRIES } @@ -29,11 +54,14 @@ export function ExperimentalPane({ hiddenExperimentalUnlocked = false }: ExperimentalPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) - // Why: "daemon enabled at startup" is the effective runtime state, read - // directly from main once on mount. The banner compares the user's current + // Why: the "enabled at startup" flags are the effective runtime state, read + // directly from main once on mount. Each banner compares the user's current // setting against this snapshot to tell them a restart is still required. // null = not yet fetched (banner stays hidden to avoid a flash). const [daemonEnabledAtStartup, setDaemonEnabledAtStartup] = useState(null) + const [agentDashboardEnabledAtStartup, setAgentDashboardEnabledAtStartup] = useState< + boolean | null + >(null) const [relaunching, setRelaunching] = useState(false) useEffect(() => { @@ -43,6 +71,7 @@ export function ExperimentalPane({ .then((flags) => { if (!cancelled) { setDaemonEnabledAtStartup(flags.daemonEnabledAtStartup) + setAgentDashboardEnabledAtStartup(flags.agentDashboardEnabledAtStartup) } }) .catch(() => { @@ -54,9 +83,12 @@ export function ExperimentalPane({ }, []) const showDaemon = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]]) - const showOrchestration = matchesSettingsSearch(searchQuery, [ + const showAgentDashboard = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_PANE_SEARCH_ENTRIES[1] ]) + const showOrchestration = matchesSettingsSearch(searchQuery, [ + EXPERIMENTAL_PANE_SEARCH_ENTRIES[2] + ]) const [orchestrationEnabled, setOrchestrationEnabled] = useState(() => { return localStorage.getItem('orca.orchestration.enabled') === '1' @@ -85,9 +117,12 @@ export function ExperimentalPane({ } } - const pendingRestart = + const pendingDaemonRestart = daemonEnabledAtStartup !== null && settings.experimentalTerminalDaemon !== daemonEnabledAtStartup + const pendingAgentDashboardRestart = + agentDashboardEnabledAtStartup !== null && + settings.experimentalAgentDashboard !== agentDashboardEnabledAtStartup const handleRelaunch = async (): Promise => { if (relaunching) { @@ -148,7 +183,7 @@ export function ExperimentalPane({
- {pendingRestart ? ( + {pendingDaemonRestart ? (

@@ -175,11 +210,91 @@ export function ExperimentalPane({ ) : null} + {showAgentDashboard ? ( + +

+
+ +

+ Adds a cross-worktree dashboard and hover cards showing each agent's live + status. Requires an app restart + {settings.experimentalTerminalDaemon + ? ', and tracks agents started in new terminals opened after the restart' + : ''} + . +

+ +
+ +
+ + {pendingAgentDashboardRestart ? ( +
+
+

+ Restart required +

+

+ {settings.experimentalAgentDashboard + ? 'Restart Orca to finish enabling the agent dashboard.' + : 'Restart Orca to finish disabling the agent dashboard.'} +

+
+ +
+ ) : null} + + ) : null} + {showOrchestration ? (
@@ -261,6 +376,29 @@ export function ExperimentalPane({ ) } +function SupportedAgentsDisclaimer(): React.JSX.Element { + return ( +
+
+ Supported agents: + {SUPPORTED_AGENT_ENTRIES.map(({ id, label }) => ( + + + {label} + + ))} +
+

+ We're currently working on support for more agent CLIs. +

+
+ ) +} + // Why: anything in this group is deliberately unfinished or staff-only. The // orange treatment (header tint, label colors) is the shared visual signal // for hidden-experimental items so future entries inherit the same diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 5f44bae1712..dd5f6f67153 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -17,6 +17,24 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'reattach' ] }, + { + title: 'Agent dashboard', + description: + 'Live cross-worktree view of agent activity, plus retention of finished runs in the sidebar hover. Experimental — managed hook installs require an app restart.', + keywords: [ + 'experimental', + 'agent', + 'dashboard', + 'status', + 'activity', + 'worktree', + 'hook', + 'claude', + 'codex', + 'gemini', + 'sidebar' + ] + }, { title: 'Agent Orchestration', description: diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a51c39d65e8..2bd6ba18b00 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -14,7 +14,6 @@ import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import type { Worktree, Repo, PRInfo, IssueInfo } from '../../../../shared/types' @@ -49,6 +48,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) const fetchIssue = useAppStore((s) => s.fetchIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) + const dashboardExperimentEnabled = useAppStore( + (s) => s.settings?.experimentalAgentDashboard === true + ) const handleEditIssue = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -362,7 +364,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ {(cardProps.includes('status') || cardProps.includes('unread')) && (
{cardProps.includes('status') && - (AGENT_DASHBOARD_ENABLED ? ( + (dashboardExperimentEnabled ? ( {/* Why: make the hover trigger keyboard-focusable so keyboard-only users can open the hover panel (Radix diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index fe9b5430c4f..27a32359112 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -15,7 +15,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import type { Worktree, Repo } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import { buildExplicitEntriesByTabId, buildWorktreeComparator, @@ -566,7 +565,13 @@ const WorktreeList = React.memo(function WorktreeList() { // Combined: O(E) index + O(N×T) scoring + O(N log N) sort, instead of // O(N × E × T) per sortEpoch bump. Only smart mode uses the score map; // other modes ignore it. - const agentStatusForSort = AGENT_DASHBOARD_ENABLED ? state.agentStatusByPaneKey : undefined + // Why: smart-sort only weighs live agent status when the experimental + // Agent Dashboard is opted in — that's the surface that populates + // agentStatusByPaneKey via hooks. With the setting off, pass undefined + // so the comparator falls back to the persisted-sortOrder + title + // heuristics instead of scoring against an empty map. + const agentStatusForSort = + state.settings?.experimentalAgentDashboard === true ? state.agentStatusByPaneKey : undefined const explicitByTabId = sortBy === 'smart' ? buildExplicitEntriesByTabId(agentStatusForSort) : undefined const precomputedScores = diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 145dddc986a..5350f145298 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,6 +1,5 @@ import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' import type { AppState } from '@/store/types' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import { matchesSearch } from './worktree-list-groups' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { useAppStore } from '@/store' @@ -120,7 +119,11 @@ export function getVisibleWorktreeIds(): string[] { let sortedIds: string[] - const agentStatusForSort = AGENT_DASHBOARD_ENABLED ? state.agentStatusByPaneKey : undefined + // Why: matches WorktreeList's gate — when the experimental Agent Dashboard + // is off, the agent-status map is not populated, so fall back to the + // non-status sort heuristics instead of scoring against an empty map. + const agentStatusForSort = + state.settings?.experimentalAgentDashboard === true ? state.agentStatusByPaneKey : undefined if (state.sortBy === 'smart') { sortedIds = sortWorktreesSmart( allWorktrees, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5cf362e60e0..b106c5be993 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5,7 +5,6 @@ import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { toast } from 'sonner' -import { AGENT_DASHBOARD_ENABLED } from '../../../../shared/constants' import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' @@ -306,17 +305,20 @@ export function connectPanePty( // Without this, the OSC parser in pty-transport strips sequences from xterm // output but the status never reaches the store or dashboard/hover UI. onAgentStatus: (payload) => { - if (!AGENT_DASHBOARD_ENABLED) { - return - } // Why: capture the store snapshot once so the title lookup and the // setAgentStatus call observe the same state. Re-reading getState() // between the two lines opens a brief window where the title could // shift (OSC title update landing in between) and the status would be - // stored against a title that was never paired with it. - const state = useAppStore.getState() - const title = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - state.setAgentStatus(cacheKey, payload, title) + // stored against a title that was never paired with it. The same + // snapshot also gates on the experimental dashboard setting — without + // the opt-in, OSC 9999 status payloads are dropped before they reach + // the store. + const currentState = useAppStore.getState() + if (currentState.settings?.experimentalAgentDashboard !== true) { + return + } + const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + currentState.setAgentStatus(cacheKey, payload, title) } }) const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0 diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 558c919fa9f..eb8e760c1e1 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -209,7 +209,8 @@ describe('useIpcEvents updater integration', () => { credentialResolvedListenerRef.current = listener return () => {} } - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -392,7 +393,8 @@ describe('useIpcEvents updater integration', () => { onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -573,7 +575,8 @@ describe('useIpcEvents updater integration', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -769,7 +772,8 @@ describe('useIpcEvents browser tab close routing', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -950,7 +954,8 @@ describe('useIpcEvents browser tab close routing', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -1126,7 +1131,8 @@ describe('useIpcEvents browser tab close routing', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -1311,7 +1317,8 @@ describe('useIpcEvents shortcut hint clearing', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) @@ -1503,7 +1510,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} - } + }, + agentStatus: { onSet: () => () => {} } } }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 0414b3f088f..96a1827ac6d 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -16,7 +16,6 @@ import { resolveZoomTarget } from './resolve-zoom-target' import { handleSwitchTab, handleSwitchTerminalTab } from './ipc-tab-switch' import { dispatchClearModifierHints } from './useModifierHint' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' -import { AGENT_DASHBOARD_ENABLED } from '../../../shared/constants' import { isGitRepoKind } from '../../../shared/repo-kind' export { resolveZoomTarget } from './resolve-zoom-target' @@ -706,43 +705,47 @@ export function useIpcEvents(): void { // Why: agent status arrives from native hook receivers in the main process. // Re-parse it here so the renderer enforces the same normalization rules // (state enum, field truncation) regardless of whether the source was a - // hook callback or an OSC fallback path. - if (AGENT_DASHBOARD_ENABLED) { - unsubs.push( - window.api.agentStatus.onSet((data) => { - // Why: the IPC payload is already a structured object — pass it - // straight to the object-input normalizer instead of round-tripping - // through JSON.stringify/JSON.parse. Hook events can fire many times - // per second during a tool-use run, so this avoids the per-event - // serialization cost. - const payload = normalizeAgentStatusPayload({ - state: data.state, - prompt: data.prompt, - agentType: data.agentType, - toolName: data.toolName, - toolInput: data.toolInput, - lastAssistantMessage: data.lastAssistantMessage, - interrupted: data.interrupted - }) - if (!payload) { - return - } - const store = useAppStore.getState() - // Why: resolve tab existence and terminal title in a single pass. - // Previously the code walked store.tabsByWorktree twice — once to - // look up the tab-level title (when the pane-level title was - // missing) and again for the explicit tabExists check. A paneKey - // that no longer resolves to a live tab belongs to a pane that has - // already been torn down; dropping here prevents orphan entries - // from accumulating in agentStatusByPaneKey. - const { exists, title } = resolvePaneKey(store, data.paneKey) - if (!exists) { - return - } - store.setAgentStatus(data.paneKey, payload, title) + // hook callback or an OSC fallback path. The subscription itself is + // unconditional so flipping the experimental dashboard setting takes + // effect without re-running this App-mount effect; the per-event guard + // inside the handler drops payloads when the setting is off. + unsubs.push( + window.api.agentStatus.onSet((data) => { + const store = useAppStore.getState() + if (store.settings?.experimentalAgentDashboard !== true) { + return + } + // Why: the IPC payload is already a structured object — pass it + // straight to the object-input normalizer instead of round-tripping + // through JSON.stringify/JSON.parse. Hook events can fire many times + // per second during a tool-use run, so this avoids the per-event + // serialization cost. + const payload = normalizeAgentStatusPayload({ + state: data.state, + prompt: data.prompt, + agentType: data.agentType, + toolName: data.toolName, + toolInput: data.toolInput, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted }) - ) - } + if (!payload) { + return + } + // Why: resolve tab existence and terminal title in a single pass. + // Previously the code walked store.tabsByWorktree twice — once to + // look up the tab-level title (when the pane-level title was + // missing) and again for the explicit tabExists check. A paneKey + // that no longer resolves to a live tab belongs to a pane that has + // already been torn down; dropping here prevents orphan entries + // from accumulating in agentStatusByPaneKey. + const { exists, title } = resolvePaneKey(store, data.paneKey) + if (!exists) { + return + } + store.setAgentStatus(data.paneKey, payload, title) + }) + ) return () => unsubs.forEach((fn) => fn()) }, []) diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 1b87f153247..872bea8a8a6 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -21,8 +21,10 @@ export type AgentHookInstallStatus = { // stale script installed by an older app build is diagnosable instead of // silently producing partial payloads. Still at v1 because the endpoint-file // rollout is additive — pre-endpoint-file scripts still post the same JSON -// body shape, and no caller was ever shipped on v2 (the Claude/Codex/Gemini -// install path has been gated behind AGENT_DASHBOARD_ENABLED=false, and the -// Cursor/OpenCode scripts reroll on every Orca launch so no in-wild fleet -// exists to distinguish from). Reserve the next bump for a real wire change. +// body shape, and no in-wild v1 script exists that a future v2 receiver would +// need to distinguish from: Claude/Codex/Gemini install is gated behind the +// experimentalAgentDashboard setting (off by default, so no shipped fleet), +// and Cursor's managed script is rewritten on every install() call so there +// is no durable on-disk v1 script to inherit. Reserve the next bump for a +// real wire change. export const ORCA_HOOK_PROTOCOL_VERSION = '1' as const diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 134c4b01ba4..5b580ea9cff 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -12,13 +12,6 @@ import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' export const SCHEMA_VERSION = 1 -// Why: temporary compile-time gate for the agent status dashboard feature. -// Flip to `true` only when every dashboard PR has landed; the follow-up cleanup -// PR will delete this constant and every `if (!AGENT_DASHBOARD_ENABLED)` branch -// entirely, making the feature permanent. Not user-facing — do not read from -// settings, env, or IPC. -export const AGENT_DASHBOARD_ENABLED = false - export const ORCA_BROWSER_PARTITION = 'persist:orca-browser' // Why: blank browser tabs must start from an inert guest URL that does not // navigate the privileged main window to about:blank. Renderer and main both @@ -187,7 +180,11 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalMacOptionAsAlt: 'auto', terminalMacOptionAsAltMigrated: false, experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false + experimentalTerminalDaemonNoticeShown: false, + // Why: opt-in preview — default off so managed-hook installation + // (Claude/Codex/Gemini) stays dormant for existing users and upgraders + // (persistence.ts merges defaults first, so upgraders inherit this). + experimentalAgentDashboard: false } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7f66f2c7b8e..d5ff5d01271 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1016,6 +1016,15 @@ export type GlobalSettings = { * toast shown to users upgrading from v1.3.0 (where the daemon was on by * default). Set to true the first time the toast fires so it never repeats. */ experimentalTerminalDaemonNoticeShown: boolean + /** Experimental: live Agent Dashboard — a bottom-docked right-sidebar panel + * that aggregates working/blocked/done agents across all worktrees, plus + * the sidebar AgentStatusHover surface, retention of "done" rows, and the + * hook-driven status slice that feeds them. Opt-in because the surface is + * still in preview: managed hook installation (Claude/Codex/Gemini) only + * runs when this is true, so toggling it on takes effect on the next app + * launch. The in-pane status indicators and the cursor-agent hook path are + * unaffected by this toggle. */ + experimentalAgentDashboard: boolean } export type GhosttyImportPreview = { From 869d65d09179c96cb5560a9081db7572cd4f1e78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:46:06 +0000 Subject: [PATCH 011/259] release: v1.3.24-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 70960a91471..fcebbe60751 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.24-rc.0", + "version": "1.3.24-rc.2", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 3d3815fe2c42f166860430ae4698faea7fc4d0dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:16:20 +0000 Subject: [PATCH 012/259] release: 1.3.24-rc.3 [rc-slot:2026-04-28-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fcebbe60751..4ae36ad61e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.24-rc.2", + "version": "1.3.24-rc.3", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 25a0cb5444dc7b215d4f5b1d99d80000b1a8266f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:49:15 -0700 Subject: [PATCH 013/259] fix(terminal): refresh WebGL canvas after split reparenting (#1230) Co-authored-by: Orca --- .../src/lib/pane-manager/pane-split-scroll.ts | 17 +++++++++++ .../src/lib/pane-manager/pane-tree-ops.ts | 29 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts index 80b8f8d2a20..f33637845d5 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts @@ -1,6 +1,21 @@ import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { restoreScrollState } from './pane-scroll' +// Why: wrapInSplit reparents the existing pane's container, which briefly +// detaches the WebGL canvas from the DOM. The WebGL renderer's internal +// render state can become stale after the re-attachment, leaving the canvas +// blank even though the terminal buffer has data. This mirrors the explicit +// refresh in resumeRendering() (pane-manager.ts) and the onContextLoss +// handler (pane-lifecycle.ts) which address the same "frozen terminal" +// symptom for analogous WebGL state transitions. +function refreshAfterReparent(pane: ManagedPaneInternal): void { + try { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } catch { + /* ignore — pane may have been disposed */ + } +} + // Why: reparenting a terminal container during split resets the viewport // scroll position (browser clears scrollTop on DOM move). This schedules a // two-phase restore: an early double-rAF (~32ms) to minimise the visible @@ -19,6 +34,7 @@ export function scheduleSplitScrollRestore( const live = getPaneById(paneId) if (live?.pendingSplitScrollState) { restoreScrollState(live.terminal, scrollState) + refreshAfterReparent(live) } }) }) @@ -33,5 +49,6 @@ export function scheduleSplitScrollRestore( } live.pendingSplitScrollState = null restoreScrollState(live.terminal, scrollState) + refreshAfterReparent(live) }, 200) } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 1a7a6194fb9..3187fecd43b 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -40,6 +40,20 @@ export function safeFit(pane: ManagedPaneInternal): void { // Why: divider drags fire refits every frame, but most frames do not // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() // churn, which was causing visible terminal blinking while resizing. + // + // Why: diagnostic for intermittent dead-terminal-after-split. If a + // just-reparented pane's proposed dimensions match its current + // dimensions (the default 80×24 at certain screen widths), this + // early-return skips fitAddon.fit() and no terminal.resize() fires + // — leaving the WebGL canvas at stale dimensions. + if (pane.pendingSplitScrollState) { + console.warn( + '[terminal] safeFit early-return during pending split for pane', + pane.id, + `— dims ${dims.cols}×${dims.rows} match current, webgl:`, + !!pane.webglAddon + ) + } return } pane.fitAddon.fit() @@ -174,10 +188,23 @@ export function insertPaneNextTo( split.appendChild(source.container) } - // Refit both + // Refit both and refresh rendering surfaces — both panes were reparented + // into the new split wrapper, which can leave the WebGL canvas in a stale + // state (same mechanism as wrapInSplit; see refreshAfterReparent in + // pane-split-scroll.ts). requestAnimationFrame(() => { callbacks.safeFit(source) callbacks.safeFit(target) + try { + source.terminal.refresh(0, source.terminal.rows - 1) + } catch { + /* ignore */ + } + try { + target.terminal.refresh(0, target.terminal.rows - 1) + } catch { + /* ignore */ + } }) } From 45d33bf744c9f1564d2c4036b37e11e0fd030807 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:05:33 -0700 Subject: [PATCH 014/259] feat(sidebar): inline Connect for disconnected SSH targets in remote project dialog (#1234) Previously the "Open remote project" dialog showed disconnected SSH targets as disabled rows with a "Not connected" label, forcing users out to Settings to bring them up. Now each disconnected row has an inline Connect button, and the row state updates live via the ssh:state-changed listener so the user can proceed without reopening the dialog. Co-authored-by: Orca --- .../src/components/sidebar/AddRepoDialog.tsx | 4 +- .../src/components/sidebar/AddRepoSteps.tsx | 70 +++++++------- .../src/components/sidebar/SshTargetRow.tsx | 95 +++++++++++++++++++ 3 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/sidebar/SshTargetRow.tsx diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 425e1fd4dd5..7a83f6ec27c 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -52,7 +52,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setRemoteError, resetRemoteState, handleOpenRemoteStep, - handleAddRemoteRepo + handleAddRemoteRepo, + handleConnectTarget } = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal) useEffect(() => { if (!isCloning) { @@ -321,6 +322,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) openSettingsPage() }} + onConnectTarget={handleConnectTarget} /> ) : step === 'clone' ? ( { + const unsubscribe = window.api.ssh.onStateChanged(({ targetId, state }) => { + setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t))) + if (state.status === 'connected') { + setSelectedTargetId((curr) => curr ?? targetId) + } + }) + return unsubscribe + }, []) + + const handleConnectTarget = useCallback(async (targetId: string) => { + try { + await window.api.ssh.connect({ targetId }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Connection failed') + } + }, []) + const handleAddRemoteRepo = useCallback(async () => { if (!selectedTargetId || !remotePath.trim()) { return @@ -133,7 +155,8 @@ export function useRemoteRepo( setRemoteError, resetRemoteState, handleOpenRemoteStep, - handleAddRemoteRepo + handleAddRemoteRepo, + handleConnectTarget } } @@ -149,6 +172,7 @@ type RemoteStepProps = { onRemotePathChange: (value: string) => void onAdd: () => void onOpenSshSettings: () => void + onConnectTarget: (id: string) => Promise } export function RemoteStep({ @@ -160,7 +184,8 @@ export function RemoteStep({ onSelectTarget, onRemotePathChange, onAdd, - onOpenSshSettings + onOpenSshSettings, + onConnectTarget }: RemoteStepProps): React.JSX.Element { const [browsing, setBrowsing] = useState(false) @@ -213,36 +238,15 @@ export function RemoteStep({
) : (
- {sshTargets.map((target) => { - const isConnected = target.state?.status === 'connected' - const isSelected = selectedTargetId === target.id - return ( - - ) - })} + {sshTargets.map((target) => ( + + ))}
)}
diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx new file mode 100644 index 00000000000..35a67303bc1 --- /dev/null +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -0,0 +1,95 @@ +/** + * Row used in the "Open remote project" step to pick an SSH target. + * + * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit + * while isolating the inline-connect interaction logic. + */ +import React, { useState } from 'react' +import { Loader2 } from 'lucide-react' +import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' + +type Props = { + target: SshTarget & { state?: SshConnectionState } + isSelected: boolean + onSelect: (id: string) => void + onConnect: (id: string) => Promise +} + +export function SshTargetRow({ + target, + isSelected, + onSelect, + onConnect +}: Props): React.JSX.Element { + const [connecting, setConnecting] = useState(false) + const status = target.state?.status ?? 'disconnected' + const isConnected = status === 'connected' + const isBusy = + connecting || + status === 'connecting' || + status === 'deploying-relay' || + status === 'reconnecting' + const dotColor = isConnected + ? 'bg-green-500' + : isBusy + ? 'bg-yellow-500' + : 'bg-muted-foreground/30' + + const handleRowClick = (): void => { + if (isConnected) { + onSelect(target.id) + } + } + + const handleConnectClick = (e: React.MouseEvent): void => { + // Why: prevent the row's onClick from also firing and treating the click + // as a selection when the target is disconnected. + e.stopPropagation() + if (isBusy) { + return + } + setConnecting(true) + void onConnect(target.id).finally(() => setConnecting(false)) + } + + return ( +
{ + if (isConnected && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onSelect(target.id) + } + }} + > + + + {target.label || `${target.username}@${target.host}`} + + {!isConnected && ( + // Why: inline Connect avoids forcing the user out to Settings just to + // bring up a configured target. + + )} +
+ ) +} From c25b61c558c50d2481b08759c383cacb490f65fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:18:22 -0700 Subject: [PATCH 015/259] fix(mac): request media permissions dynamically (#1233) --- config/electron-builder.config.cjs | 13 + resources/build/entitlements.mac.plist | 12 + src/main/ipc/developer-permissions.ts | 200 ++++++++++++++ src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + .../attach-main-window-services.test.ts | 173 +++++++----- .../window/attach-main-window-services.ts | 69 ++++- src/preload/api-types.ts | 10 + src/preload/index.ts | 8 + .../settings/DeveloperPermissionsPane.tsx | 259 ++++++++++++++++++ .../src/components/settings/Settings.tsx | 30 +- .../developer-permission-hints.test.ts | 22 ++ .../developer-permission-hints.ts | 64 +++++ .../terminal-pane/pty-connection.ts | 36 +++ src/renderer/src/store/slices/ui.ts | 1 + src/shared/developer-permissions-types.ts | 30 ++ 16 files changed, 859 insertions(+), 78 deletions(-) create mode 100644 src/main/ipc/developer-permissions.ts create mode 100644 src/renderer/src/components/settings/DeveloperPermissionsPane.tsx create mode 100644 src/renderer/src/components/terminal-pane/developer-permission-hints.test.ts create mode 100644 src/renderer/src/components/terminal-pane/developer-permission-hints.ts create mode 100644 src/shared/developer-permissions-types.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index c12f49b9261..17b3d51101b 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -81,8 +81,21 @@ module.exports = { entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', extendInfo: { + NSAppleEventsUsageDescription: + 'Orca allows terminal-launched developer tools to automate local apps when you request it.', + NSBluetoothAlwaysUsageDescription: + 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', + NSBluetoothPeripheralUsageDescription: + 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', NSCameraUsageDescription: "Application requests access to the device's camera.", + NSLocationUsageDescription: + 'Orca allows terminal-launched developer tools to access location when you request it.', + NSLocalNetworkUsageDescription: + 'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.', NSMicrophoneUsageDescription: "Application requests access to the device's microphone.", + NSAudioCaptureUsageDescription: + 'Orca allows terminal-launched developer tools to capture desktop audio when you request it.', + NSBonjourServices: ['_http._tcp', '_https._tcp'], NSDocumentsFolderUsageDescription: "Application requests access to the user's Documents folder.", NSDownloadsFolderUsageDescription: diff --git a/resources/build/entitlements.mac.plist b/resources/build/entitlements.mac.plist index bbe60166c73..f43bcc2705d 100644 --- a/resources/build/entitlements.mac.plist +++ b/resources/build/entitlements.mac.plist @@ -2,6 +2,18 @@ + com.apple.security.automation.apple-events + + com.apple.security.device.audio-input + + com.apple.security.device.bluetooth + + com.apple.security.device.camera + + com.apple.security.device.usb + + com.apple.security.personal-information.location + com.apple.security.cs.allow-dyld-environment-variables com.apple.security.cs.allow-jit diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts new file mode 100644 index 00000000000..60b4ea887d2 --- /dev/null +++ b/src/main/ipc/developer-permissions.ts @@ -0,0 +1,200 @@ +import { execFile } from 'node:child_process' +import dgram from 'node:dgram' +import { access } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { ipcMain, shell, systemPreferences } from 'electron' +import type { + DeveloperPermissionId, + DeveloperPermissionRequestResult, + DeveloperPermissionState, + DeveloperPermissionStatus +} from '../../shared/developer-permissions-types' + +const PRIVACY_PANE_URLS: Partial> = { + camera: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera', + microphone: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', + accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + 'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', + automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation', + bluetooth: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth' +} + +const DEVELOPER_PERMISSION_IDS: DeveloperPermissionId[] = [ + 'microphone', + 'camera', + 'screen', + 'accessibility', + 'full-disk-access', + 'automation', + 'local-network', + 'usb', + 'bluetooth' +] + +function unsupportedOffMac(): DeveloperPermissionStatus | null { + return process.platform === 'darwin' ? null : 'unsupported' +} + +function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): DeveloperPermissionStatus { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + try { + return systemPreferences.getMediaAccessStatus(mediaType) + } catch { + return 'unknown' + } +} + +async function getFullDiskAccessStatus(): Promise { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + try { + // Why: Safari bookmarks are TCC-protected, so read access is a practical + // Full Disk Access signal without touching user project contents. + await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) + return 'granted' + } catch { + return 'unknown' + } +} + +function getAccessibilityStatus(): DeveloperPermissionStatus { + const unsupported = unsupportedOffMac() + if (unsupported) { + return unsupported + } + return systemPreferences.isTrustedAccessibilityClient(false) ? 'granted' : 'unknown' +} + +async function openPrivacyPane(id: DeveloperPermissionId): Promise { + const url = PRIVACY_PANE_URLS[id] + if (!url) { + await shell.openExternal( + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension' + ) + return true + } + await shell.openExternal(url) + return true +} + +function triggerAppleEventsPrompt(): Promise { + return new Promise((resolve) => { + execFile( + 'osascript', + ['-e', 'tell application "System Events" to return 1'], + { timeout: 3000 }, + () => resolve() + ) + }) +} + +function triggerLocalNetworkPrompt(): Promise { + return new Promise((resolve) => { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + try { + socket.close() + } catch { + // Already closed or never fully bound. + } + resolve() + } + socket.on('error', finish) + socket.bind(() => { + const message = Buffer.from([0]) + socket.send(message, 0, message.length, 5353, '224.0.0.251', finish) + }) + setTimeout(finish, 1000) + }) +} + +async function getPermissionState(id: DeveloperPermissionId): Promise { + switch (id) { + case 'microphone': + case 'camera': + case 'screen': + return { id, status: getMediaStatus(id) } + case 'accessibility': + return { id, status: getAccessibilityStatus() } + case 'full-disk-access': + return { id, status: await getFullDiskAccessStatus() } + case 'automation': + case 'local-network': + return { id, status: unsupportedOffMac() ?? 'unknown' } + case 'usb': + case 'bluetooth': + return { id, status: unsupportedOffMac() ?? 'ready' } + } +} + +async function requestPermission( + id: DeveloperPermissionId +): Promise { + if (process.platform !== 'darwin') { + return { id, status: 'unsupported', openedSystemSettings: false } + } + + if (id === 'microphone' || id === 'camera') { + const granted = await systemPreferences.askForMediaAccess(id) + return { id, status: granted ? 'granted' : getMediaStatus(id), openedSystemSettings: false } + } + + if (id === 'accessibility') { + systemPreferences.isTrustedAccessibilityClient(true) + return { id, status: getAccessibilityStatus(), openedSystemSettings: false } + } + + if (id === 'automation') { + await triggerAppleEventsPrompt() + return { id, status: 'unknown', openedSystemSettings: false } + } + + if (id === 'local-network') { + await triggerLocalNetworkPrompt() + return { id, status: 'unknown', openedSystemSettings: false } + } + + await openPrivacyPane(id) + return { id, status: (await getPermissionState(id)).status, openedSystemSettings: true } +} + +export function registerDeveloperPermissionHandlers(): void { + ipcMain.handle( + 'developerPermissions:getStatus', + async (): Promise => { + return Promise.all(DEVELOPER_PERMISSION_IDS.map(getPermissionState)) + } + ) + + ipcMain.handle( + 'developerPermissions:request', + async ( + _event, + args: { id: DeveloperPermissionId } + ): Promise => { + if (!DEVELOPER_PERMISSION_IDS.includes(args.id)) { + return { id: args.id, status: 'unsupported', openedSystemSettings: false } + } + return requestPermission(args.id) + } + ) + + ipcMain.handle( + 'developerPermissions:openSettings', + async (_event, args: { id: DeveloperPermissionId }): Promise => { + await openPrivacyPane(args.id) + } + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 3f8a128025a..e268255f376 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -10,6 +10,7 @@ const { registerStatsHandlersMock, registerMemoryHandlersMock, registerNotificationHandlersMock, + registerDeveloperPermissionHandlersMock, registerSettingsHandlersMock, registerShellHandlersMock, registerSessionHandlersMock, @@ -39,6 +40,7 @@ const { registerStatsHandlersMock: vi.fn(), registerMemoryHandlersMock: vi.fn(), registerNotificationHandlersMock: vi.fn(), + registerDeveloperPermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), @@ -100,6 +102,10 @@ vi.mock('./notifications', () => ({ registerNotificationHandlers: registerNotificationHandlersMock })) +vi.mock('./developer-permissions', () => ({ + registerDeveloperPermissionHandlers: registerDeveloperPermissionHandlersMock +})) + vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) @@ -176,6 +182,7 @@ describe('registerCoreHandlers', () => { registerStatsHandlersMock.mockReset() registerMemoryHandlersMock.mockReset() registerNotificationHandlersMock.mockReset() + registerDeveloperPermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() registerShellHandlersMock.mockReset() registerSessionHandlersMock.mockReset() @@ -230,6 +237,7 @@ describe('registerCoreHandlers', () => { expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats) expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store) expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store) + expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled() expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index fbd4893eb4c..7df662b70a9 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -17,6 +17,7 @@ import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerNotificationHandlers } from './notifications' +import { registerDeveloperPermissionHandlers } from './developer-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' @@ -78,6 +79,7 @@ export function registerCoreHandlers( registerStatsHandlers(stats) registerMemoryHandlers(store) registerNotificationHandlers(store) + registerDeveloperPermissionHandlers() registerSettingsHandlers(store) registerBrowserHandlers() // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 8b9cc429daf..a6b279e6611 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -6,6 +6,8 @@ const { setPermissionRequestHandlerMock, setPermissionCheckHandlerMock, setDisplayMediaRequestHandlerMock, + systemPreferencesAskForMediaAccessMock, + systemPreferencesGetMediaAccessStatusMock, registerRepoHandlersMock, registerWorktreeHandlersMock, registerPtyHandlersMock, @@ -20,6 +22,8 @@ const { setPermissionRequestHandlerMock: vi.fn(), setPermissionCheckHandlerMock: vi.fn(), setDisplayMediaRequestHandlerMock: vi.fn(), + systemPreferencesAskForMediaAccessMock: vi.fn(), + systemPreferencesGetMediaAccessStatusMock: vi.fn(), registerRepoHandlersMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), @@ -36,6 +40,10 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: systemPreferencesAskForMediaAccessMock, + getMediaAccessStatus: systemPreferencesGetMediaAccessStatusMock + }, ipcMain: { on: onMock, removeAllListeners: removeAllListenersMock, @@ -74,6 +82,55 @@ vi.mock('../updater', () => ({ import { attachMainWindowServices } from './attach-main-window-services' +type MockFn = ReturnType + +type MainWindowStub = { + isDestroyed?: MockFn + on: MockFn + webContents: { + on: MockFn + send?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: MockFn + markRendererReloading: MockFn + markGraphUnavailable: MockFn +} + +function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}): MainWindowStub { + return { + on: vi.fn(), + webContents: { + on: vi.fn(), + session: { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + ...extraWebContents + } + } +} + +function createStore(): never { + return { flush: vi.fn() } as never +} + +function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn(), + markRendererReloading: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + describe('attachMainWindowServices', () => { beforeEach(() => { onMock.mockReset() @@ -81,6 +138,8 @@ describe('attachMainWindowServices', () => { setPermissionRequestHandlerMock.mockReset() setPermissionCheckHandlerMock.mockReset() setDisplayMediaRequestHandlerMock.mockReset() + systemPreferencesAskForMediaAccessMock.mockReset() + systemPreferencesGetMediaAccessStatusMock.mockReset() registerRepoHandlersMock.mockReset() registerWorktreeHandlersMock.mockReset() registerPtyHandlersMock.mockReset() @@ -95,33 +154,19 @@ describe('attachMainWindowServices', () => { setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, on: vi.fn() }) + systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) + systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) - it('only allows the explicit permission allowlist', () => { - const mainWindow = { - on: vi.fn(), - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } - - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + it('only allows the explicit permission allowlist', async () => { + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(2) const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0] const callback = vi.fn() - permissionHandler(null, 'media', callback) + permissionHandler(null, 'media', callback, { mediaTypes: ['audio'] }) + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(true)) permissionHandler(null, 'fullscreen', callback) permissionHandler(null, 'pointerLock', callback) permissionHandler(null, 'clipboard-read', callback) @@ -129,6 +174,28 @@ describe('attachMainWindowServices', () => { expect(callback.mock.calls).toEqual([[true], [true], [true], [false]]) }) + it('requests macOS media access only when the renderer asks for media', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + try { + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + + expect(systemPreferencesAskForMediaAccessMock).not.toHaveBeenCalled() + + const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0] + const callback = vi.fn() + permissionHandler(null, 'media', callback, { mediaTypes: ['audio', 'video'] }) + + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(true)) + expect(systemPreferencesAskForMediaAccessMock.mock.calls).toEqual([ + ['microphone'], + ['camera'] + ]) + } finally { + Object.defineProperty(process, 'platform', platform ?? { value: process.platform }) + } + }) + it('denies browser-session permissions, display capture, and downloads by default', () => { const browserSessionOnMock = vi.fn() sessionFromPartitionMock.mockReturnValue({ @@ -139,24 +206,10 @@ describe('attachMainWindowServices', () => { }) const mainWindowOnMock = vi.fn() - const mainWindow = { - on: mainWindowOnMock, - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as ( wc: unknown, @@ -176,7 +229,7 @@ describe('attachMainWindowServices', () => { rawUrl: 'https://example.com/account' }) - const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[0][0] as ( + const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[1][0] as ( wc: unknown, permission: string ) => boolean @@ -215,24 +268,10 @@ describe('attachMainWindowServices', () => { on: vi.fn() }) const mainWindowOnMock = vi.fn() - const mainWindow = { - on: mainWindowOnMock, - webContents: { - on: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow() + mainWindow.on = mainWindowOnMock - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) const closedHandler = mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -246,26 +285,12 @@ describe('attachMainWindowServices', () => { const sendMock = vi.fn() const webContentsOnMock = vi.fn() const mainWindowOnMock = vi.fn() - const mainWindow = { - isDestroyed: vi.fn(() => false), - on: mainWindowOnMock, - webContents: { - on: webContentsOnMock, - send: sendMock, - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock - } - } - } - const store = { flush: vi.fn() } - const runtime = { - attachWindow: vi.fn(), - setNotifier: vi.fn(), - markRendererReloading: vi.fn(), - markGraphUnavailable: vi.fn() - } + const mainWindow = createMainWindow({ on: webContentsOnMock, send: sendMock }) + mainWindow.isDestroyed = vi.fn(() => false) + mainWindow.on = mainWindowOnMock + const runtime = createRuntime() - attachMainWindowServices(mainWindow as never, store as never, runtime as never) + attachMainWindowServices(mainWindow as never, createStore(), runtime as never) expect(runtime.setNotifier).toHaveBeenCalledTimes(1) const notifier = runtime.setNotifier.mock.calls[0][0] as { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2fcf6271f05..99be52813ae 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,8 +1,8 @@ import fs from 'node:fs/promises' import path from 'node:path' -import { app, clipboard, ipcMain, nativeImage, session } from 'electron' -import type { BrowserWindow } from 'electron' +import { app, clipboard, ipcMain, nativeImage, session, systemPreferences } from 'electron' +import type { BrowserWindow, MediaAccessPermissionRequest } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult } from '../../shared/types' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' @@ -85,10 +85,25 @@ export function attachMainWindowServices( const allowedPermissions = new Set(['media', 'fullscreen', 'pointerLock']) mainWindow.webContents.session.setPermissionRequestHandler( - (_webContents, permission, callback) => { + (_webContents, permission, callback, details) => { + if (permission === 'media') { + void requestSystemMediaAccess(details).then(callback, (error: unknown) => { + console.error('[permissions] Failed to request media access:', error) + callback(false) + }) + return + } callback(allowedPermissions.has(permission)) } ) + mainWindow.webContents.session.setPermissionCheckHandler( + (_webContents, permission, _origin, details) => { + if (permission !== 'media') { + return allowedPermissions.has(permission) + } + return hasSystemMediaAccess(details?.mediaType) + } + ) const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION) browserSession.setPermissionRequestHandler((webContents, permission, callback) => { @@ -132,6 +147,54 @@ export function attachMainWindowServices( }) } +function requestedMediaTypes( + details: MediaAccessPermissionRequest | undefined +): Set<'audio' | 'video'> { + return new Set(details?.mediaTypes ?? []) +} + +function hasSystemMediaAccess(mediaType: string | undefined): boolean { + if (process.platform !== 'darwin') { + return true + } + if (mediaType === 'audio') { + return systemPreferences.getMediaAccessStatus('microphone') === 'granted' + } + if (mediaType === 'video') { + return systemPreferences.getMediaAccessStatus('camera') === 'granted' + } + return false +} + +async function requestSystemMediaAccess( + details: MediaAccessPermissionRequest | undefined +): Promise { + if (process.platform !== 'darwin') { + return true + } + + const mediaTypes = requestedMediaTypes(details) + if (mediaTypes.size === 0) { + return false + } + + if (mediaTypes.has('audio')) { + // Why: macOS only shows the TCC prompt from the app process, so Chromium's + // media grant is paired with the OS-level request at the actual media ask. + const granted = await systemPreferences.askForMediaAccess('microphone') + if (!granted) { + return false + } + } + if (mediaTypes.has('video')) { + const granted = await systemPreferences.askForMediaAccess('camera') + if (!granted) { + return false + } + } + return true +} + function registerRuntimeWindowLifecycle( mainWindow: BrowserWindow, runtime: OrcaRuntimeService diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 43b4c0550c7..96c9a7386e4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -80,6 +80,11 @@ import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { AgentStatusState } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { + DeveloperPermissionId, + DeveloperPermissionRequestResult, + DeveloperPermissionState +} from '../shared/developer-permissions-types' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -556,6 +561,11 @@ export type PreloadApi = { dispatch: (args: NotificationDispatchRequest) => Promise openSystemSettings: () => Promise } + developerPermissions: { + getStatus: () => Promise + request: (args: { id: DeveloperPermissionId }) => Promise + openSettings: (args: { id: DeveloperPermissionId }) => Promise + } shell: { openPath: (path: string) => Promise openUrl: (url: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index a472eb96e85..57c27663dee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -647,6 +647,14 @@ const api = { openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings') }, + developerPermissions: { + getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:request', args), + openSettings: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:openSettings', args) + }, + shell: { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx new file mode 100644 index 00000000000..eead3e04e1c --- /dev/null +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { + Accessibility, + Bluetooth, + Camera, + ExternalLink, + HardDrive, + Mic, + MonitorUp, + Network, + RefreshCw, + ShieldCheck, + Usb, + Workflow +} from 'lucide-react' +import { toast } from 'sonner' +import type { + DeveloperPermissionId, + DeveloperPermissionState, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' +import { Button } from '../ui/button' +import type { SettingsSearchEntry } from './settings-search' + +export const DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Developer Permissions', + description: 'macOS permissions for terminal-launched developer tools.', + keywords: ['permissions', 'privacy', 'tcc', 'macos', 'developer tools'] + }, + { + title: 'Microphone and Camera', + description: 'Allow voice, transcription, webcam, and media capture tools.', + keywords: ['microphone', 'camera', 'voice', 'audio', 'video', 'sox', 'ffmpeg', 'whisper'] + }, + { + title: 'Screen Recording and Accessibility', + description: 'Allow screenshots, screen inspection, keystrokes, and window automation.', + keywords: ['screen recording', 'accessibility', 'screenshot', 'automation', 'window'] + }, + { + title: 'Full Disk Access', + description: 'Open the macOS privacy pane for broad terminal file access.', + keywords: ['full disk access', 'documents', 'downloads', 'desktop', 'icloud'] + }, + { + title: 'Local Network, USB, and Bluetooth', + description: 'Allow device and local-network tools used from terminal sessions.', + keywords: ['local network', 'usb', 'bluetooth', 'bonjour', 'mdns', 'device'] + } +] + +type PermissionDefinition = { + id: DeveloperPermissionId + label: string + description: string + actionLabel: string + icon: ReactNode +} + +const PERMISSIONS: PermissionDefinition[] = [ + { + id: 'microphone', + label: 'Microphone', + description: 'Voice input, transcription, audio recording, sox, ffmpeg, and Whisper CLIs.', + actionLabel: 'Request', + icon: + }, + { + id: 'camera', + label: 'Camera', + description: 'Webcam capture and camera-driven local test apps.', + actionLabel: 'Request', + icon: + }, + { + id: 'screen', + label: 'Screen Recording', + description: 'Screenshot, visual automation, and UI inspection tools.', + actionLabel: 'Open Settings', + icon: + }, + { + id: 'accessibility', + label: 'Accessibility', + description: 'Keystroke injection, window control, and UI automation tools.', + actionLabel: 'Request', + icon: + }, + { + id: 'full-disk-access', + label: 'Full Disk Access', + description: 'Persistent access to protected folders from terminal sessions.', + actionLabel: 'Open Settings', + icon: + }, + { + id: 'automation', + label: 'Automation', + description: 'Apple Events for scripts that control other local apps.', + actionLabel: 'Trigger Prompt', + icon: + }, + { + id: 'local-network', + label: 'Local Network', + description: 'Discovery and access for development servers on your network.', + actionLabel: 'Trigger Prompt', + icon: + }, + { + id: 'usb', + label: 'USB Devices', + description: 'Hardware debugging and device tools that talk to USB devices.', + actionLabel: 'Open Settings', + icon: + }, + { + id: 'bluetooth', + label: 'Bluetooth', + description: 'Bluetooth device tools and local hardware experiments.', + actionLabel: 'Open Settings', + icon: + } +] + +function statusLabel(status: DeveloperPermissionStatus | undefined): string { + switch (status) { + case 'granted': + return 'Granted' + case 'denied': + return 'Denied' + case 'not-determined': + return 'Not requested' + case 'restricted': + return 'Restricted' + case 'unsupported': + return 'macOS only' + case 'ready': + return 'Entitled' + case 'unknown': + default: + return 'Check manually' + } +} + +function statusClass(status: DeveloperPermissionStatus | undefined): string { + if (status === 'granted' || status === 'ready') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + } + if (status === 'denied' || status === 'restricted') { + return 'border-destructive/30 bg-destructive/10 text-destructive' + } + return 'border-border bg-muted text-muted-foreground' +} + +export function DeveloperPermissionsPane(): React.JSX.Element { + const [states, setStates] = useState([]) + const [loading, setLoading] = useState(true) + const [pendingId, setPendingId] = useState(null) + + const stateById = useMemo( + () => new Map(states.map((state) => [state.id, state.status] as const)), + [states] + ) + + const refresh = useCallback(async (): Promise => { + setLoading(true) + try { + setStates(await window.api.developerPermissions.getStatus()) + } catch { + toast.error('Could not load developer permissions') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const request = async (id: DeveloperPermissionId): Promise => { + setPendingId(id) + try { + const result = await window.api.developerPermissions.request({ id }) + await refresh() + if (result.status === 'granted') { + toast.success('Permission granted') + } else if (result.openedSystemSettings) { + toast.message('Opened macOS Privacy & Security') + } else { + toast.message('Permission request sent') + } + } catch { + toast.error('Could not request permission') + } finally { + setPendingId(null) + } + } + + return ( +
+
+
+
+ + Terminal tools inherit Orca's macOS privacy envelope. +
+

+ Use these controls when a CLI, local app, or automation tool needs macOS privacy access. + Orca does not ask at startup. +

+
+ +
+ +
+ {PERMISSIONS.map((permission) => { + const status = stateById.get(permission.id) + const pending = pendingId === permission.id + + return ( +
+
+
{permission.icon}
+
+
+ {permission.label} + + {statusLabel(status)} + +
+

{permission.description}

+
+
+ +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 6f29c0f230d..a959430f611 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -8,6 +8,7 @@ import { GitBranch, Globe, Keyboard, + ShieldCheck, Palette, Server, SlidersHorizontal, @@ -37,6 +38,10 @@ import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './Experiment import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane' +import { + DeveloperPermissionsPane, + DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES +} from './DeveloperPermissionsPane' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' @@ -49,6 +54,7 @@ type SettingsNavTarget = | 'appearance' | 'terminal' | 'notifications' + | 'developer-permissions' | 'shortcuts' | 'stats' | 'ssh' @@ -337,6 +343,17 @@ function Settings(): React.JSX.Element { icon: Bell, searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES }, + ...(isMac + ? [ + { + id: 'developer-permissions' as const, + title: 'Permissions', + description: 'macOS privacy access for terminal-launched developer tools.', + icon: ShieldCheck, + searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES + } + ] + : []), { id: 'shortcuts', title: 'Shortcuts', @@ -381,7 +398,7 @@ function Settings(): React.JSX.Element { searchEntries: getRepositoryPaneSearchEntries(repo) })) ], - [repos, terminalPaneSearchEntries] + [isMac, repos, terminalPaneSearchEntries] ) const visibleNavSections = useMemo( @@ -648,6 +665,17 @@ function Settings(): React.JSX.Element { + {isMac ? ( + + + + ) : null} + { + it('detects microphone failures from terminal output', () => { + expect( + detectDeveloperPermissionHint('sox WARN coreaudio: default input device permission denied') + ?.permissionId + ).toBe('microphone') + }) + + it('detects screen recording failures from terminal output', () => { + expect( + detectDeveloperPermissionHint('screencapture failed: screen recording not authorized') + ?.permissionId + ).toBe('screen') + }) + + it('ignores unrelated permission text', () => { + expect(detectDeveloperPermissionHint('git: permission denied (publickey)')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/developer-permission-hints.ts b/src/renderer/src/components/terminal-pane/developer-permission-hints.ts new file mode 100644 index 00000000000..4a79bace8cb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/developer-permission-hints.ts @@ -0,0 +1,64 @@ +import type { DeveloperPermissionId } from '../../../../shared/developer-permissions-types' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const ANSI_PATTERN = new RegExp( + `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, + 'g' +) + +type DeveloperPermissionHint = { + permissionId: DeveloperPermissionId + title: string + description: string +} + +export function detectDeveloperPermissionHint(data: string): DeveloperPermissionHint | null { + const normalized = data.slice(-4000).replace(ANSI_PATTERN, '').toLowerCase() + + if ( + /\b(microphone|audio input|input device|default input device)\b/.test(normalized) && + /\b(permission|denied|not authorized|unauthorized|no audio|not permitted)\b/.test(normalized) + ) { + return { + permissionId: 'microphone', + title: 'This command may need microphone access', + description: 'Open Developer Permissions to enable audio capture for terminal tools.' + } + } + + if ( + /\b(camera|webcam|video capture)\b/.test(normalized) && + /\b(permission|denied|not authorized|unauthorized|not permitted)\b/.test(normalized) + ) { + return { + permissionId: 'camera', + title: 'This command may need camera access', + description: 'Open Developer Permissions to enable camera capture for terminal tools.' + } + } + + if ( + /\b(screen recording|screen capture|screencapture|desktop capture)\b/.test(normalized) && + /\b(permission|denied|not authorized|unauthorized|not permitted)\b/.test(normalized) + ) { + return { + permissionId: 'screen', + title: 'This command may need screen recording access', + description: 'Open Developer Permissions to enable screenshots and screen capture.' + } + } + + if ( + /\b(apple events|osascript|system events|automation)\b/.test(normalized) && + /\b(not authorized|not allowed|permission|denied|not permitted)\b/.test(normalized) + ) { + return { + permissionId: 'automation', + title: 'This command may need automation access', + description: 'Open Developer Permissions to allow Apple Events for terminal scripts.' + } + } + + return null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b106c5be993..8bdc66c73ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -16,8 +16,10 @@ import { POST_REPLAY_FOCUS_REPORTING_RESET } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' +import { detectDeveloperPermissionHint } from './developer-permission-hints' const pendingSpawnByPaneKey = new Map>() +const developerPermissionHintKeys = new Set() // Why: when multiple panes/tabs need the same deferred SSH connection, // the first one calls ssh.connect() and subsequent ones must wait for it @@ -84,6 +86,38 @@ function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolea return sessionId.slice(0, separatorIdx) === worktreeId } +function maybeShowDeveloperPermissionHint(worktreeId: string, data: string): void { + if (!navigator.userAgent.includes('Mac')) { + return + } + + const hint = detectDeveloperPermissionHint(data) + if (!hint) { + return + } + const key = `${worktreeId}:${hint.permissionId}` + if (developerPermissionHintKeys.has(key)) { + return + } + developerPermissionHintKeys.add(key) + + toast.message(hint.title, { + description: hint.description, + duration: 12000, + action: { + label: 'Open Permissions', + onClick: () => { + useAppStore.getState().openSettingsTarget({ + pane: 'developer-permissions', + repoId: null, + sectionId: 'developer-permissions' + }) + useAppStore.getState().openSettingsPage() + } + } + }) +} + export function connectPanePty( pane: ManagedPane, manager: PaneManager, @@ -449,6 +483,8 @@ export function connectPanePty( } const dataCallback = (data: string): void => { + maybeShowDeveloperPermissionHint(deps.worktreeId, data) + if (deps.isVisibleRef.current) { pane.terminal.write(data) } else { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 58d9b2ef27a..569e438a472 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -99,6 +99,7 @@ export type UISlice = { | 'browser' | 'appearance' | 'terminal' + | 'developer-permissions' | 'shortcuts' | 'repo' | 'agents' diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts new file mode 100644 index 00000000000..26c7633f021 --- /dev/null +++ b/src/shared/developer-permissions-types.ts @@ -0,0 +1,30 @@ +export type DeveloperPermissionId = + | 'microphone' + | 'camera' + | 'screen' + | 'accessibility' + | 'full-disk-access' + | 'automation' + | 'local-network' + | 'usb' + | 'bluetooth' + +export type DeveloperPermissionStatus = + | 'granted' + | 'denied' + | 'not-determined' + | 'restricted' + | 'unknown' + | 'unsupported' + | 'ready' + +export type DeveloperPermissionState = { + id: DeveloperPermissionId + status: DeveloperPermissionStatus +} + +export type DeveloperPermissionRequestResult = { + id: DeveloperPermissionId + status: DeveloperPermissionStatus + openedSystemSettings: boolean +} From 44c90b9c9a237c370dea03ae68156f39bbfe02af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:34:32 -0700 Subject: [PATCH 016/259] feat(sidebar): expand agent-status hovercard trigger to whole card (#1235) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeCard.tsx | 430 +++++++++--------- 1 file changed, 218 insertions(+), 212 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2bd6ba18b00..a2d4410741e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -12,7 +12,11 @@ import { SshDisconnectedDialog } from './SshDisconnectedDialog' import AgentStatusHover from './AgentStatusHover' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' +import { + getWorktreeStatus, + getWorktreeStatusLabel, + type WorktreeStatus +} from '@/lib/worktree-status' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' @@ -319,237 +323,239 @@ const WorktreeCard = React.memo(function WorktreeCard({ const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' - return ( - <> - -
- {isDeleting && ( -
-
- - Deleting… -
-
- )} + // Why: the whole card is the hover target for the agent-status panel, not + // just the dot. Hovering any part of the workspace row reveals the panel + // to the right (HoverCardContent inside AgentStatusHover uses + // side="right"). A dot-sized target was too easy to miss; the card-level + // trigger preserves the dot as an at-a-glance cue while giving users a + // much larger surface to surface the "agent activity" detail. Gated by + // dashboardExperimentEnabled AND cardProps.includes('status') to match + // the previous scope — when the status dot is hidden, the hover panel + // stays hidden too. + const cardBody = ( +
+ {isDeleting && ( +
+
+ + Deleting… +
+
+ )} - {/* Cmd+N hint badge — decorative only, shown when the user holds the + {/* Cmd+N hint badge — decorative only, shown when the user holds the platform modifier key for discoverability of Cmd+1–9 shortcuts. Why centered on the left edge: placing it at the top clipped the glyph against the card bounds on some sizes, while mid-card keeps the badge fully visible without competing with the title row. */} - {hintNumber != null && ( - diff --git a/src/renderer/src/components/new-workspace/CreateFromTab.tsx b/src/renderer/src/components/new-workspace/CreateFromTab.tsx index 796ef7c82b3..e2f25f6aed4 100644 --- a/src/renderer/src/components/new-workspace/CreateFromTab.tsx +++ b/src/renderer/src/components/new-workspace/CreateFromTab.tsx @@ -20,7 +20,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { normalizeGitHubLinkQuery } from '@/lib/github-links' -import type { RepoSlug } from '@/lib/github-links' import { launchWorkItemDirect, launchFromBranch } from '@/lib/launch-work-item-direct' import { isGitRepoKind } from '../../../../shared/repo-kind' import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types' @@ -201,37 +200,9 @@ export default function CreateFromTab({ ) const isRemoteRepo = Boolean(selectedRepo?.connectionId) - // Why: resolve the repo slug once so normalizeGitHubLinkQuery can detect - // pasted URLs that target a different repo than the selected one — same - // treatment StartFromPicker applies, so the "paste a URL" flow feels - // identical between the two surfaces. - const [repoSlug, setRepoSlug] = useState(null) - useEffect(() => { - if (!selectedRepo?.path) { - setRepoSlug(null) - return - } - let stale = false - void window.api.gh - .repoSlug({ repoPath: selectedRepo.path }) - .then((slug) => { - if (!stale) { - setRepoSlug(slug) - } - }) - .catch(() => { - if (!stale) { - setRepoSlug(null) - } - }) - return () => { - stale = true - } - }, [selectedRepo?.path]) - const normalizedGhQuery = useMemo( - () => normalizeGitHubLinkQuery(debouncedQuery, repoSlug), - [debouncedQuery, repoSlug] + () => normalizeGitHubLinkQuery(debouncedQuery), + [debouncedQuery] ) // --------------------------------------------------------------------- @@ -250,10 +221,7 @@ export default function CreateFromTab({ return // handled by direct-lookup effect below } const trimmed = debouncedQuery.trim() - const q = - trimmed && !normalizedGhQuery.repoMismatch - ? `is:pr is:open ${normalizedGhQuery.query}` - : 'is:pr is:open' + const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : 'is:pr is:open' let stale = false setPrLoading(true) @@ -288,7 +256,6 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.repoMismatch, normalizedGhQuery.directNumber ]) @@ -307,10 +274,7 @@ export default function CreateFromTab({ return } const trimmed = debouncedQuery.trim() - const q = - trimmed && !normalizedGhQuery.repoMismatch - ? `is:issue is:open ${normalizedGhQuery.query}` - : 'is:issue is:open' + const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : 'is:issue is:open' let stale = false setIssueLoading(true) @@ -345,7 +309,6 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.repoMismatch, normalizedGhQuery.directNumber ]) @@ -735,12 +698,6 @@ export default function CreateFromTab({ if (isRemoteRepo) { return { kind: 'empty', message: "PR start points aren't supported for remote repos yet." } } - if (normalizedGhQuery.repoMismatch && normalizedGhQuery.directNumber === null) { - return { - kind: 'empty', - message: `URL targets ${normalizedGhQuery.repoMismatch}, not the selected repo.` - } - } if (prError) { return { kind: 'empty', @@ -775,12 +732,6 @@ export default function CreateFromTab({ message: "Issue start points aren't supported for remote repos yet." } } - if (normalizedGhQuery.repoMismatch && normalizedGhQuery.directNumber === null) { - return { - kind: 'empty', - message: `URL targets ${normalizedGhQuery.repoMismatch}, not the selected repo.` - } - } if (issueError) { return { kind: 'empty', @@ -869,7 +820,6 @@ export default function CreateFromTab({ linearLoading, linearStatus.connected, normalizedGhQuery.directNumber, - normalizedGhQuery.repoMismatch, prError, prLoading, query, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index bc8258c67a7..35c6054a952 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -8,7 +8,6 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links' -import type { RepoSlug } from '@/lib/github-links' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -84,7 +83,7 @@ export type ComposerCardProps = { filteredLinkItems: GitHubWorkItem[] linkItemsLoading: boolean linkDirectLoading: boolean - normalizedLinkQuery: { query: string; repoMismatch: string | null } + normalizedLinkQuery: { query: string } onSelectLinkedItem: (item: GitHubWorkItem) => void tuiAgent: TuiAgent onTuiAgentChange: (value: TuiAgent) => void @@ -306,7 +305,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [linkItemsLoading, setLinkItemsLoading] = useState(false) const [linkDirectItem, setLinkDirectItem] = useState(null) const [linkDirectLoading, setLinkDirectLoading] = useState(false) - const [linkRepoSlug, setLinkRepoSlug] = useState(null) const lastAutoNameRef = useRef( persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName @@ -415,8 +413,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS shouldApplyLinkedOnlyTemplate ]) const normalizedLinkQuery = useMemo( - () => normalizeGitHubLinkQuery(linkDebouncedQuery, linkRepoSlug), - [linkDebouncedQuery, linkRepoSlug] + () => normalizeGitHubLinkQuery(linkDebouncedQuery), + [linkDebouncedQuery] ) const filteredLinkItems = useMemo(() => { @@ -567,32 +565,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open') }, [prefetchWorkItems, selectedRepo?.connectionId, selectedRepo?.id, selectedRepo?.path]) - // Per-repo: resolve repo slug for GH URL mismatch detection. - useEffect(() => { - if (!selectedRepo) { - setLinkRepoSlug(null) - return - } - - let cancelled = false - void window.api.gh - .repoSlug({ repoPath: selectedRepo.path }) - .then((slug) => { - if (!cancelled) { - setLinkRepoSlug(slug) - } - }) - .catch(() => { - if (!cancelled) { - setLinkRepoSlug(null) - } - }) - - return () => { - cancelled = true - } - }, [selectedRepo]) - // Reset setup decision when config / policy changes. useEffect(() => { if (shouldWaitForSetupCheck) { diff --git a/src/renderer/src/lib/github-links.test.ts b/src/renderer/src/lib/github-links.test.ts index 95dd4b2cea3..320468d6ff4 100644 --- a/src/renderer/src/lib/github-links.test.ts +++ b/src/renderer/src/lib/github-links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseGitHubIssueOrPRNumber } from './github-links' +import { normalizeGitHubLinkQuery, parseGitHubIssueOrPRNumber } from './github-links' describe('parseGitHubIssueOrPRNumber', () => { it('parses plain issue numbers and GitHub pull request URLs', () => { @@ -12,3 +12,12 @@ describe('parseGitHubIssueOrPRNumber', () => { expect(parseGitHubIssueOrPRNumber('https://example.com/stablyai/orca/pull/123')).toBeNull() }) }) + +describe('normalizeGitHubLinkQuery', () => { + it('accepts full GitHub URLs whose slug differs from the selected repo slug', () => { + expect(normalizeGitHubLinkQuery('https://github.com/stablyai/orca/issues/923')).toEqual({ + query: 'https://github.com/stablyai/orca/issues/923', + directNumber: 923 + }) + }) +}) diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index b7e71c937bf..e6db2b488f8 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -8,7 +8,6 @@ export type RepoSlug = { export type GitHubLinkQuery = { query: string - repoMismatch: string | null directNumber: number | null } @@ -83,40 +82,29 @@ export function parseGitHubIssueOrPRLink(input: string): { /** * Normalizes link-picker input so both raw issue/PR numbers and full GitHub - * URLs resolve to a usable query + direct-number lookup. Returns a repo - * mismatch when a URL targets a different repo than the selected one. + * URLs resolve to a usable query + direct-number lookup. */ -export function normalizeGitHubLinkQuery(raw: string, repoSlug: RepoSlug | null): GitHubLinkQuery { +export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { const trimmed = raw.trim() if (!trimmed) { - return { query: '', repoMismatch: null, directNumber: null } + return { query: '', directNumber: null } } const direct = parseGitHubIssueOrPRNumber(trimmed) if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, repoMismatch: null, directNumber: direct } + return { query: trimmed, directNumber: direct } } const link = parseGitHubIssueOrPRLink(trimmed) if (!link) { - return { query: trimmed, repoMismatch: null, directNumber: null } - } - - if ( - repoSlug && - (link.slug.owner.toLowerCase() !== repoSlug.owner.toLowerCase() || - link.slug.repo.toLowerCase() !== repoSlug.repo.toLowerCase()) - ) { - return { - query: '', - repoMismatch: `${repoSlug.owner}/${repoSlug.repo}`, - directNumber: null - } + return { query: trimmed, directNumber: null } } + // Why: any github.com issue/pull URL is accepted by number regardless of + // slug, since fork checkouts can legitimately target upstream issues whose + // slug differs from the origin remote. return { query: trimmed, - repoMismatch: null, directNumber: link.number } } From 29411c5a63c14743183654e6b1eec9a282ad6869 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:47:48 -0700 Subject: [PATCH 018/259] fix(sidebar): clear agent status when sleeping a worktree (#1238) Co-authored-by: Orca --- src/renderer/src/store/slices/terminals.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 348ed1dba4c..89165238aa7 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1079,6 +1079,15 @@ export const createTerminalSlice: StateCreator } }) + // Why: sleep keeps the tab records (so wake restores them) but kills the + // PTYs, and there is no implicit "PTY death drops agent-status rows" path + // — closeTab and pane-close drop their own rows explicitly. Without the + // same explicit sweep here, a worktree slept in the 'done' state leaves + // live/retained entries behind and WorktreeCard's dot stays blue. + for (const tab of tabs) { + get().dropAgentStatusByTabPrefix(tab.id) + } + if (ptyIds.length === 0) { return } From 374f821b70e6799ffccfbba8a4155f63488871c9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:52:31 -0700 Subject: [PATCH 019/259] improve remote folder picker UI (#1240) - polish remote folder picker layout and button borders - wip in improving remote folder picker Co-authored-by: Orca --- docs/remote-folder-picker-redesign.md | 119 ++++++++ .../src/components/sidebar/AddRepoDialog.tsx | 6 +- .../components/sidebar/RemoteFileBrowser.tsx | 264 ++++++++++++++---- .../remote-file-browser-helpers.test.ts | 59 ++++ .../sidebar/remote-file-browser-helpers.ts | 43 +++ 5 files changed, 437 insertions(+), 54 deletions(-) create mode 100644 docs/remote-folder-picker-redesign.md create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-helpers.ts diff --git a/docs/remote-folder-picker-redesign.md b/docs/remote-folder-picker-redesign.md new file mode 100644 index 00000000000..79036d1b607 --- /dev/null +++ b/docs/remote-folder-picker-redesign.md @@ -0,0 +1,119 @@ +# Remote SSH Folder Picker — Redesign Proposal + +## Context + +The "Browse remote filesystem" dialog lets users pick a directory on a connected SSH target to open as a remote project. It's reached from: sidebar → Add project → Open remote project → folder-picker icon next to the Remote path input. + +**Source:** `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx` +**Invoked from:** `src/renderer/src/components/sidebar/AddRepoSteps.tsx` (`RemoteStep`, around line 167) + +## User complaints + +1. **Hard to find the desired folder** — list is flat and unfiltered; on a home directory with 30+ dotfiles/dirs, you scroll. +2. **Nested folders not discoverable as navigable** — current model is single-click = highlight, double-click = navigate. Users don't discover the double-click. +3. **Unclear consequence of pressing "Select"** — button is generic; the dialog doesn't say what happens to the chosen folder next. + +## Current behavior (for reference) + +- Single-click a row → sets `selectedName`, highlights the row. +- Double-click a folder row → navigates into it. +- "Select" button → if a row is highlighted, returns `/`; otherwise returns `resolvedPath` (the current directory). +- Breadcrumb bar at top with ↑, 🏠, and clickable path segments. +- Footer shows the path that will be returned: either current dir or `current/highlighted`. + +## Reference: how others do it + +- **VS Code** (`simpleFileDialog.ts`): one unified input at the top doubles as (a) current path, (b) filter as you type, (c) editable path entry. Enter key navigates into folders; OK button label is caller-supplied ("Open Folder", etc.). Auto-complete suggests folders inline. +- **Superset, Warp**: no comparable picker. + +## Options considered + +### Option A — VS Code-style: selection = current directory +Drop the "highlight a row" model entirely. Navigate into the folder you want (single-click enters it), then "Select" always returns the current directory. + +**Rejected because:** breaks Finder/Explorer muscle memory (double-click to open is a universal convention), and adds a click for the common case of picking a visible child folder. + +### Option B — chevron-on-folders affordance +Add a `›` navigation button on folder rows to make "enter this folder" discoverable to mouse users. A permanent chevron is visual noise, so we show it only on row hover (and on keyboard focus for a11y parity). + +### Option C — add a filter input +Add a text input at the top of the list that live-filters visible entries by substring. No change to the selection model. + +### Chosen: Option C + Option B +The filter input (C) addresses complaint #1 and surfaces `Enter`-to-navigate for keyboard users. The hover/focus chevron (B) addresses complaint #2 for mouse users without the visual noise of a permanent affordance. The two are independent and compose cleanly. + +## Proposed change + +### 1. Filter input (addresses complaint #1) + +- Add a text input above the file list (below the breadcrumb bar) that auto-focuses on mount. The picker only mounts when the outer dialog opens (verified: `AddRepoSteps.tsx` conditionally renders `RemoteFileBrowser` based on dialog-open state), so the auto-focus cannot steal focus from the outer dialog's inputs. +- Live-filter `entries` by case-insensitive substring match on `entry.name`. **Filters both files and folders** — hiding files would confuse users trying to confirm they're in the right directory (e.g. looking for a README). Files remain non-actionable. +- Keyboard (handled on the input's `onKeyDown`): + - `↓` / `↑` — move the highlight (`selectedName`) through the *filtered* list. `preventDefault` so the caret doesn't jump. Clamps at the ends: ArrowUp at the first filtered entry (or with nothing highlighted) stays put; it never triggers parent-directory navigation. Parent-nav is exclusively the breadcrumb `↑` button. + - `Enter` — precedence: (a) if a folder is highlighted, navigate into it; (b) else if a **file** is highlighted, surface the transient footer hint (below) and do not navigate; (c) else if the filtered set contains exactly one folder (regardless of how many files are also in the set), navigate into that folder; (d) else highlight the first filtered entry (file or folder) — this is a highlight-only step and the visible highlight is the feedback; a subsequent `Enter` then re-enters this ladder and hits (a) or (b). Rule (b) is the only path that triggers the hint, so a filter that matches only files and is already highlighted on the first entry keeps yielding the hint on repeated `Enter`, never a silent no-op. The hint text is `Files can't be opened as a project`, shown in the footer for 2s before reverting. Chose the footer hint over an input shake/flash because it needs no animation infrastructure and the footer is already the dialog's status region. + - `Esc` — if filter is non-empty, clear it and `stopPropagation` so the outer dialog doesn't close; otherwise let the event bubble and call `onCancel`. +- **Focus management:** the input retains focus across row clicks. Clicking a row calls `setSelectedName` but does not steal focus (`onMouseDown` with `preventDefault` on the row buttons, or explicit `inputRef.current?.focus()` after selection). This keeps arrow keys and typing working after the user mouses. +- **Navigation helper.** Introduce a `navigate(path)` wrapper that calls `loadDir(path)` *and* clears the filter. All user-initiated navigation (breadcrumb segment click, the breadcrumb `↑` parent-directory button, double-click, chevron click, `Enter`-to-enter-folder) goes through `navigate`. The `↑`/`↓` ArrowUp/ArrowDown *keys* move the filter-list highlight per the keyboard spec above and do not call `navigate`. The mount effect calls `loadDir(path)` directly — it never clears the filter, so a user who types before the first load completes does not lose their input. This removes the `didInitialLoad` ref. +- When the filter changes, if the current `selectedName` is no longer in the filtered list, clear it so the button label doesn't go stale. +- **Empty-state copy.** When `entries.length > 0` but `filteredEntries.length === 0`, render `No matches for ''` instead of the generic `Empty directory` copy — the latter is misleading when the directory has contents that are simply filtered out. +- Placeholder: `Type to filter…` with a leading `Search` icon (lucide) for scannability, matching the existing icon-prefixed inputs in the sidebar. + +### 2. Dynamic button label (addresses complaint #3) + +Replace the static "Select" label with the path it will return: + +- When a row is highlighted: `Select /home/neil/myproject` +- When no row is highlighted: `Select /home/neil` (the current directory) +- **Left-ellipsis truncation.** Plain Tailwind `truncate` right-ellipsizes, which hides the meaningful tail. Use `direction: rtl; text-align: left;` on an inner span wrapping the path (keep the word "Select" in a separate LTR span), or equivalent CSS. Add the full path as a `title` attribute for hover-tooltip verification. Note: RTL-directionality on the path span can reorder punctuation in filenames that themselves contain RTL characters — accepted as a tiny edge-case risk for SSH target paths. + +This makes the two selection modes (child vs. current dir) visible without any model change, and tells the user exactly what will be opened. + +### 3. Footer + +Keep the existing muted full-path line — it's the unambiguous source of truth when the button label is ellipsized. Prepend a short hint so first-time users know what Select does: + +> Opens as a remote project · `/home/neil/myproject` + +Single line, muted, truncates with right-ellipsis (the prefix is the fixed part, the path tail can be cut since it's already in the button's `title`). + +When `Enter` is pressed on a highlighted file, swap this line for `Files can't be opened as a project` (same muted style) for 2s, then revert. Clear the hint's timer eagerly on any filter change or navigation so the message never outlives the state it describes. + +### 4. Folder-row chevron (addresses complaint #2) + +Folder rows render a `›` icon (lucide `ChevronRight`) on the right edge, shown only on row hover or keyboard focus. Files never render the chevron. + +- **Trigger:** CSS `:hover` on the row plus a `:focus-visible` rule so keyboard-focused rows also show it. Never always-visible — a permanent chevron is visual noise across a long list. +- **Click handler:** the chevron is a nested `
+ {/* Filter input */} +
+ + { + clearFileHint() + setFilter(e.target.value) + }} + onKeyDown={handleFilterKeyDown} + placeholder="Type to filter…" + className={cn( + 'w-full h-7 pl-7 pr-2 text-xs rounded-md bg-background', + 'border border-border focus:outline-none focus:ring-1 focus:ring-ring' + )} + /> +
+ {/* File listing */}
@@ -157,44 +289,74 @@ export function RemoteFileBrowser({

Empty directory

+ ) : filteredEntries.length === 0 ? ( + // The directory has contents; the filter just hid them all. Generic + // "Empty directory" copy would mislead here. +
+

{`No matches for '${filter}'`}

+
) : ( - entries.map((entry) => ( + filteredEntries.map((entry) => ( )) )}
- {/* Footer */} -
-

- {selectedName ? `${resolvedPath}/${selectedName}` : resolvedPath} -

-
- - -
+ {/* Footer. Path line is on its own row with `block + truncate` so long + paths can't push the container wider; buttons sit on a separate row + and are free to align right without competing for space. */} +

+ {fileHint ? FILE_HINT_TEXT : `Opens as a remote project · ${resolvedPath}`} +

+
+ +
) diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts new file mode 100644 index 00000000000..dbcd8570fac --- /dev/null +++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + decideEnterAction, + decideEscAction, + filterEntries, + type DirEntry +} from './remote-file-browser-helpers' + +const entries: DirEntry[] = [ + { name: 'src', isDirectory: true }, + { name: 'docs', isDirectory: true }, + { name: 'README.md', isDirectory: false }, + { name: '.env', isDirectory: false }, + { name: 'node_modules', isDirectory: true } +] + +describe('filterEntries', () => { + it('substring-matches case-insensitively across files and folders', () => { + expect(filterEntries(entries, 'RE').map((e) => e.name)).toEqual(['README.md']) + expect(filterEntries(entries, 'o').map((e) => e.name)).toEqual(['docs', 'node_modules']) + }) + + it('returns the full list when filter is empty or whitespace', () => { + expect(filterEntries(entries, '')).toHaveLength(entries.length) + expect(filterEntries(entries, ' ')).toHaveLength(entries.length) + }) +}) + +describe('decideEnterAction', () => { + it('navigates when filter matches exactly one folder (files alongside do not block)', () => { + const filtered = filterEntries(entries, 'e') // README.md, .env, node_modules + expect(decideEnterAction(filtered)).toEqual({ type: 'navigate', name: 'node_modules' }) + }) + + it('is a no-op when multiple folders match', () => { + const filtered = filterEntries(entries, 's') // src, docs (+ no files with s) + // two folders → cannot disambiguate → noop + expect(decideEnterAction(filtered)).toEqual({ type: 'noop' }) + }) + + it('shows the file hint when only files match', () => { + const filtered = filterEntries(entries, 'readme') + expect(decideEnterAction(filtered)).toEqual({ type: 'fileHint' }) + }) + + it('is a no-op on empty filtered list', () => { + expect(decideEnterAction([])).toEqual({ type: 'noop' }) + }) +}) + +describe('decideEscAction', () => { + it('clears a non-empty filter', () => { + expect(decideEscAction('foo')).toEqual({ type: 'clearFilter' }) + }) + + it('cancels when filter is empty', () => { + expect(decideEscAction('')).toEqual({ type: 'cancel' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts new file mode 100644 index 00000000000..fa93b68c5d1 --- /dev/null +++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts @@ -0,0 +1,43 @@ +export type DirEntry = { + name: string + isDirectory: boolean +} + +export function filterEntries(entries: DirEntry[], filter: string): DirEntry[] { + const q = filter.trim().toLowerCase() + if (!q) { + return entries + } + return entries.filter((e) => e.name.toLowerCase().includes(q)) +} + +// Enter-key behavior for the filter input: +// (a) filtered set has exactly one folder → navigate into it +// (files alongside it don't block — a folder match wins) +// (b) filtered set has exactly one file and no folders → fileHint +// (c) otherwise → noop +export type EnterAction = + | { type: 'navigate'; name: string } + | { type: 'fileHint' } + | { type: 'noop' } + +export function decideEnterAction(filteredEntries: DirEntry[]): EnterAction { + const folders = filteredEntries.filter((e) => e.isDirectory) + if (folders.length === 1) { + return { type: 'navigate', name: folders[0].name } + } + if (folders.length === 0 && filteredEntries.length > 0) { + return { type: 'fileHint' } + } + return { type: 'noop' } +} + +export type EscAction = { type: 'clearFilter' } | { type: 'cancel' } + +export function decideEscAction(filter: string): EscAction { + return filter.length > 0 ? { type: 'clearFilter' } : { type: 'cancel' } +} + +export function joinPath(resolvedPath: string, name: string): string { + return resolvedPath === '/' ? `/${name}` : `${resolvedPath}/${name}` +} From 4ac2a8c4a120b1b1522b89a29a0973f8723344f9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:10:11 -0700 Subject: [PATCH 020/259] improve active worktree card visibility in light mode (#1241) * feat(sidebar): improve active worktree card visibility in light mode Co-authored-by: Orca * tweak active worktree card contrast and primary badge style Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/components/sidebar/WorktreeCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a2d4410741e..96115c43684 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -337,7 +337,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ className={cn( 'group relative flex items-start gap-2.5 px-2 py-2 rounded-lg cursor-pointer transition-all duration-200 outline-none select-none ml-1', isActive - ? 'bg-black/[0.06] shadow-[0_1px_2px_rgba(0,0,0,0.03)] border border-border/60 dark:bg-white/[0.10] dark:border-border/40' + ? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]' : 'border border-transparent hover:bg-accent/40', isDeleting && 'opacity-50 grayscale cursor-not-allowed', isSshDisconnected && !isDeleting && 'opacity-60' @@ -450,7 +450,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ primary From aec56be96ed4a34f88f63890f4bad7d846227a30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:18:25 +0000 Subject: [PATCH 021/259] release: v1.3.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ae36ad61e4..18419f6c7c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.24-rc.3", + "version": "1.3.24", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 2964e2d9aa62e3498a8ce6a41bce6893c6c8535a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:22:18 -0700 Subject: [PATCH 022/259] feat(terminal): auto-focus newly created terminal tab (#1244) Extracts the double-rAF xterm focus helper into a shared module and calls it from all four new-terminal entry points (Cmd+T, "+" menu, IPC-triggered, and per-group newTerminalTab/newTerminalWithShell). Previously only the "+" menu path focused the new tab, so keyboard and IPC-created terminals would drop the first keystroke. Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 7 ++++ .../src/components/tab-bar/TabBar.tsx | 32 ++----------------- .../tab-group/useTabGroupWorkspaceModel.ts | 3 ++ src/renderer/src/hooks/useIpcEvents.ts | 2 ++ .../src/lib/focus-terminal-tab-surface.ts | 22 +++++++++++++ 5 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/focus-terminal-tab-surface.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 97f9f0ad125..c45cabf8416 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -34,6 +34,7 @@ import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer' import { handleSwitchTab, handleSwitchTerminalTab } from '../hooks/ipc-tab-switch' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout @@ -400,6 +401,12 @@ function Terminal(): React.JSX.Element | null { const order = base.filter((id) => id !== newTab.id) order.push(newTab.id) setTabBarOrder(activeWorktreeId, order) + // Why: keyboard (Cmd/Ctrl+T) creation should leave the user ready to type + // in the new shell. Without an explicit focus call, the window-level + // keydown handler keeps focus on whatever surface dispatched the shortcut + // (often ), so the first keystroke is dropped instead of reaching + // the new xterm. Matches the "+" menu path in TabBar.tsx. + focusTerminalTabSurface(newTab.id) }, [activeWorktreeId, createTab, setActiveTabType, setTabBarOrder] ) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 2fd0d6873fe..9a9d96a90f3 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -3,7 +3,7 @@ * to a file that was already ~398 code lines on main. The per-type render * branches share little beyond drag data, so consolidating them would cost * more clarity than the ~5 lines of bloat is worth. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { SortableContext } from '@dnd-kit/sortable' import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react' import type { @@ -24,6 +24,7 @@ import type { HoveredTabInsertion, TabDragItemData } from '../tab-group/useTabDr import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { ShellIcon } from './shell-icons' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { DropdownMenu, DropdownMenuContent, @@ -225,27 +226,6 @@ function TabBarInner({ return indicators }, [activeIndicator, orderedItems]) - const focusTerminalTabSurface = useCallback((tabId: string) => { - // Why: creating a terminal from the "+" menu is a two-step focus race: - // React must first mount the new TerminalPane/xterm, then Radix closes the - // menu. Even after suppressing trigger focus restore, the terminal's hidden - // textarea may not exist until the next paint. Double-rAF waits for that - // commit so the new tab, not the "+" button, ends up owning keyboard focus. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const scoped = document.querySelector( - `[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea` - ) as HTMLElement | null - if (scoped) { - scoped.focus() - return - } - const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - fallback?.focus() - }) - }) - }, []) - // Horizontal wheel scrolling for the tab strip const tabStripRef = useRef(null) const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) @@ -508,10 +488,6 @@ function TabBarInner({ key={entry.shell} onSelect={() => { onNewTerminalWithShell(entry.shell) - const newActiveTabId = useAppStore.getState().activeTabId - if (newActiveTabId) { - focusTerminalTabSurface(newActiveTabId) - } }} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > @@ -528,10 +504,6 @@ function TabBarInner({ { onNewTerminalTab() - const newActiveTabId = useAppStore.getState().activeTabId - if (newActiveTabId) { - focusTerminalTabSurface(newActiveTabId) - } }} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index c2f97e8bb15..7c7a660a97b 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -17,6 +17,7 @@ import { createUntitledMarkdownFile } from '../../lib/create-untitled-markdown' import { getConnectionId } from '../../lib/connection-context' import { extractIpcErrorMessage } from '../../lib/ipc-error' import { destroyPersistentWebview } from '../browser-pane/BrowserPane' +import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface' export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } @@ -438,11 +439,13 @@ export function useTabGroupWorkspaceModel({ const terminal = createTab(worktreeId, groupId) setActiveTab(terminal.id) setActiveTabType('terminal') + focusTerminalTabSurface(terminal.id) }, newTerminalWithShell: (shellOverride: string) => { const terminal = createTab(worktreeId, groupId, shellOverride) setActiveTab(terminal.id) setActiveTabType('terminal') + focusTerminalTabSurface(terminal.id) }, pinFile, setTabColor, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 96a1827ac6d..74cce8e0dfb 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -17,6 +17,7 @@ import { handleSwitchTab, handleSwitchTerminalTab } from './ipc-tab-switch' import { dispatchClearModifierHints } from './useModifierHint' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { isGitRepoKind } from '../../../shared/repo-kind' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' export { resolveZoomTarget } from './resolve-zoom-target' @@ -476,6 +477,7 @@ export function useIpcEvents(): void { const order = base.filter((id) => id !== newTab.id) order.push(newTab.id) store.setTabBarOrder(worktreeId, order) + focusTerminalTabSurface(newTab.id) }) ) diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts new file mode 100644 index 00000000000..ab7f9e70f2a --- /dev/null +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -0,0 +1,22 @@ +/** + * Move keyboard focus into the xterm instance for a freshly-mounted terminal + * tab. Handles the two-step race where React must first mount the new + * TerminalPane/xterm before the hidden .xterm-helper-textarea exists — + * double-rAF waits for that commit so focus lands on the new tab instead of + * whatever surface (menu trigger, body, previous tab) just relinquished it. + */ +export function focusTerminalTabSurface(tabId: string): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const scoped = document.querySelector( + `[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea` + ) as HTMLElement | null + if (scoped) { + scoped.focus() + return + } + const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null + fallback?.focus() + }) + }) +} From eb92ba9448c7bfde23da7c05e86ee86deb10ebcf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:56:29 -0700 Subject: [PATCH 023/259] fix(terminal): advertise kitty keyboard protocol for Shift+Enter (#1247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orca's terminal already encodes Shift+Enter as the kitty CSI-u sequence `\x1b[13;2u`, but without `vtExtensions.kittyKeyboard` xterm.js never answers the `CSI ? u` probe. CLIs that gate enhanced input on that handshake (Claude Code, Codex, etc.) therefore drop the extended bytes and treat Shift+Enter as a plain Enter — most visibly when running inside tmux, which strips extended-key encodings by default. - Enable `vtExtensions.kittyKeyboard` in the default terminal options (matches VS Code's xtermTerminal). - Lock in the flag with a regression test in pane-lifecycle.test.ts. - Add docs/terminal-extended-keys.md explaining the Orca side and the tmux-side `set -s extended-keys on` + `terminal-features xterm*:extkeys` users need for nested Shift+Enter to reach a CLI. Verified end-to-end in Electron: `cat -v` + Shift+Enter now prints `^[[13;2u`, and `printf '\e[?u'` elicits the expected `CSI ? 0 u` reply from xterm.js. Co-authored-by: Orca --- docs/terminal-extended-keys.md | 66 +++++++++++++++++++ .../lib/pane-manager/pane-lifecycle.test.ts | 10 +++ .../lib/pane-manager/pane-terminal-options.ts | 12 +++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 docs/terminal-extended-keys.md diff --git a/docs/terminal-extended-keys.md b/docs/terminal-extended-keys.md new file mode 100644 index 00000000000..7b4ea52a26c --- /dev/null +++ b/docs/terminal-extended-keys.md @@ -0,0 +1,66 @@ +# Extended Key Chords in the Terminal (Shift+Enter, etc.) + +## What Orca sends + +Orca's built‑in terminal already encodes extended key chords using the +[kitty keyboard protocol][kitty] (CSI‑u). For example, **Shift+Enter** is sent +as the byte sequence: + +``` +ESC [ 1 3 ; 2 u (i.e. \x1b[13;2u) +``` + +See `src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts` +for the full table. + +Orca also advertises kitty‑protocol support to the running program via +`vtExtensions.kittyKeyboard` on xterm.js (see +`src/renderer/src/lib/pane-manager/pane-terminal-options.ts`), so CLIs that +probe with `CSI ? u` learn that the terminal speaks CSI‑u and enable their +enhanced input handlers. + +## Why Shift+Enter may not reach your CLI inside tmux + +tmux, by default, strips both extended‑key encodings (modifyOtherKeys +`CSI 27 ; 2 ; 13 ~` *and* kitty‑style `CSI 13 ; 2 u`). If you run Claude +Code, Codex, or any other CLI under tmux, Shift+Enter will look like a +plain `Enter` unless tmux is told to pass those bytes through. + +Add this to `~/.tmux.conf` (tmux 3.2+): + +```tmux +set -s extended-keys on +set -as terminal-features 'xterm*:extkeys' +``` + +Then reload: `tmux source-file ~/.tmux.conf` (or restart the tmux server). + +- `extended-keys on` — tell tmux to accept and forward the extended + encodings instead of collapsing them to the unshifted key. +- `terminal-features 'xterm*:extkeys'` — tell tmux that the surrounding + terminal (Orca, in this case) understands those encodings, so tmux is + willing to emit them. + +## Verifying end‑to‑end + +Inside an Orca terminal (no tmux), run: + +``` +cat -v +``` + +Press **Shift+Enter**. You should see: + +``` +^[[13;2u +``` + +That's caret notation for `\x1b[13;2u` — the expected CSI‑u encoding. If +you see `^M` (or a blank newline) instead, either the chord isn't reaching +the terminal (check `keyboard-handlers.ts` / `terminal-shortcut-policy.ts`) +or you're inside tmux without the config above. + +Inside tmux after the config, the same `cat -v` test should print the same +`^[[13;2u`. + +[kitty]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 223a7380fdc..41e3f02cec2 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -53,6 +53,16 @@ describe('buildDefaultTerminalOptions', () => { it('leaves macOS Option available for keyboard layout characters', () => { expect(buildDefaultTerminalOptions().macOptionIsMeta).toBe(false) }) + + it('advertises kitty keyboard protocol so CLIs enable enhanced key reporting', () => { + // Why: Orca already writes CSI-u bytes for extended key chords like + // Shift+Enter (see terminal-shortcut-policy.ts). CLIs that gate + // enhanced input on a CSI ? u handshake only read those bytes once the + // terminal advertises support. Regressing this flag silently breaks + // Shift+Enter (and other extended chords) in apps like Claude Code and + // Codex, especially when running inside tmux. + expect(buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard).toBe(true) + }) }) describe('attachWebgl', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 64d485e69f9..27e7c802e9d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -16,6 +16,16 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { // Why: on macOS, non-US layouts rely on Option to compose characters like @ and €. macOptionIsMeta: false, macOptionClickForcesSelection: true, - drawBoldTextInBrightColors: true + drawBoldTextInBrightColors: true, + // Why: advertise kitty keyboard protocol support so CLIs that probe + // (CSI ? u) know Orca accepts enhanced key reporting. Without this, + // Orca already writes \x1b[13;2u for Shift+Enter (see + // terminal-shortcut-policy.ts), but programs that respect the protocol + // handshake fall back to legacy encodings and ignore the CSI-u byte, + // making chords like Shift+Enter invisible to the app — especially + // noticeable inside tmux. Matches VS Code's xtermTerminal.ts. + vtExtensions: { + kittyKeyboard: true + } } } From 39a3893a489914897d7a022778fc8b83681fe40e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:56:53 +0000 Subject: [PATCH 024/259] release: v1.3.25-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18419f6c7c2..782b910420d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.24", + "version": "1.3.25-rc.0", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 6abb9b5e8e8118c12c2300211290b33e9438008f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:33:13 +0000 Subject: [PATCH 025/259] release: 1.3.25-rc.1 [rc-slot:2026-04-29-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 782b910420d..092f8d82d86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.25-rc.0", + "version": "1.3.25-rc.1", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 006272b25cefe82ce8bc550b0748e3db3fcdb440 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:47:28 -0700 Subject: [PATCH 026/259] Recolor agent 'done' state from sky blue to emerald green (#1246) Co-authored-by: Orca --- src/renderer/src/components/AgentStateDot.tsx | 8 +++-- .../components/dashboard/AgentDashboard.tsx | 6 ++-- .../components/sidebar/StatusIndicator.tsx | 34 +++++++++++++------ .../src/components/sidebar/WorktreeCard.tsx | 2 +- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 9cbf203351f..e4068db1749 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -58,7 +58,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ > @@ -76,9 +76,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({ 'block rounded-full', inner, state === 'blocked' || state === 'waiting' || state === 'permission' - ? 'bg-red-500 animate-pulse' + ? 'bg-red-500' : state === 'done' - ? 'bg-sky-500/80' + ? // Why: emerald-500 matches StatusIndicator's done dot so the + // dashboard and sidebar read as the same state. + 'bg-emerald-500' : 'bg-neutral-500/40' )} /> diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx index 2f35a29db43..32d9f548412 100644 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboard.tsx @@ -255,19 +255,19 @@ const AgentDashboard = React.memo(function AgentDashboard() { {groupRunning > 0 && ( - {groupRunning}{' '} + {groupRunning}{' '} active )} {groupBlocked > 0 && ( - {groupBlocked}{' '} + {groupBlocked}{' '} blocked )} {groupDone > 0 && ( - {groupDone}{' '} + {groupDone}{' '} done )} diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index c30abe71b50..b9de553010d 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,6 +1,6 @@ import React from 'react' import { cn } from '@/lib/utils' -import type { WorktreeStatus } from '@/lib/worktree-status' +import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' // Why: re-export WorktreeStatus under the existing `Status` alias so the // sidebar component and the canonical lib share one source of truth — the @@ -15,15 +15,25 @@ type StatusIndicatorProps = React.ComponentProps<'span'> & { const StatusIndicator = React.memo(function StatusIndicator({ status, className, + title, ...rest }: StatusIndicatorProps) { + // Why: surface the status label as a native tooltip so hovering the dot + // reveals the state — matters especially for 'active' vs 'inactive', which + // share the same grey dot (see color-branch comment below). Callers pass + // aria-hidden="true" alongside an sr-only label, so the `title` attribute + // is ignored by AT and only serves sighted users on hover. Callers can + // override by passing their own `title`. + const resolvedTitle = title ?? getWorktreeStatusLabel(status) + if (status === 'working') { return ( - + ) } @@ -31,20 +41,22 @@ const StatusIndicator = React.memo(function StatusIndicator({ return ( diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 96115c43684..03dbb82de72 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -157,7 +157,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ // that the spinner flickered; the blocked/waiting/done states don't have // that problem — they're terminal (done) or attention-needed (blocked/ // waiting) and persist until the user acts. Retained "done" snapshots are - // consulted too so the sky dot keeps glowing after the agent process exits, + // consulted too so the done dot keeps glowing after the agent process exits, // matching the dashboard's retention behavior. // // Priority (highest first): permission (blocked/waiting) > heuristic From 3a138a2f07f642390642db736a733580c50e9be3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:29:24 -0700 Subject: [PATCH 027/259] ci: stabilize pnpm native addon install in PR checks (#733) Co-authored-by: Orca --- .github/workflows/e2e.yml | 12 ++++++++++++ .github/workflows/pr.yml | 16 ++++++++++++++++ .github/workflows/release.yml | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ed6217f75a8..cbfabad4d16 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,6 +40,18 @@ jobs: with: run_install: false + # Why: this job runs the same pnpm install path as pr.yml's verify + # job, so it needs the same pinned node-gyp override to avoid pnpm's + # broken bundled gyp_main.py on Linux. Gate on runner.os matches + # release.yml so the invariant "this workaround is Linux-only" is + # consistent across all three workflows, even though this job + # currently pins runs-on: ubuntu-latest. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 678f7974689..1472f7472b7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,6 +29,22 @@ jobs: with: run_install: false + # Why: pnpm's bundled node-gyp ships gyp_main.py without execute + # permission, which breaks native module builds (e.g. node-pty's + # postinstall) with "/bin/sh: gyp_main.py: Permission denied". + # Pin the fallback to the lockfile's node-gyp version so CI stays + # reproducible while forcing pnpm to bypass its broken bundled copy. + # Gate on runner.os == 'Linux' to match release.yml — the + # npm-global path layout this step assumes is POSIX-shaped, and the + # failure has only been observed on Linux runners. Today this job + # pins runs-on: ubuntu-latest so the guard is a no-op, but it + # prevents a silent break if a Windows/macOS matrix is added later. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c4c210e15f..43a9170d22c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,21 @@ jobs: node-version-file: .nvmrc cache: pnpm + # Why: release builds hit the same native-module postinstall path as + # PR CI, so keep the pinned node-gyp override here too instead of + # relying on pnpm's bundled copy. Scoped to Linux via runner.os (not + # a specific matrix image) because the failing postinstall has only + # been observed on Linux runners — see run 25081763129. The macOS + # and Windows release jobs exercise the same pnpm install path and + # have not reproduced it, so keep the gate narrow until we know why. + # Using runner.os instead of matrix.os == 'ubuntu-latest' means the + # gate still works if another Linux matrix entry is added later. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # 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 From c85f487ebf98660700a5700a40ee495935289295 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:36:03 -0700 Subject: [PATCH 028/259] chore: move agent skills to internal delivery (#1254) Private skill files are now stored and versioned in a separate internal repo; a setup hook populates .claude/skills and .agents/skills with machine-local symlinks instead. This keeps contributor-facing patterns (agent skills in the tree) while allowing some skills to be developed privately. - Remove tracked skill files (62 files across 7 skill dirs). - Gitignore the now machine-local skill directories. - Add an env-gated hook to orca.yaml scripts.setup. Runs a setup script whose path lives in $ORCA_INTERNAL_DEV_SETUP when present; silently no-ops for public contributors. No new contributor-facing requirements: the hook is optional, the env var is only set by internal tooling, and the setup itself runs in the worktree that Orca just created. Co-authored-by: Orca --- .agents/skills/auto-pr-merge/SKILL.md | 321 --------- .agents/skills/auto-review-fix/SKILL.md | 650 ------------------ .agents/skills/auto-submit/SKILL.md | 48 -- .agents/skills/electron/SKILL.md | 310 --------- .agents/skills/react-useeffect/README.md | 341 --------- .agents/skills/react-useeffect/SKILL.md | 53 -- .../skills/react-useeffect/alternatives.md | 265 ------- .../skills/react-useeffect/anti-patterns.md | 289 -------- .agents/skills/typescript/SKILL.md | 95 --- .../typescript/assets/templates/_template.md | 26 - .../skills/typescript/references/_sections.md | 46 -- .../references/advanced-branded-types.md | 92 --- .../references/advanced-satisfies-operator.md | 95 --- .../advanced-template-literal-types.md | 105 --- .../references/async-avoid-loop-await.md | 73 -- .../async-avoid-unnecessary-async.md | 64 -- .../references/async-defer-await.md | 71 -- .../references/async-explicit-return-types.md | 77 --- .../references/async-parallel-promises.md | 64 -- .../references/mem-avoid-closure-leaks.md | 100 --- .../references/mem-avoid-global-state.md | 97 --- .../references/mem-cleanup-event-listeners.md | 111 --- .../typescript/references/mem-clear-timers.md | 106 --- .../mem-use-weakmap-for-metadata.md | 85 --- .../references/module-avoid-barrel-imports.md | 71 -- .../module-avoid-circular-dependencies.md | 98 --- .../module-control-types-inclusion.md | 93 --- .../references/module-dynamic-imports.md | 78 --- .../references/module-use-type-imports.md | 81 --- .../runtime-avoid-object-spread-in-loops.md | 86 --- .../runtime-cache-property-access.md | 67 -- .../runtime-prefer-array-methods.md | 77 --- .../runtime-use-for-of-for-iteration.md | 89 --- .../references/runtime-use-set-for-lookups.md | 67 -- .../references/runtime-use-string-methods.md | 78 --- .../references/safety-assertion-functions.md | 93 --- .../references/safety-const-assertions.md | 78 --- .../references/safety-exhaustive-checks.md | 84 --- .../safety-no-unchecked-indexed-access.md | 86 --- .../safety-prefer-unknown-over-any.md | 77 --- .../references/safety-strict-null-checks.md | 72 -- .../references/safety-use-type-guards.md | 86 --- .../references/tscfg-enable-incremental.md | 66 -- .../references/tscfg-erasable-syntax-only.md | 88 --- .../references/tscfg-exclude-properly.md | 80 --- .../references/tscfg-isolate-modules.md | 81 --- .../references/tscfg-isolated-declarations.md | 73 -- .../references/tscfg-project-references.md | 96 --- .../references/tscfg-skip-lib-check.md | 65 -- .../references/tscfg-strict-function-types.md | 65 -- .../references/type-avoid-deep-generics.md | 81 --- .../references/type-avoid-large-unions.md | 62 -- .../references/type-explicit-return-types.md | 66 -- .../type-extract-conditional-types.md | 54 -- .../type-interfaces-over-intersections.md | 42 -- .../references/type-limit-recursion-depth.md | 59 -- .../references/type-simplify-mapped-types.md | 67 -- .claude/skills/auto-review-fix | 1 - .claude/skills/electron | 1 - .claude/skills/react-useeffect | 1 - .claude/skills/review-and-submit/SKILL.md | 278 -------- .claude/skills/typescript | 1 - .gitignore | 4 + orca.yaml | 4 + 64 files changed, 8 insertions(+), 6372 deletions(-) delete mode 100644 .agents/skills/auto-pr-merge/SKILL.md delete mode 100644 .agents/skills/auto-review-fix/SKILL.md delete mode 100644 .agents/skills/auto-submit/SKILL.md delete mode 100644 .agents/skills/electron/SKILL.md delete mode 100644 .agents/skills/react-useeffect/README.md delete mode 100644 .agents/skills/react-useeffect/SKILL.md delete mode 100644 .agents/skills/react-useeffect/alternatives.md delete mode 100644 .agents/skills/react-useeffect/anti-patterns.md delete mode 100644 .agents/skills/typescript/SKILL.md delete mode 100644 .agents/skills/typescript/assets/templates/_template.md delete mode 100644 .agents/skills/typescript/references/_sections.md delete mode 100644 .agents/skills/typescript/references/advanced-branded-types.md delete mode 100644 .agents/skills/typescript/references/advanced-satisfies-operator.md delete mode 100644 .agents/skills/typescript/references/advanced-template-literal-types.md delete mode 100644 .agents/skills/typescript/references/async-avoid-loop-await.md delete mode 100644 .agents/skills/typescript/references/async-avoid-unnecessary-async.md delete mode 100644 .agents/skills/typescript/references/async-defer-await.md delete mode 100644 .agents/skills/typescript/references/async-explicit-return-types.md delete mode 100644 .agents/skills/typescript/references/async-parallel-promises.md delete mode 100644 .agents/skills/typescript/references/mem-avoid-closure-leaks.md delete mode 100644 .agents/skills/typescript/references/mem-avoid-global-state.md delete mode 100644 .agents/skills/typescript/references/mem-cleanup-event-listeners.md delete mode 100644 .agents/skills/typescript/references/mem-clear-timers.md delete mode 100644 .agents/skills/typescript/references/mem-use-weakmap-for-metadata.md delete mode 100644 .agents/skills/typescript/references/module-avoid-barrel-imports.md delete mode 100644 .agents/skills/typescript/references/module-avoid-circular-dependencies.md delete mode 100644 .agents/skills/typescript/references/module-control-types-inclusion.md delete mode 100644 .agents/skills/typescript/references/module-dynamic-imports.md delete mode 100644 .agents/skills/typescript/references/module-use-type-imports.md delete mode 100644 .agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md delete mode 100644 .agents/skills/typescript/references/runtime-cache-property-access.md delete mode 100644 .agents/skills/typescript/references/runtime-prefer-array-methods.md delete mode 100644 .agents/skills/typescript/references/runtime-use-for-of-for-iteration.md delete mode 100644 .agents/skills/typescript/references/runtime-use-set-for-lookups.md delete mode 100644 .agents/skills/typescript/references/runtime-use-string-methods.md delete mode 100644 .agents/skills/typescript/references/safety-assertion-functions.md delete mode 100644 .agents/skills/typescript/references/safety-const-assertions.md delete mode 100644 .agents/skills/typescript/references/safety-exhaustive-checks.md delete mode 100644 .agents/skills/typescript/references/safety-no-unchecked-indexed-access.md delete mode 100644 .agents/skills/typescript/references/safety-prefer-unknown-over-any.md delete mode 100644 .agents/skills/typescript/references/safety-strict-null-checks.md delete mode 100644 .agents/skills/typescript/references/safety-use-type-guards.md delete mode 100644 .agents/skills/typescript/references/tscfg-enable-incremental.md delete mode 100644 .agents/skills/typescript/references/tscfg-erasable-syntax-only.md delete mode 100644 .agents/skills/typescript/references/tscfg-exclude-properly.md delete mode 100644 .agents/skills/typescript/references/tscfg-isolate-modules.md delete mode 100644 .agents/skills/typescript/references/tscfg-isolated-declarations.md delete mode 100644 .agents/skills/typescript/references/tscfg-project-references.md delete mode 100644 .agents/skills/typescript/references/tscfg-skip-lib-check.md delete mode 100644 .agents/skills/typescript/references/tscfg-strict-function-types.md delete mode 100644 .agents/skills/typescript/references/type-avoid-deep-generics.md delete mode 100644 .agents/skills/typescript/references/type-avoid-large-unions.md delete mode 100644 .agents/skills/typescript/references/type-explicit-return-types.md delete mode 100644 .agents/skills/typescript/references/type-extract-conditional-types.md delete mode 100644 .agents/skills/typescript/references/type-interfaces-over-intersections.md delete mode 100644 .agents/skills/typescript/references/type-limit-recursion-depth.md delete mode 100644 .agents/skills/typescript/references/type-simplify-mapped-types.md delete mode 120000 .claude/skills/auto-review-fix delete mode 120000 .claude/skills/electron delete mode 120000 .claude/skills/react-useeffect delete mode 100644 .claude/skills/review-and-submit/SKILL.md delete mode 120000 .claude/skills/typescript diff --git a/.agents/skills/auto-pr-merge/SKILL.md b/.agents/skills/auto-pr-merge/SKILL.md deleted file mode 100644 index 42f4fed9d09..00000000000 --- a/.agents/skills/auto-pr-merge/SKILL.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -name: auto-pr-merge -description: Create PR, wait for checks, fix issues iteratively, and merge with --admin ---- - -# auto-pr-merge - -End-to-end autonomous PR workflow: create PR, wait for CI checks to pass, fix any issues (typecheck, code review), update PR, and merge with admin override. Runs fully autonomously without user confirmation. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do it. - ---- - -## Process Overview - -``` - CREATE PR → WAIT FOR CHECKS → FIX LOOP (max 3) → MERGE --admin - │ │ │ │ - /create-pr 1min + 1min polls diagnose CI gh pr merge - (up to ~6min) fix issues --admin - pn typecheck --squash - /review-code --delete-branch - commit & push - wait for checks -``` - ---- - -## Step 1: Create PR - -Run the `/create-pr` skill to create or update the pull request. - -``` -Use the Skill tool: skill: "create-pr" -``` - -After `/create-pr` completes, extract the PR number and URL: - -```bash -gh pr view --json number,url --jq '.number, .url' -``` - -Store the PR number for later use. - -**CRITICAL: DO NOT STOP after /create-pr completes. Continue to Step 2.** - ---- - -## Step 2: Wait for PR Checks - -Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result: - -- `PASSED` → Skip to Step 4 (MERGE) -- `FAILED` → Proceed to Step 3 (FIX LOOP) -- `NO_CHECKS` → Skip to Step 4 (MERGE) — no CI configured, nothing to wait for - ---- - -## Step 3: Fix Loop (Max 3 Iterations) - -Run up to **3 iterations** of the diagnose-fix-review-push cycle. - -### 3a. Diagnose Failures - -Identify which checks failed and get their logs: - -```bash -# Get the most recent failed run ID for this branch -FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId') -echo "Failed run: $FAILED_RUN" - -# View failed step logs (truncated to last 200 lines to avoid context bloat) -if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then - gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200 -fi -``` - -If `gh run view --log-failed` returns too much output or doesn't work, fall back to: - -```bash -gh run view "$FAILED_RUN" --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name, steps: [.steps[] | select(.conclusion == "failure") | {name, conclusion}]}' -``` - -### 3b. Fix Issues - -Based on the failure diagnosis: - -1. **Read the error output** from the failed checks -2. **Identify the root cause** (build error, lint error, test failure, type error, etc.) -3. **Apply fixes** using the Edit tool -4. **Delegate to specialized skills when appropriate**: - - **Lint errors**: Run `/fix-lint` skill - - **Build errors**: Run `/fix-build` skill - - **Test failures**: Read failing test, fix the code or test manually - -### 3c. Run Typecheck - -After applying fixes, verify types are clean: - -```bash -pn typecheck 2>&1 -``` - -- If typecheck passes: continue to 3d -- If typecheck fails: fix type errors and re-run (up to 3 attempts within this step) - - Read the error output - - Fix the type errors - - Re-run `pn typecheck` -- If still failing after 3 attempts: move on to 3d anyway (CI will catch remaining issues) - -### 3d. Run Code Review (Quick Round) - -Run **only** `/review-code` to catch any issues introduced by the fixes. Do NOT run `/review-correctness`, `/review-via-codex`, `/review-algorithm-architecture`, or any other review skill — only `/review-code`: - -``` -Use the Skill tool: skill: "review-code" -``` - -After review completes: - -- If **Critical or High** issues found: fix them using the Edit tool, then re-run `pn typecheck` to verify the review fixes don't introduce type errors -- If only **Medium or Low** issues: acceptable, continue -- If **no issues**: continue - -### 3e. Update PR - -Commit and push only if there are actual changes: - -```bash -# Check if there are changes to commit -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address CI failures and review feedback" - git push --force-with-lease -else - echo "NO_CHANGES" -fi -``` - -- If `NO_CHANGES`: No fixes were needed/possible. Exit the fix loop and proceed to Step 4. -- If changes were pushed: continue to 3f. - -### 3f. Wait for PR Checks Again - -Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result: - -- `PASSED` → Exit fix loop, proceed to Step 4 -- `FAILED` → Next iteration of fix loop (back to 3a) -- `NO_CHECKS` → Exit fix loop, proceed to Step 4 - -If max 3 fix iterations reached, proceed to Step 4 anyway. - ---- - -## Step 4: Merge with --admin - -Merge the PR using admin override with squash merge. - -**IMPORTANT: Worktree detection.** When running from a git worktree, `--delete-branch` will fail because `gh` tries to checkout the default branch locally, but it's already checked out in the main worktree. Detect this and handle accordingly: - -```bash -# Check if we're in a worktree (not the main working tree) -IS_WORKTREE=false -if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then - IS_WORKTREE=true -fi - -BRANCH=$(git branch --show-current) - -if [ "$IS_WORKTREE" = "true" ]; then - # In a worktree: merge WITHOUT --delete-branch, then delete remote branch separately - gh pr merge --admin --squash - # Delete the remote branch manually (local cleanup happens when worktree is removed) - git push origin --delete "$BRANCH" 2>/dev/null || true -else - # Normal repo: use --delete-branch as usual - gh pr merge --admin --squash --delete-branch -fi -``` - -- `--admin` bypasses branch protection rules (required reviews, status checks) -- `--squash` squashes all commits into one clean commit -- `--delete-branch` cleans up the feature branch after merge (skipped in worktrees to avoid checkout conflict) - -If merge fails: - -1. Check the error message -2. If the error contains `'master' is already used by worktree` or similar: retry without `--delete-branch` and delete the remote branch manually with `git push origin --delete ` -3. If merge conflicts: use Skill tool with `skill: "resolve-conflicts"`, push, then retry merge once -4. If other error: report the full error to the user - ---- - -## Check Polling Procedure - -This is the shared polling logic used by Step 2 and Step 3f. **Do NOT run this as a single long bash command** — the Bash tool will timeout. Instead, run each poll as a **separate Bash call**. - -**IMPORTANT: Minimize token waste.** Do NOT add verbose commentary between polls. Just run the wait, run the check, and act on the result. No cheerful status messages, no "patience is key", no filler text. - -### Initial wait - -After a push, CI takes time to run. Wait **1 minute** before the first poll: - -```bash -echo "Waiting 1 minute for CI checks to run..." && sleep 60 -``` - -**IMPORTANT**: You MUST actually execute the `sleep 60` command and wait for it to complete. Do NOT skip the sleep or claim you waited without running the command. The sleep ensures CI has time to finish. - -**CRITICAL**: When calling the Bash tool for `sleep 60`, you MUST set `timeout: 120000` (2 minutes) on the Bash tool call. Similarly, for `sleep 60` poll interval calls, set `timeout: 120000` (2 minutes). - -### Poll loop (run each poll as a separate Bash call) - -For each poll attempt (1 through 5): - -```bash -CHECKS=$(gh pr checks --json name,state,bucket 2>&1) -echo "$CHECKS" - -# Parse results using 'bucket' field (pass, fail, pending, skipping, cancel) -if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then - echo "RESULT:NO_CHECKS" -elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then - echo "RESULT:PASSED" -elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then - echo "RESULT:FAILED" -else - echo "RESULT:PENDING" -fi -``` - -### Decision logic after each poll - -- `RESULT:PASSED` → Return `PASSED`. Stop polling. -- `RESULT:FAILED` → Return `FAILED`. Stop polling. -- `RESULT:PENDING` → Wait **1 minute**, then poll again (use `timeout: 120000` on the Bash tool call): - ```bash - sleep 60 - ``` -- `RESULT:NO_CHECKS` → Return `NO_CHECKS` (no CI configured). - -### After 5 polls (timeout) - -If checks are still pending after 5 polls (~6 min total), return `FAILED` to enter the fix loop for investigation. - -### Handling stale checks after re-push (Step 3f only) - -After pushing new commits in Step 3e, old check results may linger briefly. To avoid reading stale results: - -1. Record the latest commit SHA **before pushing**: `OLD_SHA=$(git rev-parse HEAD)` -2. After pushing, the first 1-2 polls should verify the check suite is for the **new** commit. If `gh pr checks` still shows the old results (check names match but they completed instantly), wait an extra 30s. -3. Alternatively, look for checks with `state: "IN_PROGRESS"` or `"QUEUED"` as a signal that fresh checks have started. - ---- - -## Exit Conditions - -- **Success**: PR merged successfully -- **Max fix iterations**: After 3 fix loop iterations, attempt merge with --admin regardless -- **No changes to fix**: Fix loop produced no changes, merge with --admin -- **No CI checks**: Repo has no checks configured, merge immediately -- **Unrecoverable error**: PR creation fails, merge fails after retry, or gh CLI issues - ---- - -## Progress Tracking - -Display progress after each major step: - -``` -╔════════════════════════════════════════════════════════════╗ -║ AUTO PR MERGE ║ -╠════════════════════════════════════════════════════════════╣ -║ Step 1 - Create PR: ✅ PR #123 created ║ -║ Step 2 - Wait for checks: ❌ 2/5 checks failed ║ -║ Step 3 - Fix Loop: ║ -║ Iteration 1/3: ║ -║ Diagnose: ✅ Type errors in 2 files ║ -║ Fix: ✅ Fixed type errors ║ -║ Typecheck: ✅ Passed ║ -║ Review: ✅ No critical issues ║ -║ Push: ✅ Updated PR ║ -║ Checks: ✅ All passed ║ -║ Step 4 - Merge: ✅ Merged with --admin --squash ║ -╠════════════════════════════════════════════════════════════╣ -║ PR URL: https://github.com/org/repo/pull/123 ║ -║ Result: Successfully merged! ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** - Execute the entire workflow autonomously -2. **DO NOT stop after /create-pr** - The PR creation is just step 1 of 4 -3. **DO NOT stop after checks pass** - Must complete the merge step -4. **DO NOT run polling as a single long bash loop** - Each poll must be a separate Bash call to avoid the 2-minute Bash timeout. Use `sleep` in its own Bash call between polls. -5. **DO wait the full polling period** - Don't skip check waiting; CI needs time -6. **DO run typecheck before review** - Catch type errors early -7. **DO run /review-code in each fix iteration** - Ensure code quality -8. **DO check for actual changes before committing** - Skip commit/push if `git status --porcelain` is empty -9. **DO use --admin for merge** - This is intentional to bypass protection rules -10. **DO use --squash for merge** - Keep git history clean -11. **DO delete branch after merge** - Clean up with --delete-branch -12. **Maximum 3 fix iterations** - Don't loop forever; after 3, merge with --admin -13. **DO NOT run /review-correctness or /review-via-codex** - This skill only uses `/review-code` for quick checks in the fix loop. Codex-based reviews are slow and belong in `/auto-review-fix`, not here. -14. **DO actually execute sleep commands** - When the skill says `sleep 60`, you MUST run the bash command and wait for it to complete. Do not skip or fabricate the wait. -15. **DO set timeout on Bash tool for sleep commands** - `sleep 60` requires `timeout: 120000`. The default 2-minute Bash timeout is sufficient for 1-minute sleeps but set it explicitly for safety. - ---- - -## Error Handling - -- If `/create-pr` fails: Report error and exit -- If `gh` CLI is not installed or authenticated: Report and exit -- If no CI checks are configured: Skip waiting and merge directly -- If check polling times out: Enter fix loop to investigate -- If typecheck loops more than 3 times within a single fix iteration: Move on to review -- If fix loop produces no changes: Exit loop and merge with --admin -- If merge fails due to conflicts: Try `/resolve-conflicts`, push, then retry merge once -- If merge fails for other reasons: Report the error to the user with the full error output diff --git a/.agents/skills/auto-review-fix/SKILL.md b/.agents/skills/auto-review-fix/SKILL.md deleted file mode 100644 index 8edd67151b3..00000000000 --- a/.agents/skills/auto-review-fix/SKILL.md +++ /dev/null @@ -1,650 +0,0 @@ ---- -name: auto-review-fix -description: Automated iterative code review and fix loop with parallel review agents ---- - -# auto-review-fix - -Automated iterative code review and fix loop. Reviews all code changes on the current branch since it diverged from main, including uncommitted changes (staged and unstaged). Validates findings, fixes issues, and repeats until clean or max iterations reached. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do the iterations. - ---- - -## Process Overview - -Run up to **4 iterations** of the review-validate-fix cycle. Stop early ONLY when a **follow-up review confirms no issues to fix** remain. - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ITERATION LOOP (max 4 rounds) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 0. SETUP PHASE (first iteration only) │ │ -│ │ - Fetch diff and create 00-review-context.md │ │ -│ │ - Categorize files by review area │ │ -│ │ - Deduplicate file assignments │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 1. PARALLEL REVIEW PHASE │ │ -│ │ - Spawn Task() subagents for each review area │ │ -│ │ - Each receives ONLY their relevant files │ │ -│ │ - All reference shared 00-review-context.md │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 2. COMBINE & DEDUPLICATE │ │ -│ │ - Aggregate all findings │ │ -│ │ - Deduplicate across review areas │ │ -│ │ - Exclude previously-skipped issues │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 3. VALIDATE PHASE (all severities) │ │ -│ │ - Validate all issues (Critical to Low) │ │ -│ │ - Skip issues already in "Skipped Issues" list │ │ -│ │ - Group by file, one agent per file │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 4. FIX PHASE │ │ -│ │ - Group issues by file (up to 5 per agent) │ │ -│ │ - Each fix done via Task() with opus │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 5. CHECK EXIT CONDITIONS │ │ -│ │ - Fixed issues? → MUST run another review │ │ -│ │ - Review shows no issues? → EXIT │ │ -│ │ - Iteration 4 reached? → EXIT │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## Phase 0: Setup (First Iteration Only) - -### Step 1: Fetch the diff - -```bash -# Get the merge base (where the branch diverged from main) -git diff $(git merge-base origin/main HEAD) -``` - -### Step 1.5: Check diff size - -```bash -# Count changed files -git diff $(git merge-base origin/main HEAD) --stat | tail -1 -# If >500 files changed, exit with recommendation to split PR -``` - -If the diff contains more than 500 changed files, **EXIT immediately** with: - -``` -❌ Diff too large (>500 files). Please split this PR into smaller, focused changes. -``` - -### Step 2: Create shared context file - -Create `00-review-context.md` in the working directory (see below for format). - -### Step 2.5: Extract changed line ranges - -Parse `git diff $(git merge-base origin/main HEAD) --unified=0` to extract line ranges (`@@ +A,B @@` → lines A to A+B-1). Add to context file below. - -### Step 2 (continued): Context file format - -Create `00-review-context.md` in the working directory with: - -```markdown -# Review Context - -## Branch Info - -- Base: origin/main -- Current: [branch name] - -## Changed Files Summary - -[List all changed files with their change type: A/M/D] - -## Changed Line Ranges (PR Scope) - - - -| File | Changed Lines | -| ------------------ | ---------------- | -| [path/to/file1.ts] | [45-67, 120-135] | -| [path/to/file2.ts] | [10-25] | - -## Review Standards Reference - -- Follow /review-code standards -- Focus on: correctness, security, performance, maintainability -- Priority levels: Critical > High > Medium > Low - -## File Categories - -[Categorized list - see below] - -## Skipped Issues (Do Not Re-validate) - - - - - -[Initially empty - populated during validation phase] - -## Iteration State - - - -Current iteration: 1 -Last completed phase: Setup -Files fixed this iteration: [] -``` - -**IMPORTANT**: The "Skipped Issues" section persists across iterations. However, skips should be RARE - only purely cosmetic issues (naming, JSDoc, import ordering) may be skipped. Functional issues like error handling, type safety, and performance must ALWAYS be fixed. - -### Step 3: Categorize and deduplicate files - -Assign each file to **exactly ONE** file category (no duplicates): - -| Priority | Category | File Patterns | -| -------- | -------------- | ---------------------------------------------------------------- | -| 1 | Electron/Main | `src/main/`, `src/preload/`, `electron.*` | -| 2 | Backend/IPC | `*ipc*`, `*handler*`, `*service*` (in main process) | -| 3 | Frontend/UI | `src/renderer/`, `components/`, `*.tsx`, `*.css` | -| 4 | Config/Build | `*.config.*`, `package.json`, `tsconfig.*`, `electron-builder.*` | -| 5 | Utility/Common | Everything else | - -**Deduplication rule**: A file belongs to the FIRST matching category only. - -**Tiebreaker rule**: If a file path matches multiple categories at different directory depths: - -1. Use the **deepest matching directory** (e.g., `src/main/services/ui/dialog.ts` → Backend/IPC because `services/` is deeper than `main/`) -2. If same depth, use priority order (lower number wins) - -### Step 4: Map categories to review commands - -Each file category gets reviewed by multiple specialized review commands: - -| Category | Review Commands | -| -------------- | ------------------------------------------------ | -| Electron/Main | `/review-code`, `/review-algorithm-architecture` | -| Backend/IPC | `/review-code`, `/review-algorithm-architecture` | -| Frontend/UI | `/review-code`, `/review-algorithm-architecture` | -| Config/Build | `/review-code` | -| Utility/Common | `/review-code`, `/review-algorithm-architecture` | - -**Total review agents**: (Categories with files) × (Applicable review commands per category) - ---- - -## Phase 1: Parallel Review - -Spawn review subagents using the Task tool. **CRITICAL**: Use the exact Task() definition format. - -### Review Command Descriptions - -| Command | Focus | Engine | -| -------------------------------- | ------------------------------------------------------------------------ | ------ | -| `/review-code` | Logical bugs, security issues, TypeScript best practices, error handling | Claude | -| `/review-algorithm-architecture` | File organization, module boundaries, performance on hot paths | Claude | - -### Spawn agents by category × review command - -For each (category, review_command) pair where files exist, spawn a Task: - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for quality reviews - description: "[REVIEW_COMMAND] for [CATEGORY] files", - prompt: """ - Read 00-review-context.md for branch context and changed line ranges. - - You are running [REVIEW_COMMAND] on [CATEGORY] files. - - Review ONLY these files: - [SPECIFIC FILE LIST FOR THIS CATEGORY] - - **SCOPE RESTRICTION**: - Report issues on changed lines OR caused by the changes (e.g., broken callers, type mismatches). - Do NOT report pre-existing issues unrelated to this PR. - For issues outside changed lines, include a "Causal Link" explaining the connection. - - Changed line ranges: [FROM 00-review-context.md] - - Follow the [REVIEW_COMMAND] standards exactly. Focus on issues relevant to that review type. - - Output format for each finding: - - File: path/to/file.ts - - Line: 42 - - Severity: Critical|High|Medium|Low - - Review Type: [REVIEW_COMMAND] - - Causal Link: [required if outside changed lines] - - Issue: [description] - - Fix: [suggested fix] - """ -) -``` - -### Example agent spawning for a PR with Electron, Backend, and Frontend files: - -``` -# Electron/Main files → 2 agents -Task(..., description: "/review-code for Electron/Main files", ...) -Task(..., description: "/review-algorithm-architecture for Electron/Main files", ...) - -# Backend/IPC files → 2 agents -Task(..., description: "/review-code for Backend/IPC files", ...) -Task(..., description: "/review-algorithm-architecture for Backend/IPC files", ...) - -# Frontend/UI files → 2 agents -Task(..., description: "/review-code for Frontend/UI files", ...) -Task(..., description: "/review-algorithm-architecture for Frontend/UI files", ...) - -Total: 6 parallel review agents -``` - -**Launch ALL Task() calls in a single message block for true parallelism.** - -**IMPORTANT — WAIT FOR ALL AGENTS**: - -- Do NOT use `run_in_background: true` for any Phase 1 review agent. -- Task() calls without `run_in_background` are blocking by default — they return only when the subagent finishes. -- **VERIFICATION CHECKPOINT**: Before starting Phase 2, you MUST count the number of Task() results you received. It MUST equal the total number of Task() calls you made. If any result is missing, DO NOT proceed — wait or re-spawn the missing agent. - ---- - -## Phase 2: Combine & Deduplicate Results - -**⛔ GATE CHECK**: Before starting Phase 2, verify you received results from ALL Task() agents spawned in Phase 1. Count: [N agents] = [total]. If any agent result is missing, DO NOT proceed. Log which agent(s) are missing and note it in the final report. - -1. **Collect all subagent outputs** (from all review types) - -2. **Scope filter**: Discard findings outside changed lines that lack a Causal Link. Pass others to validation. - -3. **Deduplicate findings** across review types and categories: - - Same file + same line range (within 5 lines) + similar issue = duplicate - - Keep the more specific/actionable finding - - Merge severity (take highest) - - Preserve the review type that caught the issue - -4. **Exclude previously-skipped issues**: - - Read the "Skipped Issues" section from `00-review-context.md` - - For each new finding, check if it matches a skipped issue (same file + overlapping line range + similar description) - - If match found → exclude from validation (already determined not worth fixing) - - This prevents re-validating the same low-priority issues every iteration - -5. **Categorize remaining issues**: - - 🔴 **Critical** - Must fix - - 🟠 **High** - Should fix - - 🟡 **Medium** - Consider fixing - - 🟢 **Low** - Nice to have - -6. **Create findings report** with all non-skipped issues (all severities), including which review type found each issue - -7. **Severity escalation** (iterations 2+): - - If an issue was marked "✅ Fix" in iteration N but reappears in iteration N+1: - - Escalate severity by one level: Low → Medium → High → Critical - - Add note: "⚠️ Persisted from iteration N - previous fix may have been incomplete" - - This ensures recurring issues get higher priority attention - ---- - -## Phase 3: Validation (All Severities) - -Validate all issues that weren't excluded by the "Skipped Issues" list. - -### Grouping strategy: - -- Group findings by file -- One validation agent per file (handles all findings for that file) -- Maximum 10 validation agents total - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for accurate validation - description: "Validate findings in [filename]", - prompt: """ - Validate these findings for [FILE PATH]: - - [LIST OF 1-N FINDINGS FOR THIS FILE] - - **Changed line ranges**: [FROM 00-review-context.md] - - **SCOPE CHECK (first step)**: Issues on changed lines are in scope. For issues outside changed lines, validate the Causal Link - reject as 🚫 Out of Scope if it's just pre-existing tech debt unrelated to PR changes. - - For each IN-SCOPE finding: - 1. Read the actual code at the reported location - 2. Verify the issue actually exists - 3. Check if the suggested fix is correct - 4. Determine if it's worth fixing - - Mark each as: - - ✅ **Fix** - Valid issue in changed code (default - when in doubt, fix it) - - ⏭️ **Skip** - Valid but genuinely not worth fixing (RARE - see strict criteria below) - - ❌ **False Positive** - Issue doesn't actually exist - - 🚫 **Out of Scope** - Issue exists but is in pre-existing code not changed by this PR - - **STRICT Skip Criteria** - Only skip if ALL of these are true: - 1. The issue is purely cosmetic/stylistic with zero functional impact - 2. Fixing it would require changing >50 lines of unrelated code - 3. The pattern is consistently used throughout the codebase (not just this file) - - **DO NOT SKIP these issues (always fix):** - - ❌ Silent error swallowing or empty catch blocks - - ❌ Type coercion hacks (e.g., `as unknown as X`, `as any`) - - ❌ Missing error handling or error messages - - ❌ Redundant code, dead code, or unused variables - - ❌ Security issues of any severity - - ❌ Race conditions or async issues - - ❌ Memory leaks or resource cleanup issues - - ❌ Any issue in NEW code (code added in this PR) - - ❌ IPC security issues (missing validation, exposed handlers) - - Examples of what CAN be skipped: - - Pure naming preferences when existing codebase uses the same style - - Adding JSDoc to functions that are already self-documenting - - Reordering imports when not violating any lint rules - - For each Skip decision, provide: - - File and line range - - Severity level - - Reason for skipping (must match one of the allowed skip criteria above) - - Brief issue summary - """ -) -``` - -### After validation: Update Skipped Issues - -For any issues marked as ⏭️ **Skip**, append them to the "Skipped Issues" section in `00-review-context.md`: - -```markdown -## Skipped Issues (Do Not Re-validate) - - - -src/utils/helper.ts:42-45 | Low | Stylistic preference | Variable naming convention -``` - -**This ensures the same issues are not re-validated in subsequent iterations.** - -**Out of Scope** issues are discarded (not tracked). Issues outside changed lines CAN be in scope if caused by PR changes. - -### Early exit check - -After validation completes, check if there are any actionable issues: - -``` -IF (all_issues_marked_FP_or_Skip): - → Update Skipped Issues list in 00-review-context.md - → Update Iteration State: "Last completed phase: Validation (no actionable issues)" - → EXIT - No actionable issues found - → Do NOT proceed to Phase 4 -``` - -This prevents spawning fix agents when there's nothing to fix. - ---- - -## Phase 4: Fix - -**CRITICAL**: All fixes MUST be done via Task() subagents with opus. No direct edits. - -### Step 1: Commit uncommitted changes first - -```bash -git status --porcelain -# If output exists: -git add -A && git commit -m "WIP: Changes before auto-review fixes" -``` - -### Step 2: Build the fix manifest from validation output - -**MANDATORY**: Create an explicit mapping from validation results to fix actions. - -For every issue marked "✅ Fix" in validation: - -1. Record: `[file] → [issue description] → [suggested fix]` -2. Group by file - -**Traceability requirement**: Every "✅ Fix" issue MUST appear in the fix manifest. Do not: - -- Skip issues because you believe a different fix "solves the root cause" -- Substitute your judgment for what validation explicitly said to fix -- Rationalize away issues as "will be handled by another fix" - -If validation says "✅ Fix" for file X, spawn a fix agent for file X. Period. - -### Step 3: Group validated issues by file - -- Group up to **5 issues per fix agent** (within same file) -- If a file has >5 issues, split into multiple agents -- **Maximum 10 fix agents** per iteration -- If >10 files need fixes, prioritize by severity (Critical > High > Medium > Low) -- Defer lower-priority files to the next iteration - -### Conflict prevention - -To avoid race conditions when multiple agents edit overlapping code: - -- Each file should have **at most ONE fix agent running at a time** -- If a file has >5 issues requiring multiple agents, spawn them **sequentially** (not in parallel) -- After each fix agent completes for a multi-agent file, verify the file is syntactically valid before spawning the next -- Update Iteration State after each agent completes: `Files fixed this iteration: [updated list]` - -### Step 4: Spawn fix subagents - -``` -Task( - subagent_type: "general-purpose", - model: "opus", // REQUIRED: opus for quality fixes - description: "Fix issues in [filename]", - prompt: """ - Fix the following validated issues in [FILE PATH]: - - Issue 1: [description] - - Line: XX - - Fix: [suggested fix] - - Issue 2: [description] - - Line: YY - - Fix: [suggested fix] - - [Up to 5 issues] - - Instructions: - 1. Read the file first - 2. Apply each fix using the Edit tool - 3. Verify fixes don't break syntax - 4. Report what was fixed - """ -) -``` - -### Step 5: Track changes - -Record all files modified for the iteration summary. - -### Step 5.5: Verify fixes don't break build - -After all fix agents complete, run a type check: - -```bash -npm run typecheck 2>&1 -``` - -If the type check fails: - -- Log which fix agent(s) likely caused the failure -- Continue to Phase 5 - the next iteration's review will catch the type errors as new issues -- Update Iteration State: `Build status: FAILED (type errors in [files])` - -### Step 6: Verify fix manifest completeness - -Before proceeding to Phase 5, verify: - -- Every file in the fix manifest had a fix agent spawned -- Every "✅ Fix" issue from validation was addressed by a fix agent -- No issues were skipped due to assumptions about "root cause" fixes elsewhere - -If any "✅ Fix" issue was not addressed, spawn additional fix agents now. - ---- - -## Phase 5: Exit Conditions - -**CRITICAL LOGIC**: You cannot exit with "no issues" immediately after fixing. Must confirm with another review. - -``` -IF (fixes_applied_this_iteration > 0): - → MUST run another iteration to confirm fixes work - → Cannot claim "no issues" without verification - -IF (review_found_no_issues_to_fix AND fixes_applied_this_iteration == 0): - → EXIT - Clean (all new findings either fixed or added to Skipped Issues) - -IF (iteration >= 4): - → EXIT - Max iterations reached - -IF (same_issues_persist_for_2_iterations): - → EXIT - Stuck in loop - -OTHERWISE: - → Continue to next iteration -``` - -**The key insight**: "I fixed all issues" ≠ "There are no issues". Another review round must confirm. - -**Note on convergence**: The loop converges because: - -1. Fixed issues no longer appear in subsequent reviews -2. Skipped issues are tracked and excluded from future validation -3. Only genuinely new issues trigger additional work - ---- - -## Iteration Tracking - -Display progress after each phase: - -``` -╔════════════════════════════════════════════════════════════╗ -║ ITERATION 1/4 ║ -╠════════════════════════════════════════════════════════════╣ -║ Phase 0 - Setup: ✅ Context file created ║ -║ Phase 1 - Review: ✅ 6 agents across 2 review types ║ -║ /review-code: 3 agents (all categories) ║ -║ /review-algorithm-architecture: 3 agents ║ -║ Phase 2 - Combine: ✅ 12 findings (3 out-of-scope, 0 skipped) ║ -║ Phase 3 - Validate: ✅ 8 fix, 2 skip, 1 out-of-scope, 1 FP ║ -║ Phase 4 - Fix: ✅ 8 issues fixed (opus) ║ -║ Phase 5 - Check: 🔁 Fixes applied → verify next round ║ -║ Skipped Issues: 📝 2 added to tracking list ║ -╚════════════════════════════════════════════════════════════╝ -→ Starting iteration 2 to verify fixes... -``` - ---- - -## Final Report - -``` -╔════════════════════════════════════════════════════════════╗ -║ AUTO REVIEW-FIX COMPLETE ║ -╠════════════════════════════════════════════════════════════╣ -║ Iterations completed: X/4 ║ -║ Exit reason: [Clean review | Max iterations | Loop stuck] ║ -║ Total issues found: XX ║ -║ Out of scope: XX (pre-existing code, not in PR) ║ -║ Issues fixed: XX ║ -║ Issues skipped: XX (tracked, won't revalidate) ║ -║ Issues remaining: XX ║ -╠════════════════════════════════════════════════════════════╣ -║ Review agents by type: ║ -║ /review-code: X agents ║ -║ /review-algorithm-architecture: X agents ║ -║ Fix agents used: X (opus) ║ -╠════════════════════════════════════════════════════════════╣ -║ Issues found by review type: ║ -║ /review-code: XX issues ║ -║ /review-algorithm-architecture: XX issues ║ -╠════════════════════════════════════════════════════════════╣ -║ Files modified: ║ -║ - path/to/file1.ts ║ -║ - path/to/file2.ts ║ -╠════════════════════════════════════════════════════════════╣ -║ Skipped issues (should be RARE - cosmetic only): ║ -║ ⏭️ [file:line] Reason - Description ║ -╠════════════════════════════════════════════════════════════╣ -║ Remaining issues (if any): ║ -║ 🔴 [file:line] Description ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Cleanup - -After generating the final report, clean up the context file: - -```bash -# Create .context directory if it doesn't exist -mkdir -p .context - -# Archive the context file with timestamp for debugging/audit -mv 00-review-context.md .context/auto-review-$(date +%Y%m%d-%H%M%S).md - -# Optional: Keep only the last 5 archived reviews to prevent accumulation -ls -t .context/auto-review-*.md 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null -``` - -**Important**: Clean up even on error - wrap the cleanup in the error handling flow. - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** - Execute autonomously -2. **DO NOT skip the verification review** - After fixes, another review MUST confirm -3. **DO NOT skip validated issues** - Every "✅ Fix" from validation MUST have a fix agent spawned for that file. No exceptions, no rationalization. -4. **DO use Task() for ALL review/validate/fix work** - Never do these inline -5. **DO use opus for all agents** - Quality matters -6. **DO pass only relevant files to each area** - Never the full diff -7. **DO deduplicate files across areas** - Each file to one area only -8. **DO validate all severities** - But skip issues already in "Skipped Issues" list -9. **DO update Skipped Issues** - When validator says "not worth fixing", add to list -10. **DO group fixes by file** - Up to 5 issues per fix agent -11. **DO clean up 00-review-context.md** when done -12. **BE STRICT about skipping** - Skip should be RARE. If in doubt, fix it. Silent error swallowing, type hacks, and redundant code should NEVER be skipped. -13. **DO set agent timeouts** - Review agents: 7min, Validation agents: 3min, Fix agents: 5min. If an agent times out, log the timeout and continue. -14. **DO update Iteration State** - After each phase completes, update the Iteration State section in 00-review-context.md to enable crash recovery - ---- - -## Token Efficiency Summary - -| Before | After | Improvement | -| -------------------------- | ----------------------------------- | --------------------------- | -| Full diff to all agents | Relevant files only per category | ~6x reduction | -| Generic review for all | Specialized review commands | Better issue coverage | -| Re-validate skipped issues | Track in Skipped Issues list | ~2x reduction per iteration | -| 1 agent per finding | 1 agent per file (up to 5 issues) | ~5x reduction | -| No deduplication | Files assigned to one category only | ~2x reduction | - ---- - -## Error Handling - -- If a subagent fails, log the error and continue with others -- If the diff is too large (>500 files), split into batches -- If fixes cause build failures, revert and report -- If stuck in a loop (same issues persist for 2 iterations), exit with report -- Clean up 00-review-context.md even on error diff --git a/.agents/skills/auto-submit/SKILL.md b/.agents/skills/auto-submit/SKILL.md deleted file mode 100644 index fb32f05eac8..00000000000 --- a/.agents/skills/auto-submit/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: auto-submit -description: End-to-end autonomous pipeline that runs auto-review-fix, then auto-pr-merge ---- - -# auto-submit - -Autonomous pipeline: review+fix code, then create PR and merge. **Execute without user confirmation.** - -## Steps - -### 1. Auto Review-Fix - -**IMPORTANT**: Run auto-review-fix as a sub-agent (not an inline skill) to ensure it gets its own isolated context window. This prevents instruction dilution and ensures fix phases properly spawn their own Task() sub-agents as required. - -``` -Agent( - subagent_type: "general-purpose", - description: "Run auto-review-fix", - prompt: "Run the /auto-review-fix skill. Follow ALL instructions exactly, especially: all fixes MUST be done via Task() subagents with opus-4-5. No direct edits." -) -``` - -Wait for the agent to complete. Then commit any changes: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address auto-review findings" -fi -``` - -**Continue to Step 2 — do not stop here.** - -### 2. Auto PR-Merge - -``` -Use the Skill tool: skill: "auto-pr-merge" -``` - -Handles PR creation, CI polling, fix loops, and merge with `--admin --squash`. - -## Rules - -- Execute autonomously — no user confirmation -- Both steps run sequentially — do not stop between them -- Commit changes between steps -- If Step 1 fails catastrophically, stop (don't create a broken PR) -- If Step 2 fails, report the error with PR URL if available diff --git a/.agents/skills/electron/SKILL.md b/.agents/skills/electron/SKILL.md deleted file mode 100644 index 43756b4e794..00000000000 --- a/.agents/skills/electron/SKILL.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -name: electron -description: Launch, automate, and validate Electron desktop apps using playwright-cli via Chrome DevTools Protocol. Use this skill to validate UI changes in Orca, test features in the running Electron app, verify code fixes work end-to-end, or automate any Electron app (VS Code, Slack, Discord, etc.). Triggers include "validate in Electron", "test in the app", "verify the fix", "check the UI", "/electron", "automate Slack app", "control VS Code", or any task requiring interaction with a running Electron application. -allowed-tools: Bash(playwright-cli:*), Bash(npx playwright-cli:*), Bash(curl:*), Bash(lsof:*), Bash(open:*), Bash(ps:*), Bash(kill:*), Bash(node:*), Bash(pnpm:*), Read, Grep, Monitor ---- - -# Electron App Automation - -Automate any Electron desktop app using playwright-cli's CDP attach mode. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that playwright-cli can connect to, enabling the same snapshot-interact workflow used for web pages. - -## Critical Safety Rule: Never Kill Processes You Didn't Start - -**You may be running inside an Electron app (e.g., Orca).** Killing the wrong process will terminate your own session. - -- **NEVER** run `killall Electron`, `pkill Electron`, or any broad process-killing command. -- **NEVER** kill a process unless you launched it yourself in this session and you recorded its PID. -- Before killing, **always verify** the PID belongs to the process you started — check the command line includes the workspace path or args you used to launch it. -- When quitting apps to relaunch with `--remote-debugging-port`, use `osascript -e 'quit app "AppName"'` for named apps (Slack, VS Code, etc.) — **never for Orca or the app you're running inside**. -- If unsure whether a process is safe to kill, **ask the user**. - -## Core Workflow - -1. **Launch** the Electron app with remote debugging enabled (or find an already-running app with CDP) -2. **Attach** playwright-cli to the CDP endpoint -3. **Snapshot** to discover interactive elements -4. **Interact** using element refs -5. **Re-snapshot** after navigation or state changes - -```bash -# Launch an Electron app with remote debugging -open -a "Slack" --args --remote-debugging-port=9222 - -# Wait for the app to initialize -sleep 3 - -# Attach playwright-cli to the app via CDP -command playwright-cli attach --cdp="http://localhost:9222" - -# Standard workflow from here -command playwright-cli snapshot -command playwright-cli click e5 -command playwright-cli screenshot -``` - -## Always prefix with `command` - -Use `command playwright-cli …` to bypass shell aliases (e.g. `alias playwright-cli='playwright-cli --persistent'`) that leak flags into subcommands. Behaves identically when no alias is set. All examples below use this form. - -## Pick a free CDP port - -Port 9333 is a convention, not a guarantee — another worktree may hold it, and electron-vite will start without a debugger, letting you silently attach to the old app. - -```bash -for p in 9333 9334 9335 9336 9337 9338 9339 9340; do - if ! lsof -i :$p >/dev/null 2>&1; then PORT=$p; break; fi -done -# After attach, verify you hit the right worktree: -command playwright-cli eval "window.__store.getState().worktreesByRepo" -``` - -## Launching Orca Dev Build with CDP - -Orca uses electron-vite for dev builds. The correct way to launch with CDP: - -```bash -# Launch with remote debugging (PORT picked above) -node config/scripts/run-electron-vite-dev.mjs --remote-debugging-port=$PORT 2>&1 & - -# Wait for "DevTools listening on ws://..." in output, then attach -command playwright-cli attach --cdp="http://localhost:$PORT" -``` - -**Key details:** -- Pass `--remote-debugging-port=NNNN` directly to the script — do NOT use `pnpm run dev -- --` (the double `--` breaks Chromium flag parsing) -- electron-vite also supports `REMOTE_DEBUGGING_PORT` env var: `REMOTE_DEBUGGING_PORT=$PORT pnpm run dev` -- The Zustand store is exposed at `window.__store` — use `window.__store.getState()` and `window.__store.getState().someAction()` to read/mutate state -- Use port 9333 (not 9222) to avoid conflicts with other Electron apps - -### Accessing Orca State via eval - -```bash -# Read store state -command playwright-cli eval "(() => { const s = window.__store?.getState(); return JSON.stringify({ activeWorktreeId: s.activeWorktreeId, activeTabId: s.activeTabId, activeFileId: s.activeFileId, activeTabType: s.activeTabType }); })()" - -# Open an editor file -command playwright-cli eval "(() => { const s = window.__store?.getState(); const wtId = s.activeWorktreeId; s.openFile({ worktreeId: wtId, filePath: '/path/to/file', relativePath: 'file.ts', mode: 'edit', language: 'typescript' }); return 'done'; })()" - -# Close a file -command playwright-cli eval "(() => { window.__store.getState().closeFile('/path/to/file'); return 'closed'; })()" -``` - -## Launching Electron Apps with CDP - -Every Electron app supports the `--remote-debugging-port` flag since it's built into Chromium. - -### macOS - -```bash -# Slack -open -a "Slack" --args --remote-debugging-port=9222 - -# VS Code -open -a "Visual Studio Code" --args --remote-debugging-port=9223 - -# Discord -open -a "Discord" --args --remote-debugging-port=9224 - -# Figma -open -a "Figma" --args --remote-debugging-port=9225 - -# Notion -open -a "Notion" --args --remote-debugging-port=9226 - -# Spotify -open -a "Spotify" --args --remote-debugging-port=9227 -``` - -### Linux - -```bash -slack --remote-debugging-port=9222 -code --remote-debugging-port=9223 -discord --remote-debugging-port=9224 -``` - -### Windows - -```bash -"C:\Users\%USERNAME%\AppData\Local\slack\slack.exe" --remote-debugging-port=9222 -"C:\Users\%USERNAME%\AppData\Local\Programs\Microsoft VS Code\Code.exe" --remote-debugging-port=9223 -``` - -**Important:** If the app is already running, quit it first, then relaunch with the flag. The `--remote-debugging-port` flag must be present at launch time. - -## Connecting to an Already-Running App - -If an Electron app was already launched with `--remote-debugging-port`, you can attach directly: - -```bash -# Check what's listening on a port -lsof -i :9222 - -# Verify the CDP endpoint has targets -curl -s http://localhost:9222/json - -# Attach playwright-cli -command playwright-cli attach --cdp="http://localhost:9222" -``` - -## Attaching - -```bash -# Attach to a specific CDP port -command playwright-cli attach --cdp="http://localhost:9222" - -# Attach with a named session (for controlling multiple apps) -command playwright-cli -s=slack attach --cdp="http://localhost:9222" -command playwright-cli -s=vscode attach --cdp="http://localhost:9223" -``` - -After `attach`, all subsequent commands (in that session) target the connected app. - -## Tab Management - -Electron apps may have multiple windows or webviews. Use tab commands to list and switch between them: - -```bash -# List all available targets -command playwright-cli tab-list - -# Switch to a specific tab by index -command playwright-cli tab-select 2 -``` - -If `tab-list` doesn't show all targets, query the CDP endpoint directly to see everything: - -```bash -curl -s http://localhost:9222/json | python3 -c " -import sys, json -for i, t in enumerate(json.load(sys.stdin)): - print(f'[{i}] ({t[\"type\"]}) {t[\"title\"][:60]} - {t[\"url\"][:60]}') -" -``` - -## Common Patterns - -### Inspect and Navigate an App - -```bash -open -a "Slack" --args --remote-debugging-port=9222 -sleep 3 -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -# Read the snapshot output to identify UI elements -command playwright-cli click e10 # Navigate to a section -command playwright-cli snapshot # Re-snapshot after navigation -``` - -### Take Screenshots of Desktop Apps - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli screenshot -command playwright-cli screenshot e5 # Screenshot a specific element -command playwright-cli screenshot --filename=app-state.png -``` - -### Extract Data from a Desktop App - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -command playwright-cli eval "document.title" -command playwright-cli eval "el => el.textContent" e5 -``` - -### Fill Forms in Desktop Apps - -```bash -command playwright-cli attach --cdp="http://localhost:9222" -command playwright-cli snapshot -command playwright-cli fill e3 "search query" -command playwright-cli press Enter -command playwright-cli snapshot -``` - -### Run Multiple Apps Simultaneously - -Use named sessions to control multiple Electron apps at the same time: - -```bash -# Attach to Slack -command playwright-cli -s=slack attach --cdp="http://localhost:9222" - -# Attach to VS Code -command playwright-cli -s=vscode attach --cdp="http://localhost:9223" - -# Interact with each independently -command playwright-cli -s=slack snapshot -command playwright-cli -s=vscode snapshot -``` - -### Run Custom Playwright Code - -For advanced scenarios, use `run-code` to execute arbitrary Playwright code: - -```bash -command playwright-cli run-code "async page => { - await page.waitForSelector('.loading', { state: 'hidden' }); - const items = await page.locator('.item').allTextContents(); - return items; -}" -``` - -## Troubleshooting - -### "Connection refused" or "Cannot connect" - -- Make sure the app was launched with `--remote-debugging-port=NNNN` -- If the app was already running, quit and relaunch with the flag -- Check that the port isn't in use by another process: `lsof -i :9222` - -### App launches but attach fails - -- Wait a few seconds after launch before attaching (`sleep 3`) -- Some apps take time to initialize their webview -- Verify the endpoint is responding: `curl -s http://localhost:9222/json` - -### Elements not appearing in snapshot - -- The app may use multiple webviews. Use `playwright-cli tab-list` to list targets and switch -- Use `curl -s http://localhost:/json` to see all CDP targets if tab-list shows fewer -- Try `playwright-cli snapshot` without flags first - -### Cannot type in input fields - -- Some Electron apps use custom input components -- Try `playwright-cli press` for keyboard events -- Use `playwright-cli run-code` for complex input scenarios - -### Stale element refs after interaction - -- Element refs change when the page state updates -- Always re-snapshot after clicking, navigating, or filling forms -- Use the new refs from the latest snapshot - -## Supported Apps - -Any app built on Electron works, including: - -- **Communication:** Slack, Discord, Microsoft Teams, Signal, Telegram Desktop -- **Development:** VS Code, GitHub Desktop, Postman, Insomnia -- **Design:** Figma, Notion, Obsidian -- **Media:** Spotify, Tidal -- **Productivity:** Todoist, Linear, 1Password - -If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with playwright-cli. - -## Cleaning Up - -```bash -# Close the playwright-cli session (does NOT kill the Electron app) -command playwright-cli close - -# Close a named session -command playwright-cli -s=slack close - -# Close all playwright-cli sessions -command playwright-cli close-all -``` diff --git a/.agents/skills/react-useeffect/README.md b/.agents/skills/react-useeffect/README.md deleted file mode 100644 index 6c21cdd17ca..00000000000 --- a/.agents/skills/react-useeffect/README.md +++ /dev/null @@ -1,341 +0,0 @@ -# React useEffect Best Practices - -A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns. - -## Purpose - -Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means. - -This skill helps you: - -- Identify when you truly need an Effect vs. when you don't -- Recognize common anti-patterns and their fixes -- Apply better alternatives like `useMemo`, `key` prop, and event handlers -- Write Effects that are clean, maintainable, and free from race conditions - -## When to Use This Skill - -Use this skill when you're: - -- Writing or reviewing `useEffect` code -- Using `useState` to store derived values -- Implementing data fetching or subscriptions -- Synchronizing state between components -- Facing bugs with stale data or race conditions -- Wondering if your Effect is necessary - -**Trigger phrases:** - -- "Should I use useEffect for this?" -- "How do I fix this useEffect?" -- "My Effect is causing too many re-renders" -- "Data fetching with useEffect" -- "Reset state when props change" -- "Derived state from props" - -## How It Works - -This skill provides guidance through three key resources: - -1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns -2. **Decision Tree** - Visual flowchart to determine the right approach -3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes -4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects - -The skill teaches you to ask the right questions: - -- Is there an external system involved? -- Am I responding to a user event or component appearance? -- Can this value be calculated during render? -- Do I need to reset state when a prop changes? - -## Key Features - -### 1. Quick Reference Guide - -Visual table showing the DO/DON'T for common scenarios: - -- Derived state from props/state -- Expensive calculations -- Resetting state on prop change -- User event responses -- Notifying parent components -- Data fetching - -### 2. Decision Tree - -Clear flowchart that guides you from "Need to respond to something?" to the correct solution: - -- User interaction → Event handler -- Component appeared → Effect (for external sync/analytics) -- Derived value needed → Calculate during render (+ useMemo if expensive) -- Reset state on prop change → Key prop - -### 3. Anti-Pattern Recognition - -Detailed examples of 9 common mistakes: - -1. Redundant state for derived values -2. Filtering/transforming data in Effect -3. Resetting state on prop change -4. Event-specific logic in Effect -5. Chains of Effects -6. Notifying parent via Effect -7. Passing data up to parent -8. Fetching without cleanup (race conditions) -9. App initialization in Effect - -Each anti-pattern includes: - -- Bad example with explanation -- Good example with fix -- Why the anti-pattern is problematic - -### 4. Better Alternatives - -8 proven patterns to replace unnecessary Effects: - -1. Calculate during render for derived state -2. `useMemo` for expensive calculations -3. `key` prop to reset state -4. Store ID instead of object for stable references -5. Event handlers for user actions -6. `useSyncExternalStore` for external stores -7. Lifting state up for shared state -8. Custom hooks for data fetching with cleanup - -## Usage Examples - -### Example 1: Derived State - -**Bad - Unnecessary Effect:** - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const [fullName, setFullName] = useState('') - - useEffect(() => { - setFullName(firstName + ' ' + lastName) - }, [firstName, lastName]) -} -``` - -**Good - Calculate during render:** - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const fullName = firstName + ' ' + lastName // Just compute it -} -``` - -### Example 2: Resetting State - -**Bad - Effect to reset:** - -```tsx -function ProfilePage({ userId }) { - const [comment, setComment] = useState('') - - useEffect(() => { - setComment('') - }, [userId]) -} -``` - -**Good - Key prop:** - -```tsx -function ProfilePage({ userId }) { - return -} - -function Profile({ userId }) { - const [comment, setComment] = useState('') // Resets automatically -} -``` - -### Example 3: Data Fetching with Cleanup - -**Bad - Race condition:** - -```tsx -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - fetchResults(query).then((json) => { - setResults(json) // "hello" response may arrive after "hell" - }) - }, [query]) -} -``` - -**Good - Cleanup flag:** - -```tsx -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - let ignore = false - - fetchResults(query).then((json) => { - if (!ignore) setResults(json) - }) - - return () => { - ignore = true - } - }, [query]) -} -``` - -### Example 4: Event Handler Instead of Effect - -**Bad - Effect watching state:** - -```tsx -function ProductPage({ product, addToCart }) { - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name}!`) - } - }, [product]) - - function handleBuyClick() { - addToCart(product) - } -} -``` - -**Good - Handle in event:** - -```tsx -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - } -} -``` - -## When You DO Need Effects - -Effects are appropriate for: - -- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code -- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`) -- **Analytics/logging** - Code that needs to run because the component displayed -- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism) - -## When You DON'T Need Effects - -Avoid Effects for: - -1. **Transforming data for rendering** - Calculate at the top level instead -2. **Handling user events** - Use event handlers where you know exactly what happened -3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` -4. **Chaining state updates** - Calculate all next state in the event handler -5. **Notifying parent components** - Call the callback in the same event handler -6. **Resetting state** - Use the `key` prop to create a fresh component instance - -## Best Practices - -### 1. Start Without an Effect - -Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect. - -### 2. Prefer Derived State - -If you can calculate a value from props or state, don't store it in state with an Effect updating it. - -### 3. Use the Right Tool - -- Expensive calculation → `useMemo` -- User interaction → Event handler -- Reset on prop change → `key` prop -- External subscription → `useSyncExternalStore` -- Shared state → Lift state up - -### 4. Always Clean Up - -If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions. - -### 5. Avoid Effect Chains - -Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler). - -### 6. Test in Strict Mode - -React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup. - -### 7. Consider Framework Solutions - -For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects. - -## Reference Files - -This skill includes three detailed reference documents: - -1. **SKILL.md** - Quick reference table and decision tree -2. **anti-patterns.md** - 9 common mistakes with detailed explanations -3. **alternatives.md** - 8 better alternatives with code examples - -## Common Pitfalls - -### Multiple Re-renders - -**Symptom:** Component re-renders many times in quick succession. - -**Cause:** Effect that sets state based on state it depends on, creating a loop. - -**Fix:** Calculate the final value in an event handler or during render. - -### Stale Data - -**Symptom:** UI shows outdated values briefly before updating. - -**Cause:** Using Effect to update derived state causes an extra render pass. - -**Fix:** Calculate derived values during render instead of in state. - -### Race Conditions - -**Symptom:** Fast typing shows results for old queries after new ones. - -**Cause:** Missing cleanup in data fetching Effect. - -**Fix:** Use cleanup flag (`ignore` variable) or AbortController. - -### Runs Twice in Development - -**Symptom:** Effect runs twice on component mount in development. - -**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs. - -**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard. - -## Resources - -This skill is based on: - -- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) -- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) -- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects) - -## Summary - -The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect. - -Before writing `useEffect`, ask yourself: - -1. Is this responding to a user interaction? → Use event handler -2. Is this a value I can calculate from props/state? → Calculate during render -3. Is this resetting state when a prop changes? → Use key prop -4. Is this synchronizing with an external system? → Use Effect with cleanup - -Follow these patterns, and your React code will be more maintainable, performant, and bug-free. diff --git a/.agents/skills/react-useeffect/SKILL.md b/.agents/skills/react-useeffect/SKILL.md deleted file mode 100644 index 83ef6e036e6..00000000000 --- a/.agents/skills/react-useeffect/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: react-useeffect -description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives. ---- - -# You Might Not Need an Effect - -Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect. - -## Quick Reference - -| Situation | DON'T | DO | -| ------------------------------ | ------------------------------ | ------------------------------------- | -| Derived state from props/state | `useState` + `useEffect` | Calculate during render | -| Expensive calculations | `useEffect` to cache | `useMemo` | -| Reset state on prop change | `useEffect` with `setState` | `key` prop | -| User event responses | `useEffect` watching state | Event handler directly | -| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler | -| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework | - -## When You DO Need Effects - -- Synchronizing with **external systems** (non-React widgets, browser APIs) -- **Subscriptions** to external stores (use `useSyncExternalStore` when possible) -- **Analytics/logging** that runs because component displayed -- **Data fetching** with proper cleanup (or use framework's built-in mechanism) - -## When You DON'T Need Effects - -1. **Transforming data for rendering** - Calculate at top level, re-runs automatically -2. **Handling user events** - Use event handlers, you know exactly what happened -3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName` -4. **Chaining state updates** - Calculate all next state in the event handler - -## Decision Tree - -``` -Need to respond to something? -├── User interaction (click, submit, drag)? -│ └── Use EVENT HANDLER -├── Component appeared on screen? -│ └── Use EFFECT (external sync, analytics) -├── Props/state changed and need derived value? -│ └── CALCULATE DURING RENDER -│ └── Expensive? Use useMemo -└── Need to reset state when prop changes? - └── Use KEY PROP on component -``` - -## Detailed Guidance - -- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes -- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore diff --git a/.agents/skills/react-useeffect/alternatives.md b/.agents/skills/react-useeffect/alternatives.md deleted file mode 100644 index 01fa6b6a4b3..00000000000 --- a/.agents/skills/react-useeffect/alternatives.md +++ /dev/null @@ -1,265 +0,0 @@ -# Better Alternatives to useEffect - -## 1. Calculate During Render (Derived State) - -For values derived from props or state, just compute them: - -```tsx -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - - // Runs every render - that's fine and intentional - const fullName = firstName + ' ' + lastName - const isValid = firstName.length > 0 && lastName.length > 0 -} -``` - -**When to use**: The value can be computed from existing props/state. - ---- - -## 2. useMemo for Expensive Calculations - -When computation is expensive, memoize it: - -```tsx -import { useMemo } from 'react' - -function TodoList({ todos, filter }) { - const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]) -} -``` - -**How to know if it's expensive**: - -```tsx -console.time('filter') -const visibleTodos = getFilteredTodos(todos, filter) -console.timeEnd('filter') -// If > 1ms, consider memoizing -``` - -**Note**: React Compiler can auto-memoize, reducing manual useMemo needs. - ---- - -## 3. Key Prop to Reset State - -To reset ALL state when a prop changes, use key: - -```tsx -// Parent passes userId as key -function ProfilePage({ userId }) { - return ( - - ) -} - -function Profile({ userId }) { - // All state here resets when userId changes - const [comment, setComment] = useState('') - const [likes, setLikes] = useState([]) -} -``` - -**When to use**: You want a "fresh start" when an identity prop changes. - ---- - -## 4. Store ID Instead of Object - -To preserve selection when list changes: - -```tsx -// BAD: Storing object that needs Effect to "adjust" -function List({ items }) { - const [selection, setSelection] = useState(null) - - useEffect(() => { - setSelection(null) // Reset when items change - }, [items]) -} - -// GOOD: Store ID, derive object -function List({ items }) { - const [selectedId, setSelectedId] = useState(null) - - // Derived - no Effect needed - const selection = items.find((item) => item.id === selectedId) ?? null -} -``` - -**Benefit**: If item with selectedId exists in new list, selection preserved. - ---- - -## 5. Event Handlers for User Actions - -User clicks/submits/drags should be handled in event handlers, not Effects: - -```tsx -// Event handler knows exactly what happened -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - analytics.track('product_added', { id: product.id }) - } - - function handleCheckoutClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - navigateTo('/checkout') - } -} -``` - -**Shared logic**: Extract a function, call from both handlers: - -```tsx -function buyProduct() { - addToCart(product) - showNotification(`Added ${product.name}!`) -} - -function handleBuyClick() { - buyProduct() -} -function handleCheckoutClick() { - buyProduct() - navigateTo('/checkout') -} -``` - ---- - -## 6. useSyncExternalStore for External Stores - -For subscribing to external data (browser APIs, third-party stores): - -```tsx -// Instead of manual Effect subscription -function useOnlineStatus() { - const [isOnline, setIsOnline] = useState(true) - - useEffect(() => { - function update() { - setIsOnline(navigator.onLine) - } - window.addEventListener('online', update) - window.addEventListener('offline', update) - return () => { - window.removeEventListener('online', update) - window.removeEventListener('offline', update) - } - }, []) - - return isOnline -} - -// Use purpose-built hook -import { useSyncExternalStore } from 'react' - -function subscribe(callback) { - window.addEventListener('online', callback) - window.addEventListener('offline', callback) - return () => { - window.removeEventListener('online', callback) - window.removeEventListener('offline', callback) - } -} - -function useOnlineStatus() { - return useSyncExternalStore( - subscribe, - () => navigator.onLine, // Client value - () => true // Server value (SSR) - ) -} -``` - ---- - -## 7. Lifting State Up - -When two components need synchronized state, lift it to common ancestor: - -```tsx -// Instead of syncing via Effects between siblings -function Parent() { - const [value, setValue] = useState('') - - return ( - <> - - - - ) -} -``` - ---- - -## 8. Custom Hooks for Data Fetching - -Extract fetch logic with proper cleanup: - -```tsx -function useData(url) { - const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let ignore = false - setLoading(true) - - fetch(url) - .then((res) => res.json()) - .then((json) => { - if (!ignore) { - setData(json) - setError(null) - } - }) - .catch((err) => { - if (!ignore) setError(err) - }) - .finally(() => { - if (!ignore) setLoading(false) - }) - - return () => { - ignore = true - } - }, [url]) - - return { data, error, loading } -} - -// Usage -function SearchResults({ query }) { - const { data, error, loading } = useData(`/api/search?q=${query}`) -} -``` - -**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.) - ---- - -## Summary: When to Use What - -| Need | Solution | -| ------------------------------ | ------------------------------------ | -| Value from props/state | Calculate during render | -| Expensive calculation | `useMemo` | -| Reset all state on prop change | `key` prop | -| Respond to user action | Event handler | -| Sync with external system | `useEffect` with cleanup | -| Subscribe to external store | `useSyncExternalStore` | -| Share state between components | Lift state up | -| Fetch data | Custom hook with cleanup / framework | diff --git a/.agents/skills/react-useeffect/anti-patterns.md b/.agents/skills/react-useeffect/anti-patterns.md deleted file mode 100644 index 2a05839f3b2..00000000000 --- a/.agents/skills/react-useeffect/anti-patterns.md +++ /dev/null @@ -1,289 +0,0 @@ -# useEffect Anti-Patterns - -## 1. Redundant State for Derived Values - -```tsx -// BAD: Extra state + Effect for derived value -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const [fullName, setFullName] = useState('') - - useEffect(() => { - setFullName(firstName + ' ' + lastName) - }, [firstName, lastName]) -} - -// GOOD: Calculate during rendering -function Form() { - const [firstName, setFirstName] = useState('Taylor') - const [lastName, setLastName] = useState('Swift') - const fullName = firstName + ' ' + lastName // Just compute it -} -``` - -**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value. - ---- - -## 2. Filtering/Transforming Data in Effect - -```tsx -// BAD: Effect to filter list -function TodoList({ todos, filter }) { - const [visibleTodos, setVisibleTodos] = useState([]) - - useEffect(() => { - setVisibleTodos(getFilteredTodos(todos, filter)) - }, [todos, filter]) -} - -// GOOD: Filter during render (memoize if expensive) -function TodoList({ todos, filter }) { - const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]) -} -``` - ---- - -## 3. Resetting State on Prop Change - -```tsx -// BAD: Effect to reset state -function ProfilePage({ userId }) { - const [comment, setComment] = useState('') - - useEffect(() => { - setComment('') - }, [userId]) -} - -// GOOD: Use key prop -function ProfilePage({ userId }) { - return -} - -function Profile({ userId }) { - const [comment, setComment] = useState('') // Resets automatically -} -``` - -**Why key works**: React treats components with different keys as different components, recreating state. - ---- - -## 4. Event-Specific Logic in Effect - -```tsx -// BAD: Effect for button click result -function ProductPage({ product, addToCart }) { - useEffect(() => { - if (product.isInCart) { - showNotification(`Added ${product.name}!`) - } - }, [product]) - - function handleBuyClick() { - addToCart(product) - } -} - -// GOOD: Handle in event handler -function ProductPage({ product, addToCart }) { - function handleBuyClick() { - addToCart(product) - showNotification(`Added ${product.name}!`) - } -} -``` - -**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly. - ---- - -## 5. Chains of Effects - -```tsx -// BAD: Effects triggering each other -function Game() { - const [card, setCard] = useState(null) - const [goldCardCount, setGoldCardCount] = useState(0) - const [round, setRound] = useState(1) - const [isGameOver, setIsGameOver] = useState(false) - - useEffect(() => { - if (card?.gold) setGoldCardCount((c) => c + 1) - }, [card]) - - useEffect(() => { - if (goldCardCount > 3) { - setRound((r) => r + 1) - setGoldCardCount(0) - } - }, [goldCardCount]) - - useEffect(() => { - if (round > 5) setIsGameOver(true) - }, [round]) -} - -// GOOD: Calculate in event handler -function Game() { - const [card, setCard] = useState(null) - const [goldCardCount, setGoldCardCount] = useState(0) - const [round, setRound] = useState(1) - const isGameOver = round > 5 // Derived! - - function handlePlaceCard(nextCard) { - if (isGameOver) throw Error('Game ended') - - setCard(nextCard) - if (nextCard.gold) { - if (goldCardCount < 3) { - setGoldCardCount(goldCardCount + 1) - } else { - setGoldCardCount(0) - setRound(round + 1) - if (round === 5) alert('Good game!') - } - } - } -} -``` - -**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay. - ---- - -## 6. Notifying Parent via Effect - -```tsx -// BAD: Effect to notify parent -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false) - - useEffect(() => { - onChange(isOn) - }, [isOn, onChange]) - - function handleClick() { - setIsOn(!isOn) - } -} - -// GOOD: Notify in same event -function Toggle({ onChange }) { - const [isOn, setIsOn] = useState(false) - - function updateToggle(nextIsOn) { - setIsOn(nextIsOn) - onChange(nextIsOn) // Same event, batched render - } - - function handleClick() { - updateToggle(!isOn) - } -} - -// BEST: Fully controlled component -function Toggle({ isOn, onChange }) { - function handleClick() { - onChange(!isOn) - } -} -``` - ---- - -## 7. Passing Data Up to Parent - -```tsx -// BAD: Child fetches, passes up via Effect -function Parent() { - const [data, setData] = useState(null) - return -} - -function Child({ onFetched }) { - const data = useSomeAPI() - - useEffect(() => { - if (data) onFetched(data) - }, [onFetched, data]) -} - -// GOOD: Parent fetches, passes down -function Parent() { - const data = useSomeAPI() - return -} -``` - -**Why**: Data should flow down. Upward flow via Effects makes debugging hard. - ---- - -## 8. Fetching Without Cleanup (Race Condition) - -```tsx -// BAD: No cleanup - race condition -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - fetchResults(query).then((json) => { - setResults(json) // "hello" response may arrive after "hell" - }) - }, [query]) -} - -// GOOD: Cleanup ignores stale responses -function SearchResults({ query }) { - const [results, setResults] = useState([]) - - useEffect(() => { - let ignore = false - - fetchResults(query).then((json) => { - if (!ignore) setResults(json) - }) - - return () => { - ignore = true - } - }, [query]) -} -``` - ---- - -## 9. App Initialization in Effect - -```tsx -// BAD: Runs twice in dev, may break auth -function App() { - useEffect(() => { - loadDataFromLocalStorage() - checkAuthToken() // May invalidate token on second call! - }, []) -} - -// GOOD: Module-level guard -let didInit = false - -function App() { - useEffect(() => { - if (!didInit) { - didInit = true - loadDataFromLocalStorage() - checkAuthToken() - } - }, []) -} - -// ALSO GOOD: Module-level execution -if (typeof window !== 'undefined') { - checkAuthToken() - loadDataFromLocalStorage() -} -``` diff --git a/.agents/skills/typescript/SKILL.md b/.agents/skills/typescript/SKILL.md deleted file mode 100644 index 8de8e7d7754..00000000000 --- a/.agents/skills/typescript/SKILL.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: typescript -description: This skill should be used when the user asks to "optimize TypeScript performance", "speed up tsc compilation", "configure tsconfig.json", "fix type errors", "improve async patterns", or encounters TS errors (TS2322, TS2339, "is not assignable to"). Also triggers on .ts, .tsx, .d.ts file work involving type definitions, module organization, or memory management. Does NOT cover TypeScript basics, framework-specific patterns, or testing. ---- - -# TypeScript Best Practices - -Comprehensive performance optimization guide for TypeScript applications. Contains 45 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. - -## When to Apply - -Reference these guidelines when: - -- Configuring tsconfig.json for a new or existing project -- Writing complex type definitions or generics -- Optimizing async/await patterns and data fetching -- Organizing modules and managing imports -- Reviewing code for compilation or runtime performance - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ----------------------- | ----------- | ----------- | -| 1 | Type System Performance | CRITICAL | `type-` | -| 2 | Compiler Configuration | CRITICAL | `tscfg-` | -| 3 | Async Patterns | HIGH | `async-` | -| 4 | Module Organization | HIGH | `module-` | -| 5 | Type Safety Patterns | MEDIUM-HIGH | `safety-` | -| 6 | Memory Management | MEDIUM | `mem-` | -| 7 | Runtime Optimization | LOW-MEDIUM | `runtime-` | -| 8 | Advanced Patterns | LOW | `advanced-` | - -## Table of Contents - -1. [Type System Performance](references/_sections.md#1-type-system-performance) — **CRITICAL** - - 1.1 [Add Explicit Return Types to Exported Functions](references/type-explicit-return-types.md) — CRITICAL (30-50% faster declaration emit) - - 1.2 [Avoid Deeply Nested Generic Types](references/type-avoid-deep-generics.md) — CRITICAL (prevents exponential instantiation cost) - - 1.3 [Avoid Large Union Types](references/type-avoid-large-unions.md) — CRITICAL (quadratic O(n²) comparison cost) - - 1.4 [Extract Conditional Types to Named Aliases](references/type-extract-conditional-types.md) — CRITICAL (enables compiler caching, prevents re-evaluation) - - 1.5 [Limit Type Recursion Depth](references/type-limit-recursion-depth.md) — HIGH (prevents exponential type expansion when applicable) - - 1.6 [Prefer Interfaces Over Type Intersections](references/type-interfaces-over-intersections.md) — CRITICAL (2-5× faster type resolution) - - 1.7 [Simplify Complex Mapped Types](references/type-simplify-mapped-types.md) — HIGH (reduces type computation by 50-80% when applicable) -2. [Compiler Configuration](references/_sections.md#2-compiler-configuration) — **CRITICAL** - - 2.1 [Configure Include and Exclude Properly](references/tscfg-exclude-properly.md) — CRITICAL (prevents scanning thousands of unnecessary files) - - 2.2 [Enable Incremental Compilation](references/tscfg-enable-incremental.md) — CRITICAL (50-90% faster rebuilds) - - 2.3 [Enable isolatedDeclarations for Parallel Declaration Emit](references/tscfg-isolated-declarations.md) — CRITICAL (enables parallel .d.ts generation without type-checker) - - 2.4 [Enable skipLibCheck for Faster Builds](references/tscfg-skip-lib-check.md) — CRITICAL (20-40% faster compilation) - - 2.5 [Enable strictFunctionTypes for Faster Variance Checks](references/tscfg-strict-function-types.md) — CRITICAL (enables optimized variance checking) - - 2.6 [Use erasableSyntaxOnly for Node.js Native TypeScript](references/tscfg-erasable-syntax-only.md) — HIGH (prevents 100% of Node.js type-stripping runtime errors) - - 2.7 [Use isolatedModules for Single-File Transpilation](references/tscfg-isolate-modules.md) — CRITICAL (80-90% faster transpilation with bundlers) - - 2.8 [Use Project References for Large Codebases](references/tscfg-project-references.md) — CRITICAL (60-80% faster incremental builds) -3. [Async Patterns](references/_sections.md#3-async-patterns) — **HIGH** - - 3.1 [Annotate Async Function Return Types](references/async-explicit-return-types.md) — HIGH (prevents runtime errors, improves inference) - - 3.2 [Avoid await Inside Loops](references/async-avoid-loop-await.md) — HIGH (N× faster for N iterations, 10 users = 10× improvement) - - 3.3 [Avoid Unnecessary async/await](references/async-avoid-unnecessary-async.md) — HIGH (eliminates trivial Promise wrappers and improves stack traces) - - 3.4 [Defer await Until Value Is Needed](references/async-defer-await.md) — HIGH (enables implicit parallelization) - - 3.5 [Use Promise.all for Independent Operations](references/async-parallel-promises.md) — HIGH (2-10× improvement in I/O-bound code) -4. [Module Organization](references/_sections.md#4-module-organization) — **HIGH** - - 4.1 [Avoid Barrel File Imports](references/module-avoid-barrel-imports.md) — HIGH (200-800ms import cost, 30-50% larger bundles) - - 4.2 [Avoid Circular Dependencies](references/module-avoid-circular-dependencies.md) — HIGH (prevents runtime undefined errors and slow compilation) - - 4.3 [Control @types Package Inclusion](references/module-control-types-inclusion.md) — HIGH (prevents type conflicts and reduces memory usage) - - 4.4 [Use Dynamic Imports for Large Modules](references/module-dynamic-imports.md) — HIGH (reduces initial bundle by 30-70%) - - 4.5 [Use Type-Only Imports for Types](references/module-use-type-imports.md) — HIGH (eliminates runtime imports for type information) -5. [Type Safety Patterns](references/_sections.md#5-type-safety-patterns) — **MEDIUM-HIGH** - - 5.1 [Enable noUncheckedIndexedAccess](references/safety-no-unchecked-indexed-access.md) — MEDIUM-HIGH (prevents 100% of unchecked index access errors at compile time) - - 5.2 [Enable strictNullChecks](references/safety-strict-null-checks.md) — MEDIUM-HIGH (prevents null/undefined runtime errors) - - 5.3 [Prefer unknown Over any](references/safety-prefer-unknown-over-any.md) — MEDIUM-HIGH (forces type narrowing, prevents runtime errors) - - 5.4 [Use Assertion Functions for Validation](references/safety-assertion-functions.md) — MEDIUM-HIGH (reduces validation boilerplate by 50-70%) - - 5.5 [Use const Assertions for Literal Types](references/safety-const-assertions.md) — MEDIUM-HIGH (preserves literal types, enables better inference) - - 5.6 [Use Exhaustive Checks for Union Types](references/safety-exhaustive-checks.md) — MEDIUM-HIGH (prevents 100% of missing case errors at compile time) - - 5.7 [Use Type Guards for Runtime Type Checking](references/safety-use-type-guards.md) — MEDIUM-HIGH (eliminates type assertions, catches errors at boundaries) -6. [Memory Management](references/_sections.md#6-memory-management) — **MEDIUM** - - 6.1 [Avoid Closure Memory Leaks](references/mem-avoid-closure-leaks.md) — MEDIUM (prevents retained references in long-lived callbacks) - - 6.2 [Avoid Global State Accumulation](references/mem-avoid-global-state.md) — MEDIUM (prevents unbounded memory growth) - - 6.3 [Clean Up Event Listeners](references/mem-cleanup-event-listeners.md) — MEDIUM (prevents unbounded memory growth) - - 6.4 [Clear Timers and Intervals](references/mem-clear-timers.md) — MEDIUM (prevents callback retention and repeated execution) - - 6.5 [Use WeakMap for Object Metadata](references/mem-use-weakmap-for-metadata.md) — MEDIUM (prevents memory leaks, enables automatic cleanup) -7. [Runtime Optimization](references/_sections.md#7-runtime-optimization) — **LOW-MEDIUM** - - 7.1 [Avoid Object Spread in Hot Loops](references/runtime-avoid-object-spread-in-loops.md) — LOW-MEDIUM (reduces object allocations by N×) - - 7.2 [Cache Property Access in Loops](references/runtime-cache-property-access.md) — LOW-MEDIUM (reduces property lookups by N× in hot paths) - - 7.3 [Prefer Native Array Methods Over Lodash](references/runtime-prefer-array-methods.md) — LOW-MEDIUM (eliminates library overhead, enables tree-shaking) - - 7.4 [Use for-of for Simple Iteration](references/runtime-use-for-of-for-iteration.md) — LOW-MEDIUM (reduces iteration boilerplate by 30-50%) - - 7.5 [Use Modern String Methods](references/runtime-use-string-methods.md) — LOW-MEDIUM (2-5× faster than regex for simple patterns) - - 7.6 [Use Set/Map for O(1) Lookups](references/runtime-use-set-for-lookups.md) — LOW-MEDIUM (O(n) to O(1) per lookup) -8. [Advanced Patterns](references/_sections.md#8-advanced-patterns) — **LOW** - - 8.1 [Use Branded Types for Type-Safe IDs](references/advanced-branded-types.md) — LOW (prevents mixing incompatible ID types) - - 8.2 [Use satisfies for Type Validation with Inference](references/advanced-satisfies-operator.md) — LOW (prevents property access errors, enables 100% autocomplete accuracy) - - 8.3 [Use Template Literal Types for String Patterns](references/advanced-template-literal-types.md) — LOW (prevents 100% of string format errors at compile time) - -## References - -1. [https://github.com/microsoft/TypeScript/wiki/Performance](https://github.com/microsoft/TypeScript/wiki/Performance) -2. [https://www.typescriptlang.org/docs/handbook/](https://www.typescriptlang.org/docs/handbook/) -3. [https://v8.dev/blog](https://v8.dev/blog) -4. [https://nodejs.org/en/learn/diagnostics/memory](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/assets/templates/_template.md b/.agents/skills/typescript/assets/templates/_template.md deleted file mode 100644 index 3071a0b92b9..00000000000 --- a/.agents/skills/typescript/assets/templates/_template.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Rule Title Here -impact: MEDIUM -impactDescription: Quantified impact (e.g., "2-10× improvement", "200ms savings") -tags: prefix, technique, related-concepts ---- - -## Rule Title Here - -Brief explanation of the rule and why it matters (1-3 sentences). Focus on performance implications. - -**Incorrect (description of what's wrong):** - -```typescript -// Bad code example here -const badExample = inefficientOperation() -``` - -**Correct (description of what's right):** - -```typescript -// Good code example here -const goodExample = efficientOperation() -``` - -Reference: [Link to documentation](https://example.com) diff --git a/.agents/skills/typescript/references/_sections.md b/.agents/skills/typescript/references/_sections.md deleted file mode 100644 index bf5e5463f23..00000000000 --- a/.agents/skills/typescript/references/_sections.md +++ /dev/null @@ -1,46 +0,0 @@ -# Sections - -This file defines all sections, their ordering, impact levels, and descriptions. -The section ID (in parentheses) is the filename prefix used to group rules. - ---- - -## 1. Type System Performance (type) - -**Impact:** CRITICAL -**Description:** Complex types, deep generics, and large unions cause quadratic compilation time. Simplifying type definitions yields the largest compile-time gains. - -## 2. Compiler Configuration (tscfg) - -**Impact:** CRITICAL -**Description:** Misconfigured tsconfig causes full rebuilds and unnecessary file scanning. Proper configuration reduces compile time by 50-80%. - -## 3. Async Patterns (async) - -**Impact:** HIGH -**Description:** Sequential awaits create runtime waterfalls. Parallelizing async operations yields 2-10× improvement in I/O-bound code. - -## 4. Module Organization (module) - -**Impact:** HIGH -**Description:** Barrel files and circular dependencies force excessive module loading. Direct imports reduce bundle size and improve tree-shaking. - -## 5. Type Safety Patterns (safety) - -**Impact:** MEDIUM-HIGH -**Description:** Type guards, narrowing, and strict mode prevent runtime errors. Proper patterns eliminate defensive runtime checks. - -## 6. Memory Management (mem) - -**Impact:** MEDIUM -**Description:** Object pooling, WeakMap usage, and closure hygiene reduce GC pressure and memory leaks in long-running applications. - -## 7. Runtime Optimization (runtime) - -**Impact:** LOW-MEDIUM -**Description:** Loop optimization, property caching, and collection choice improve hot-path performance. - -## 8. Advanced Patterns (advanced) - -**Impact:** LOW -**Description:** Branded types, variance annotations, and declaration merging for specialized use cases. diff --git a/.agents/skills/typescript/references/advanced-branded-types.md b/.agents/skills/typescript/references/advanced-branded-types.md deleted file mode 100644 index 12f14f6dedf..00000000000 --- a/.agents/skills/typescript/references/advanced-branded-types.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Use Branded Types for Type-Safe IDs -impact: LOW -impactDescription: prevents mixing incompatible ID types -tags: advanced, branded-types, nominal-types, type-safety, ids ---- - -## Use Branded Types for Type-Safe IDs - -TypeScript uses structural typing, so `string` types are interchangeable even when they represent different concepts. Branded types add a unique marker to prevent mixing incompatible values. - -**Incorrect (structural typing allows mixing):** - -```typescript -type UserId = string -type OrderId = string -type ProductId = string - -function fetchUser(id: UserId): Promise { - /* ... */ -} -function fetchOrder(id: OrderId): Promise { - /* ... */ -} - -const userId: UserId = 'user-123' -const orderId: OrderId = 'order-456' - -// No error - all strings are interchangeable -fetchUser(orderId) // Bug: passed OrderId to UserId parameter -fetchOrder(userId) // Bug: passed UserId to OrderId parameter -``` - -**Correct (branded types prevent mixing):** - -```typescript -type Brand = K & { __brand: T } - -type UserId = Brand -type OrderId = Brand -type ProductId = Brand - -function createUserId(id: string): UserId { - return id as UserId -} - -function createOrderId(id: string): OrderId { - return id as OrderId -} - -function fetchUser(id: UserId): Promise { - /* ... */ -} -function fetchOrder(id: OrderId): Promise { - /* ... */ -} - -const userId = createUserId('user-123') -const orderId = createOrderId('order-456') - -fetchUser(orderId) // Error: Argument of type 'OrderId' is not assignable to 'UserId' -fetchOrder(userId) // Error: Argument of type 'UserId' is not assignable to 'OrderId' -fetchUser(userId) // OK -``` - -**For numeric types:** - -```typescript -type Cents = Brand -type Dollars = Brand - -function toCents(dollars: Dollars): Cents { - return (dollars * 100) as Cents -} - -function formatPrice(cents: Cents): string { - return `$${(cents / 100).toFixed(2)}` -} - -const price = 29.99 as Dollars -formatPrice(price) // Error: Dollars not assignable to Cents -formatPrice(toCents(price)) // OK: '$29.99' -``` - -**When to use branded types:** - -- Entity IDs that shouldn't be mixed -- Currency/unit conversions -- Validated strings (email, URL, slug) -- Sensitive data that needs tracking - -Reference: [TypeScript Playground - Nominal Typing](https://www.typescriptlang.org/play/typescript/language-extensions/nominal-typing.ts.html) diff --git a/.agents/skills/typescript/references/advanced-satisfies-operator.md b/.agents/skills/typescript/references/advanced-satisfies-operator.md deleted file mode 100644 index 83d069ea04f..00000000000 --- a/.agents/skills/typescript/references/advanced-satisfies-operator.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Use satisfies for Type Validation with Inference -impact: LOW -impactDescription: prevents property access errors, enables 100% autocomplete accuracy -tags: advanced, satisfies, inference, validation, type-checking ---- - -## Use satisfies for Type Validation with Inference - -The `satisfies` operator validates that a value conforms to a type while preserving the narrower inferred type. This gives you both type safety and precise autocomplete. - -**Incorrect (type annotation loses literal types):** - -```typescript -type ColorConfig = Record - -const colors: ColorConfig = { - red: [255, 0, 0], - green: [0, 255, 0], - blue: [0, 0, 255] - // Can't access colors.red - it's just string keys -} - -// TypeScript doesn't know 'red' is a valid key -const redValue = colors.red // Type: [number, number, number] -const pinkValue = colors.pink // No error! Type: [number, number, number] -``` - -**Correct (satisfies preserves literal types):** - -```typescript -type ColorConfig = Record - -const colors = { - red: [255, 0, 0], - green: [0, 255, 0], - blue: [0, 0, 255] -} satisfies ColorConfig - -// TypeScript knows exact keys -const redValue = colors.red // Type: [number, number, number] -const pinkValue = colors.pink // Error: Property 'pink' does not exist -``` - -**For configuration objects:** - -```typescript -interface Route { - path: string - component: () => JSX.Element - auth?: boolean -} - -// Without satisfies - loses literal path types -const routes: Route[] = [ - { path: '/', component: Home }, - { path: '/users', component: Users } -] -// routes[0].path is just 'string' - -// With satisfies - preserves literal paths -const routes = [ - { path: '/', component: Home }, - { path: '/users', component: Users } -] satisfies Route[] -// routes[0].path is '/' - -type RoutePath = (typeof routes)[number]['path'] // '/' | '/users' -``` - -**Combining with as const:** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - timeout: 5000, - retries: 3 -} as const satisfies { - apiUrl: string - timeout: number - retries: number -} - -// Both validated AND readonly with literal types -config.apiUrl // Type: 'https://api.example.com' (not just string) -config.timeout = 3000 // Error: Cannot assign to 'timeout' (readonly) -``` - -**When to use satisfies vs type annotation:** - -- Use `satisfies` when you want validation but need literal types -- Use type annotation (`:`) when you want the variable to be exactly that type -- Use `as const satisfies` for readonly config with validation - -Reference: [TypeScript 4.9 satisfies](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator) diff --git a/.agents/skills/typescript/references/advanced-template-literal-types.md b/.agents/skills/typescript/references/advanced-template-literal-types.md deleted file mode 100644 index a43c45b23bc..00000000000 --- a/.agents/skills/typescript/references/advanced-template-literal-types.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Use Template Literal Types for String Patterns -impact: LOW -impactDescription: prevents 100% of string format errors at compile time -tags: advanced, template-literals, string-types, patterns, validation ---- - -## Use Template Literal Types for String Patterns - -Template literal types allow defining string patterns at the type level. TypeScript validates that strings match the expected format at compile time. - -**Incorrect (plain string allows any value):** - -```typescript -type EventHandler = { - event: string - handler: () => void -} - -const handler: EventHandler = { - event: 'click', // OK - handler: () => {} -} - -const badHandler: EventHandler = { - event: 'clck', // Typo - no error - handler: () => {} -} - -function addEventListener(event: string, handler: () => void): void {} -addEventListener('onlcick', () => {}) // Typo compiles fine -``` - -**Correct (template literal type validates pattern):** - -```typescript -type DOMEvent = 'click' | 'focus' | 'blur' | 'submit' | 'change' -type EventHandlerName = `on${Capitalize}` - -type EventHandler = { - event: EventHandlerName - handler: () => void -} - -const handler: EventHandler = { - event: 'onClick', // OK - handler: () => {} -} - -const badHandler: EventHandler = { - event: 'onClck', // Error: Type '"onClck"' is not assignable to type 'EventHandlerName' - handler: () => {} -} -``` - -**For CSS-like patterns:** - -```typescript -type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw' -type CSSValue = `${number}${CSSUnit}` - -function setWidth(element: HTMLElement, width: CSSValue): void { - element.style.width = width -} - -setWidth(div, '100px') // OK -setWidth(div, '2.5rem') // OK -setWidth(div, '100') // Error: Type '"100"' is not assignable to type 'CSSValue' -setWidth(div, '100pixels') // Error -``` - -**For API route patterns:** - -```typescript -type APIVersion = 'v1' | 'v2' -type Resource = 'users' | 'orders' | 'products' -type APIRoute = `/api/${APIVersion}/${Resource}` - -function fetchResource(route: APIRoute): Promise { - return fetch(route) -} - -fetchResource('/api/v1/users') // OK -fetchResource('/api/v2/orders') // OK -fetchResource('/api/v3/users') // Error: 'v3' not in APIVersion -fetchResource('/users') // Error: doesn't match pattern -``` - -**Combining with mapped types:** - -```typescript -type Getters = { - [K in keyof T as `get${Capitalize}`]: () => T[K] -} - -interface User { - name: string - age: number -} - -type UserGetters = Getters -// { getName: () => string; getAge: () => number } -``` - -Reference: [TypeScript 4.1 Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) diff --git a/.agents/skills/typescript/references/async-avoid-loop-await.md b/.agents/skills/typescript/references/async-avoid-loop-await.md deleted file mode 100644 index e299ef0d99a..00000000000 --- a/.agents/skills/typescript/references/async-avoid-loop-await.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Avoid await Inside Loops -impact: HIGH -impactDescription: N× faster for N iterations, 10 users = 10× improvement -tags: async, loops, batching, waterfalls, performance ---- - -## Avoid await Inside Loops - -Using `await` inside a loop creates N sequential operations. Collect promises and await them together, or use `Promise.all()` with `map()` for parallel execution. - -**Incorrect (N sequential requests):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const enrichedUsers: EnrichedUser[] = [] - - for (const userId of userIds) { - const user = await fetchUser(userId) // Waits for each request - const profile = await fetchProfile(userId) - enrichedUsers.push({ ...user, profile }) - } - // 10 users × 2 requests × 100ms = 2000ms - - return enrichedUsers -} -``` - -**Correct (parallel execution):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const enrichedUsers = await Promise.all( - userIds.map(async (userId) => { - const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)]) - return { ...user, profile } - }) - ) - // 10 users processed in parallel = 100ms total - - return enrichedUsers -} -``` - -**For rate-limited APIs (chunked batching):** - -```typescript -async function enrichUsers(userIds: string[]): Promise { - const BATCH_SIZE = 5 - const results: EnrichedUser[] = [] - - for (let i = 0; i < userIds.length; i += BATCH_SIZE) { - const batch = userIds.slice(i, i + BATCH_SIZE) - const batchResults = await Promise.all( - batch.map(async (userId) => { - const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)]) - return { ...user, profile } - }) - ) - results.push(...batchResults) - } - - return results -} -``` - -**When sequential loop await is acceptable:** - -- Each iteration depends on the previous result -- API strictly requires sequential calls -- Processing order affects correctness - -Reference: [ESLint no-await-in-loop](https://eslint.org/docs/rules/no-await-in-loop) diff --git a/.agents/skills/typescript/references/async-avoid-unnecessary-async.md b/.agents/skills/typescript/references/async-avoid-unnecessary-async.md deleted file mode 100644 index 252f11b8617..00000000000 --- a/.agents/skills/typescript/references/async-avoid-unnecessary-async.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Avoid Unnecessary async/await -impact: HIGH -impactDescription: eliminates trivial Promise wrappers and improves stack traces -tags: async, promises, overhead, optimization, return-await ---- - -## Avoid Unnecessary async/await - -Remove `async` from functions that only wrap a single Promise without using `await` for control flow. However, prefer `return await` over bare `return` inside try/catch blocks — it ensures errors are caught and produces better stack traces. - -**Incorrect (trivial async wrapper with no logic):** - -```typescript -async function getUser(userId: string): Promise { - return userRepository.findById(userId) - // async keyword creates unnecessary Promise wrapper - // No await, no try/catch — async adds nothing here -} - -async function deleteUser(userId: string): Promise { - return userRepository.delete(userId) - // Same pattern — async is pure overhead -} -``` - -**Correct (remove async when it adds nothing):** - -```typescript -function getUser(userId: string): Promise { - return userRepository.findById(userId) - // Direct Promise return, no wrapper -} - -function deleteUser(userId: string): Promise { - return userRepository.delete(userId) -} -``` - -**Keep async + return await in try/catch:** - -```typescript -// Correct — return await ensures the error is caught -async function getUser(userId: string): Promise { - try { - return await userRepository.findById(userId) - // Without await, rejected promise skips the catch block - } catch (error) { - logger.error('Failed to fetch user', { userId, error }) - throw new UserNotFoundError(userId) - } -} -``` - -**When async IS needed:** - -- Multiple sequential await statements -- Try/catch around await (use `return await` here) -- Conditional await logic -- Complex control flow with early returns - -**Note:** ESLint deprecated `no-return-await` in v8.46.0 because `return await` is both safe and produces better stack traces. Use the `@typescript-eslint/return-await` rule with `in-try-catch` setting for nuanced control. - -Reference: [ESLint no-return-await deprecation](https://eslint.org/docs/latest/rules/no-return-await) diff --git a/.agents/skills/typescript/references/async-defer-await.md b/.agents/skills/typescript/references/async-defer-await.md deleted file mode 100644 index 0a9f0296258..00000000000 --- a/.agents/skills/typescript/references/async-defer-await.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Defer await Until Value Is Needed -impact: HIGH -impactDescription: enables implicit parallelization -tags: async, defer, promises, optimization, performance ---- - -## Defer await Until Value Is Needed - -Start async operations immediately but defer `await` until the value is actually required. This allows independent work to proceed while promises resolve in the background. - -**Incorrect (blocks immediately, serializes independent work):** - -```typescript -async function processOrder(orderId: string): Promise { - const order = await fetchOrder(orderId) // Blocks here - const config = await loadProcessingConfig() // Waits for order first, unnecessarily - - // config doesn't depend on order — these could run in parallel - if (order.priority === 'express') { - return processExpress(order, config) - } - return processStandard(order, config) -} -``` - -**Correct (deferred await):** - -```typescript -async function processOrder(orderId: string): Promise { - const orderPromise = fetchOrder(orderId) // Start immediately - const config = await loadProcessingConfig() // Runs while order fetches - - const order = await orderPromise // Now await when needed - - if (order.priority === 'express') { - return processExpress(order, config) - } - return processStandard(order, config) -} -``` - -**Pattern for dependent-then-independent operations:** - -```typescript -async function loadUserContent(userId: string): Promise { - // Start user fetch (needed for dependent calls) - const userPromise = fetchUser(userId) - - // Start independent operations immediately - const settingsPromise = fetchGlobalSettings() - const featuresPromise = fetchFeatureFlags() - - // Await user for dependent operations - const user = await userPromise - const ordersPromise = fetchOrders(user.id) - const prefsPromise = fetchPreferences(user.id) - - // Await all remaining - const [settings, features, orders, prefs] = await Promise.all([ - settingsPromise, - featuresPromise, - ordersPromise, - prefsPromise - ]) - - return { user, settings, features, orders, prefs } -} -``` - -Reference: [V8 Blog - Fast Async](https://v8.dev/blog/fast-async) diff --git a/.agents/skills/typescript/references/async-explicit-return-types.md b/.agents/skills/typescript/references/async-explicit-return-types.md deleted file mode 100644 index 82683582a26..00000000000 --- a/.agents/skills/typescript/references/async-explicit-return-types.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Annotate Async Function Return Types -impact: HIGH -impactDescription: prevents runtime errors, improves inference -tags: async, return-types, promises, type-safety, inference ---- - -## Annotate Async Function Return Types - -Explicit return types on async functions catch mismatches at the function boundary rather than at call sites. They also improve IDE performance by avoiding full function body inference. - -**Incorrect (inferred Promise type):** - -```typescript -async function fetchUserOrders(userId: string) { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return null // Implicit: Promise - } - return response.json() // Implicit: Promise -} - -// Caller has unclear type: Promise -const orders = await fetchUserOrders('123') -orders.map((o) => o.id) // No type error even if orders is null -``` - -**Correct (explicit Promise type):** - -```typescript -interface Order { - id: string - total: number - status: OrderStatus -} - -async function fetchUserOrders(userId: string): Promise { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return null - } - return response.json() as Promise -} - -// Caller knows the exact type -const orders = await fetchUserOrders('123') -if (orders) { - orders.map((o) => o.id) // Type-safe access -} -``` - -**For functions that might throw:** - -```typescript -type Result = { ok: true; value: T } | { ok: false; error: E } - -async function fetchUserOrders(userId: string): Promise> { - try { - const response = await fetch(`/api/users/${userId}/orders`) - if (!response.ok) { - return { ok: false, error: new Error(`HTTP ${response.status}`) } - } - const orders = (await response.json()) as Order[] - return { ok: true, value: orders } - } catch (error) { - return { ok: false, error: error as Error } - } -} -``` - -**Benefits:** - -- Errors caught at function definition, not call sites -- Better IDE autocomplete for consumers -- Self-documenting API contracts - -Reference: [TypeScript Performance Wiki - Using Type Annotations](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations) diff --git a/.agents/skills/typescript/references/async-parallel-promises.md b/.agents/skills/typescript/references/async-parallel-promises.md deleted file mode 100644 index 6f565b947f6..00000000000 --- a/.agents/skills/typescript/references/async-parallel-promises.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Use Promise.all for Independent Operations -impact: HIGH -impactDescription: 2-10× improvement in I/O-bound code -tags: async, promises, parallel, waterfalls, performance ---- - -## Use Promise.all for Independent Operations - -Sequential `await` statements create request waterfalls—each operation waits for the previous one to complete. Use `Promise.all()` to execute independent async operations concurrently. - -**Incorrect (sequential execution, N round trips):** - -```typescript -async function loadDashboard(userId: string): Promise { - const user = await fetchUser(userId) // 200ms - const orders = await fetchOrders(userId) // 300ms - const notifications = await fetchNotifications(userId) // 150ms - // Total: 650ms (sequential) - - return { user, orders, notifications } -} -``` - -**Correct (parallel execution, wall-clock time = max latency):** - -```typescript -async function loadDashboard(userId: string): Promise { - const [user, orders, notifications] = await Promise.all([ - fetchUser(userId), // 200ms ─┐ - fetchOrders(userId), // 300ms ─┼─ Run in parallel - fetchNotifications(userId) // 150ms ─┘ - ]) - // Total: 300ms (max of all operations) - - return { user, orders, notifications } -} -``` - -**For error handling with partial success:** - -```typescript -async function loadDashboard(userId: string): Promise { - const results = await Promise.allSettled([ - fetchUser(userId), - fetchOrders(userId), - fetchNotifications(userId) - ]) - - return { - user: results[0].status === 'fulfilled' ? results[0].value : null, - orders: results[1].status === 'fulfilled' ? results[1].value : [], - notifications: results[2].status === 'fulfilled' ? results[2].value : [] - } -} -``` - -**When sequential is correct:** - -- Operations have data dependencies (need result A to make request B) -- Rate limiting requires sequential requests -- Order of execution matters for side effects - -Reference: [MDN Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) diff --git a/.agents/skills/typescript/references/mem-avoid-closure-leaks.md b/.agents/skills/typescript/references/mem-avoid-closure-leaks.md deleted file mode 100644 index af2aa3b9049..00000000000 --- a/.agents/skills/typescript/references/mem-avoid-closure-leaks.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Avoid Closure Memory Leaks -impact: MEDIUM -impactDescription: prevents retained references in long-lived callbacks -tags: mem, closures, memory-leaks, callbacks, garbage-collection ---- - -## Avoid Closure Memory Leaks - -Closures retain references to their outer scope variables. Long-lived callbacks can accidentally keep large objects alive, causing memory to grow unboundedly. - -**Incorrect (closure retains entire scope):** - -```typescript -function createDataProcessor(largeDataset: DataRecord[]): () => void { - const processedIds = new Set() - - return function processNext(): void { - // This closure retains reference to largeDataset - // even though it only needs processedIds - const next = largeDataset.find((r) => !processedIds.has(r.id)) - if (next) { - processedIds.add(next.id) - sendToServer(next) - } - } -} - -// largeDataset (100MB) stays in memory as long as processNext exists -const processor = createDataProcessor(hugeDataset) -setInterval(processor, 1000) // Runs forever, 100MB never freed -``` - -**Correct (closure captures only what it needs):** - -```typescript -function createDataProcessor(largeDataset: DataRecord[]): () => void { - // Build a queue of just the IDs and a lookup for individual records - const pendingQueue: string[] = largeDataset.map((r) => r.id) - const getRecord = (id: string): DataRecord | undefined => largeDataset.find((r) => r.id === id) - - // Release the array reference — closure only captures pendingQueue and getRecord - // Caller should also release their reference to largeDataset - return function processNext(): void { - const nextId = pendingQueue.shift() - if (nextId) { - const record = getRecord(nextId) - if (record) sendToServer(record) - } - } -} -``` - -**Better pattern — accept an iterator to avoid holding the full dataset:** - -```typescript -function createDataProcessor(records: Iterable): () => void { - const iterator = records[Symbol.iterator]() - - return function processNext(): void { - const { value, done } = iterator.next() - if (!done) { - sendToServer(value) - } - } -} -``` - -**For event handlers:** - -```typescript -// Incorrect - handler retains component instance forever -class Dashboard { - private largeCache: Map = new Map() - - initialize(): void { - window.addEventListener('resize', () => { - this.handleResize() // 'this' keeps entire Dashboard alive - }) - } -} - -// Correct - remove listener when done -class Dashboard { - private largeCache: Map = new Map() - private resizeHandler: () => void - - initialize(): void { - this.resizeHandler = () => this.handleResize() - window.addEventListener('resize', this.resizeHandler) - } - - destroy(): void { - window.removeEventListener('resize', this.resizeHandler) - this.largeCache.clear() - } -} -``` - -Reference: [Node.js Memory Diagnostics](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/references/mem-avoid-global-state.md b/.agents/skills/typescript/references/mem-avoid-global-state.md deleted file mode 100644 index e1a0fdde2b9..00000000000 --- a/.agents/skills/typescript/references/mem-avoid-global-state.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Avoid Global State Accumulation -impact: MEDIUM -impactDescription: prevents unbounded memory growth -tags: mem, global-state, singletons, memory-leaks, caching ---- - -## Avoid Global State Accumulation - -Global variables and module-level state persist for the application's lifetime. Unbounded caches or collections at module scope grow indefinitely, causing memory exhaustion. - -**Incorrect (unbounded global cache):** - -```typescript -// cache.ts -const userCache = new Map() // Never cleared - -export function getCachedUser(id: string): User | undefined { - return userCache.get(id) -} - -export function cacheUser(user: User): void { - userCache.set(user.id, user) - // Cache grows forever, never evicts old entries -} - -// After 1 million users, cache holds 1 million User objects -``` - -**Correct (bounded cache with eviction):** - -```typescript -// cache.ts -class LRUCache { - private cache = new Map() - private maxSize: number - - constructor(maxSize: number) { - this.maxSize = maxSize - } - - get(key: K): V | undefined { - const value = this.cache.get(key) - if (value !== undefined) { - // Move to end (most recently used) - this.cache.delete(key) - this.cache.set(key, value) - } - return value - } - - set(key: K, value: V): void { - if (this.cache.has(key)) { - this.cache.delete(key) - } else if (this.cache.size >= this.maxSize) { - // Evict oldest (first) entry - const oldest = this.cache.keys().next().value - this.cache.delete(oldest) - } - this.cache.set(key, value) - } -} - -const userCache = new LRUCache(1000) // Max 1000 entries - -export function getCachedUser(id: string): User | undefined { - return userCache.get(id) -} - -export function cacheUser(user: User): void { - userCache.set(user.id, user) -} -``` - -**For request-scoped state (Node.js):** - -```typescript -import { AsyncLocalStorage } from 'async_hooks' - -interface RequestContext { - userId: string - cache: Map -} - -const requestContext = new AsyncLocalStorage() - -export function runWithContext(context: RequestContext, fn: () => T): T { - return requestContext.run(context, fn) - // Context is automatically cleaned up when request ends -} - -export function getRequestCache(): Map { - return requestContext.getStore()?.cache ?? new Map() -} -``` - -Reference: [Node.js Memory Management](https://nodejs.org/en/learn/diagnostics/memory) diff --git a/.agents/skills/typescript/references/mem-cleanup-event-listeners.md b/.agents/skills/typescript/references/mem-cleanup-event-listeners.md deleted file mode 100644 index ae58671d7cf..00000000000 --- a/.agents/skills/typescript/references/mem-cleanup-event-listeners.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Clean Up Event Listeners -impact: MEDIUM -impactDescription: prevents unbounded memory growth -tags: mem, event-listeners, cleanup, memory-leaks, lifecycle ---- - -## Clean Up Event Listeners - -Event listeners hold references to their callback functions and bound objects. Failing to remove them when components unmount causes memory to grow with each mount/unmount cycle. - -**Incorrect (listeners never removed):** - -```typescript -class WebSocketManager { - private socket: WebSocket - - connect(url: string): void { - this.socket = new WebSocket(url) - - this.socket.addEventListener('message', (event) => { - this.handleMessage(event.data) - }) - - this.socket.addEventListener('error', (event) => { - this.handleError(event) - }) - // Listeners keep 'this' alive even after disconnect - } - - disconnect(): void { - this.socket.close() - // Listeners still attached, WebSocketManager can't be GC'd - } -} -``` - -**Correct (listeners removed on cleanup):** - -```typescript -class WebSocketManager { - private socket: WebSocket - private messageHandler: (event: MessageEvent) => void - private errorHandler: (event: Event) => void - - connect(url: string): void { - this.socket = new WebSocket(url) - - this.messageHandler = (event) => this.handleMessage(event.data) - this.errorHandler = (event) => this.handleError(event) - - this.socket.addEventListener('message', this.messageHandler) - this.socket.addEventListener('error', this.errorHandler) - } - - disconnect(): void { - this.socket.removeEventListener('message', this.messageHandler) - this.socket.removeEventListener('error', this.errorHandler) - this.socket.close() - } -} -``` - -**Using AbortController (modern pattern):** - -```typescript -class WebSocketManager { - private socket: WebSocket - private abortController: AbortController - - connect(url: string): void { - this.abortController = new AbortController() - const { signal } = this.abortController - - this.socket = new WebSocket(url) - - this.socket.addEventListener('message', (e) => this.handleMessage(e.data), { signal }) - this.socket.addEventListener('error', (e) => this.handleError(e), { signal }) - // All listeners automatically removed when signal is aborted - } - - disconnect(): void { - this.abortController.abort() // Removes all listeners at once - this.socket.close() - } -} -``` - -**React useEffect pattern:** - -```typescript -function useWebSocket(url: string): Data | null { - const [data, setData] = useState(null) - - useEffect(() => { - const socket = new WebSocket(url) - const handler = (event: MessageEvent) => setData(JSON.parse(event.data)) - - socket.addEventListener('message', handler) - - return () => { - socket.removeEventListener('message', handler) - socket.close() - } - }, [url]) - - return data -} -``` - -Reference: [MDN AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) diff --git a/.agents/skills/typescript/references/mem-clear-timers.md b/.agents/skills/typescript/references/mem-clear-timers.md deleted file mode 100644 index 0d8e7a28b82..00000000000 --- a/.agents/skills/typescript/references/mem-clear-timers.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Clear Timers and Intervals -impact: MEDIUM -impactDescription: prevents callback retention and repeated execution -tags: mem, timers, intervals, cleanup, memory-leaks ---- - -## Clear Timers and Intervals - -`setInterval` and `setTimeout` callbacks retain references to their closure scope. Failing to clear them causes callbacks to execute indefinitely and prevents garbage collection of referenced objects. - -**Incorrect (intervals never cleared):** - -```typescript -class DataPoller { - private data: LargeDataset - - start(): void { - setInterval(() => { - this.data = fetchLatestData() - this.updateDashboard() - }, 5000) - // No reference to interval ID, can't clear it - } - - stop(): void { - // Can't stop the interval - it runs forever - // 'this' is retained, DataPoller can't be GC'd - } -} - -// Each new DataPoller instance creates another interval -// Old instances can't be cleaned up -``` - -**Correct (intervals tracked and cleared):** - -```typescript -class DataPoller { - private data: LargeDataset - private intervalId: ReturnType | null = null - - start(): void { - if (this.intervalId) return // Prevent duplicate intervals - - this.intervalId = setInterval(() => { - this.data = fetchLatestData() - this.updateDashboard() - }, 5000) - } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId) - this.intervalId = null - } - } -} -``` - -**For multiple timers:** - -```typescript -class AnimationController { - private timers = new Set>() - - scheduleAnimation(delay: number, callback: () => void): void { - const timerId = setTimeout(() => { - this.timers.delete(timerId) - callback() - }, delay) - this.timers.add(timerId) - } - - cancelAll(): void { - for (const timerId of this.timers) { - clearTimeout(timerId) - } - this.timers.clear() - } -} -``` - -**React hook pattern:** - -```typescript -function usePolling(callback: () => void, interval: number): void { - useEffect(() => { - const id = setInterval(callback, interval) - return () => clearInterval(id) // Cleanup on unmount - }, [callback, interval]) -} - -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value) - - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay) - return () => clearTimeout(timer) // Clear on value change or unmount - }, [value, delay]) - - return debouncedValue -} -``` - -Reference: [MDN clearInterval](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) diff --git a/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md b/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md deleted file mode 100644 index b76cbbe6914..00000000000 --- a/.agents/skills/typescript/references/mem-use-weakmap-for-metadata.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Use WeakMap for Object Metadata -impact: MEDIUM -impactDescription: prevents memory leaks, enables automatic cleanup -tags: mem, weakmap, metadata, garbage-collection, memory-leaks ---- - -## Use WeakMap for Object Metadata - -WeakMap allows garbage collection of keys when no other references exist. Use it for associating metadata with objects without preventing their cleanup. - -**Incorrect (Map retains object references):** - -```typescript -const userMetadata = new Map() - -function trackUser(user: User): void { - userMetadata.set(user, { - lastSeen: Date.now(), - pageViews: 0 - }) -} - -function removeUser(user: User): void { - // Even after user is "removed" from app state, - // Map still holds reference, preventing GC - userMetadata.delete(user) // Must manually clean up -} - -// If delete is forgotten, user objects leak forever -``` - -**Correct (WeakMap allows GC):** - -```typescript -const userMetadata = new WeakMap() - -function trackUser(user: User): void { - userMetadata.set(user, { - lastSeen: Date.now(), - pageViews: 0 - }) -} - -// No cleanup needed - when user object is GC'd, -// WeakMap entry is automatically removed -function processUsers(users: User[]): void { - for (const user of users) { - trackUser(user) - } - // When users array is cleared, all metadata is cleaned up automatically -} -``` - -**Common use cases:** - -```typescript -// DOM element metadata -const elementState = new WeakMap() - -function attachState(element: HTMLElement): void { - elementState.set(element, { isExpanded: false }) - // When element is removed from DOM and GC'd, state is cleaned up -} - -// Caching computed values -const computedCache = new WeakMap() - -function getComputedConfig(config: Config): ComputedConfig { - let computed = computedCache.get(config) - if (!computed) { - computed = expensiveComputation(config) - computedCache.set(config, computed) - } - return computed -} -``` - -**Limitations of WeakMap:** - -- Keys must be objects (not primitives) -- Not iterable (no `.keys()`, `.values()`, `.entries()`) -- No `.size` property - -Reference: [MDN WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) diff --git a/.agents/skills/typescript/references/module-avoid-barrel-imports.md b/.agents/skills/typescript/references/module-avoid-barrel-imports.md deleted file mode 100644 index 85fbeae7f79..00000000000 --- a/.agents/skills/typescript/references/module-avoid-barrel-imports.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Avoid Barrel File Imports -impact: HIGH -impactDescription: 200-800ms import cost, 30-50% larger bundles -tags: module, barrel-files, imports, tree-shaking, bundling ---- - -## Avoid Barrel File Imports - -Barrel files (index.ts re-exports) defeat tree-shaking and force bundlers to load entire module graphs. Import directly from source files to enable proper dead-code elimination. - -**Incorrect (imports entire module tree):** - -```typescript -// utils/index.ts (barrel file) -export * from './string' -export * from './date' -export * from './validation' -export * from './crypto' // Heavy, rarely used - -// consumer.ts -import { formatDate } from '@/utils' -// Loads ALL utils modules, including crypto -// Bundle includes 50KB of unused code -``` - -**Correct (direct imports):** - -```typescript -// consumer.ts -import { formatDate } from '@/utils/date' -// Loads only the date module -// Bundle includes only what's used -``` - -**For icon libraries (common barrel offender):** - -```typescript -// Incorrect - loads all 1500+ icons -import { Check, X } from 'lucide-react' - -// Correct - loads only 2 icons -import Check from 'lucide-react/dist/esm/icons/check' -import X from 'lucide-react/dist/esm/icons/x' -``` - -**Alternative (configure bundler optimization):** - -```javascript -// next.config.js -module.exports = { - experimental: { - optimizePackageImports: ['lucide-react', '@mui/material', 'lodash'] - } -} - -// vite.config.ts -export default { - optimizeDeps: { - include: ['lucide-react'] - } -} -``` - -**When barrels are acceptable:** - -- Internal modules with few exports (< 10) -- Package entry points for library consumers -- When bundler is configured to optimize them - -Reference: [Vercel - How we optimized package imports](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) diff --git a/.agents/skills/typescript/references/module-avoid-circular-dependencies.md b/.agents/skills/typescript/references/module-avoid-circular-dependencies.md deleted file mode 100644 index d56e0e6f781..00000000000 --- a/.agents/skills/typescript/references/module-avoid-circular-dependencies.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: Avoid Circular Dependencies -impact: HIGH -impactDescription: prevents runtime undefined errors and slow compilation -tags: module, circular, dependencies, architecture, compilation ---- - -## Avoid Circular Dependencies - -Circular dependencies cause undefined values at runtime (due to incomplete module initialization) and slow TypeScript compilation as the checker resolves cycles repeatedly. - -**Incorrect (circular dependency):** - -```typescript -// user.ts -import { Order } from './order' - -export interface User { - id: string - orders: Order[] -} - -export function createUser(): User { - /* ... */ -} - -// order.ts -import { User } from './user' // Circular! - -export interface Order { - id: string - user: User -} - -export function createOrder(user: User): Order { - // 'createUser' might be undefined if order.ts loads first -} -``` - -**Correct (extract shared types):** - -```typescript -// types.ts (no dependencies) -export interface User { - id: string - orders: Order[] -} - -export interface Order { - id: string - user: User -} - -// user.ts -import { User, Order } from './types' - -export function createUser(): User { - /* ... */ -} - -// order.ts -import { User, Order } from './types' - -export function createOrder(user: User): Order { - /* ... */ -} -``` - -**Alternative (interface segregation):** - -```typescript -// user-types.ts -export interface UserBase { - id: string - name: string -} - -// order.ts -import { UserBase } from './user-types' - -export interface Order { - id: string - user: UserBase // Only needs base interface, not full User -} -``` - -**Detection tools:** - -```bash -# Madge - visualize circular dependencies -npx madge --circular --extensions ts ./src - -# ESLint plugin -npm install eslint-plugin-import -# Rule: import/no-cycle -``` - -Reference: [Node.js Cycles Documentation](https://nodejs.org/api/modules.html#cycles) diff --git a/.agents/skills/typescript/references/module-control-types-inclusion.md b/.agents/skills/typescript/references/module-control-types-inclusion.md deleted file mode 100644 index ce22a732740..00000000000 --- a/.agents/skills/typescript/references/module-control-types-inclusion.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Control @types Package Inclusion -impact: HIGH -impactDescription: prevents type conflicts and reduces memory usage -tags: module, types, tsconfig, declaration-files, performance ---- - -## Control @types Package Inclusion - -By default, TypeScript loads all `@types/*` packages from `node_modules`. This causes conflicts between incompatible type versions and wastes memory loading unused declarations. - -**Incorrect (loads all @types automatically):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext" - } -} -``` - -```bash -# All @types/* packages loaded: -# @types/node, @types/react, @types/express, @types/lodash, -# @types/jest, @types/mocha (conflict!), @types/jasmine (conflict!) -``` - -**Correct (explicit types inclusion):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "types": ["node", "react", "jest"] - } -} -``` - -```bash -# Only specified @types loaded -# No conflicts between test frameworks -``` - -**For different environments:** - -```json -// tsconfig.json (base) -{ - "compilerOptions": { - "types": [] - } -} - -// tsconfig.node.json (Node.js scripts) -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node"] - } -} - -// tsconfig.test.json (Jest tests) -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node", "jest"] - } -} -``` - -**Using typeRoots for custom declarations:** - -```json -{ - "compilerOptions": { - "typeRoots": [ - "./types", // Custom declarations first - "./node_modules/@types" // Then @types - ], - "types": ["node"] - } -} -``` - -**Benefits:** - -- Prevents type conflicts between similar packages -- Reduces memory usage during compilation -- Faster IDE responsiveness - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#controlling-types-inclusion) diff --git a/.agents/skills/typescript/references/module-dynamic-imports.md b/.agents/skills/typescript/references/module-dynamic-imports.md deleted file mode 100644 index c2f35946573..00000000000 --- a/.agents/skills/typescript/references/module-dynamic-imports.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use Dynamic Imports for Large Modules -impact: HIGH -impactDescription: reduces initial bundle by 30-70% -tags: module, dynamic-import, code-splitting, lazy-loading, bundling ---- - -## Use Dynamic Imports for Large Modules - -Dynamic `import()` creates separate chunks that load on demand. Use them for large dependencies, route-specific code, and features that aren't needed immediately. - -**Incorrect (static import, always loaded):** - -```typescript -import { PDFGenerator } from 'pdfkit' // 500KB -import { ExcelExporter } from 'exceljs' // 800KB -import { ChartLibrary } from 'chart.js' // 300KB - -export async function exportReport(format: 'pdf' | 'excel' | 'chart') { - if (format === 'pdf') { - return new PDFGenerator().generate() - } - // All 1.6MB loaded even if user never exports -} -``` - -**Correct (dynamic import, loaded on demand):** - -```typescript -export async function exportReport(format: 'pdf' | 'excel' | 'chart') { - if (format === 'pdf') { - const { PDFGenerator } = await import('pdfkit') - return new PDFGenerator().generate() - } - - if (format === 'excel') { - const { ExcelExporter } = await import('exceljs') - return new ExcelExporter().export() - } - - const { ChartLibrary } = await import('chart.js') - return new ChartLibrary().render() -} -// Only loads the module needed for the specific format -``` - -**With TypeScript typing:** - -```typescript -async function loadPdfGenerator(): Promise { - return import('pdfkit') -} - -// Or with type-only import for the interface -import type { PDFDocument } from 'pdfkit' - -async function generatePdf(): Promise { - const { default: PDFDocument } = await import('pdfkit') - return new PDFDocument() -} -``` - -**Framework-specific patterns:** - -```typescript -// Next.js -import dynamic from 'next/dynamic' - -const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { - loading: () => , - ssr: false // Skip server-side rendering -}) - -// React -const HeavyChart = React.lazy(() => import('@/components/HeavyChart')) -``` - -Reference: [MDN Dynamic Import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) diff --git a/.agents/skills/typescript/references/module-use-type-imports.md b/.agents/skills/typescript/references/module-use-type-imports.md deleted file mode 100644 index bffd6d20b7a..00000000000 --- a/.agents/skills/typescript/references/module-use-type-imports.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Use Type-Only Imports for Types -impact: HIGH -impactDescription: eliminates runtime imports for type information -tags: module, type-imports, tree-shaking, bundling, compilation ---- - -## Use Type-Only Imports for Types - -Type-only imports (`import type`) are completely erased during compilation, preventing unnecessary runtime module loading. Regular imports of types can force module execution even when only the type is needed. - -**Incorrect (runtime import for type-only usage):** - -```typescript -// config.ts -import { DatabaseConfig } from './database' // Loads entire database module -import { Logger } from './logger' // Loads entire logger module - -interface AppConfig { - db: DatabaseConfig - logger: Logger -} - -// Runtime: database.js and logger.js are both loaded -// even though we only use their types -``` - -**Correct (type-only imports):** - -```typescript -// config.ts -import type { DatabaseConfig } from './database' -import type { Logger } from './logger' - -interface AppConfig { - db: DatabaseConfig - logger: Logger -} - -// Runtime: no modules loaded, types are erased -``` - -**Mixed imports (types and values):** - -```typescript -// Incorrect - unclear what's type vs value -import { User, createUser, UserRole } from './user' - -// Correct - explicit separation -import { createUser } from './user' -import type { User, UserRole } from './user' - -// Or inline type imports (TypeScript 4.5+) -import { createUser, type User, type UserRole } from './user' -``` - -**Enable enforcement:** - -```json -// tsconfig.json -{ - "compilerOptions": { - "verbatimModuleSyntax": true - } -} - -// .eslintrc -{ - "rules": { - "@typescript-eslint/consistent-type-imports": "error" - } -} -``` - -**Benefits:** - -- Smaller bundles (unused modules not included) -- Faster cold starts (fewer modules to parse) -- Clearer code intent (types vs runtime values) - -Reference: [TypeScript 3.8 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export) diff --git a/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md b/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md deleted file mode 100644 index e01e455eb79..00000000000 --- a/.agents/skills/typescript/references/runtime-avoid-object-spread-in-loops.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Avoid Object Spread in Hot Loops -impact: LOW-MEDIUM -impactDescription: reduces object allocations by N× -tags: runtime, object-spread, loops, allocation, performance ---- - -## Avoid Object Spread in Hot Loops - -Object spread (`...`) creates a new object on each use. In loops, this causes N object allocations and copies. Mutate objects directly when creating new instances isn't required. - -**Incorrect (N object allocations):** - -```typescript -function enrichOrders(orders: Order[]): EnrichedOrder[] { - return orders.map((order) => ({ - ...order, // Creates new object - ...calculateTotals(order), // Spreads another object - processedAt: new Date() - })) -} -// 10,000 orders = 10,000 object spreads = significant GC pressure -``` - -**Correct (direct assignment):** - -```typescript -interface EnrichedOrder extends Order { - tax: number - shipping: number - total: number - processedAt: Date -} - -function enrichOrders(orders: Order[]): EnrichedOrder[] { - return orders.map((order) => { - const totals = calculateTotals(order) - - return { - id: order.id, - customerId: order.customerId, - items: order.items, - subtotal: order.subtotal, - tax: totals.tax, - shipping: totals.shipping, - total: totals.total, - processedAt: new Date() - } - }) -} -``` - -**Note:** For immutable object creation, explicit property listing is the only spread-free option. This trades verbosity for performance in hot paths. If immutability isn't required, mutating the original object is faster still. - -**For accumulation patterns:** - -```typescript -// Incorrect - spreads on every iteration -const result = items.reduce( - (acc, item) => ({ - ...acc, - [item.id]: item.value - }), - {} -) -// O(n²) - each spread copies growing object - -// Correct - mutate accumulator -const result = items.reduce( - (acc, item) => { - acc[item.id] = item.value - return acc - }, - {} as Record -) -// O(n) - direct property assignment -``` - -**When spread is acceptable:** - -- Outside hot paths -- Small objects (< 10 properties) -- When immutability is required for state management -- When readability significantly improves - -Reference: [V8 Object Shapes](https://mathiasbynens.be/notes/shapes-ics) diff --git a/.agents/skills/typescript/references/runtime-cache-property-access.md b/.agents/skills/typescript/references/runtime-cache-property-access.md deleted file mode 100644 index eab402e5dce..00000000000 --- a/.agents/skills/typescript/references/runtime-cache-property-access.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Cache Property Access in Loops -impact: LOW-MEDIUM -impactDescription: reduces property lookups by N× in hot paths -tags: runtime, loops, caching, property-access, optimization ---- - -## Cache Property Access in Loops - -Cache deeply nested or polymorphic property access before hot loops. **Note:** Modern V8's inline caches optimize monomorphic access efficiently — this optimization is only meaningful for 10,000+ iterations with deeply nested or polymorphic properties. - -**Incorrect (repeated nested access in hot loop):** - -```typescript -function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] { - const results: ProcessedOrder[] = [] - - for (const order of orders) { - const tax = order.total * config.tax.rate // Nested access each iteration - const shipping = config.shipping.rates[order.region] // Nested access again - results.push({ ...order, tax, shipping, final: order.total + tax + shipping }) - } - - return results -} -``` - -**Correct (cached property access):** - -```typescript -function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] { - const results: ProcessedOrder[] = [] - const taxRate = config.tax.rate - const shippingRates = config.shipping.rates - - for (const order of orders) { - const tax = order.total * taxRate - const shipping = shippingRates[order.region] - results.push({ ...order, tax, shipping, final: order.total + tax + shipping }) - } - - return results -} -``` - -**When V8 handles it automatically (no caching needed):** - -```typescript -// Monomorphic — all objects have same shape, V8 ICs optimize this -function sumOrders(orders: Order[]): number { - let total = 0 - for (let i = 0; i < orders.length; i++) { - // orders.length is fine - total += orders[i].total // Same shape every time - } - return total -} -``` - -**When to skip this optimization:** - -- Arrays under 1,000 items -- Monomorphic objects (same shape/class) -- Non-hot paths executed infrequently -- When readability suffers significantly - -Reference: [V8 Hidden Classes](https://v8.dev/blog/fast-properties) diff --git a/.agents/skills/typescript/references/runtime-prefer-array-methods.md b/.agents/skills/typescript/references/runtime-prefer-array-methods.md deleted file mode 100644 index 950074a3987..00000000000 --- a/.agents/skills/typescript/references/runtime-prefer-array-methods.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Prefer Native Array Methods Over Lodash -impact: LOW-MEDIUM -impactDescription: eliminates library overhead, enables tree-shaking -tags: runtime, arrays, lodash, native-methods, bundling ---- - -## Prefer Native Array Methods Over Lodash - -Modern JavaScript includes most common array operations. Native methods are faster (no function call overhead) and don't add bundle weight. Use native methods when they provide equivalent functionality. - -**Incorrect (lodash for native operations):** - -```typescript -import _ from 'lodash' // Imports entire library - -const activeUsers = _.filter(users, (u) => u.isActive) -const userNames = _.map(activeUsers, (u) => u.name) -const firstAdmin = _.find(users, (u) => u.role === 'admin') -const hasAdmin = _.some(users, (u) => u.role === 'admin') -const allActive = _.every(users, (u) => u.isActive) -const userIds = _.uniq(users.map((u) => u.id)) -``` - -**Correct (native methods):** - -```typescript -const activeUsers = users.filter((u) => u.isActive) -const userNames = activeUsers.map((u) => u.name) -const firstAdmin = users.find((u) => u.role === 'admin') -const hasAdmin = users.some((u) => u.role === 'admin') -const allActive = users.every((u) => u.isActive) -const userIds = [...new Set(users.map((u) => u.id))] -``` - -**Native replacements for common Lodash functions:** - -```typescript -// _.flatten / _.flattenDeep -const flat = nestedArrays.flat(Infinity) - -// _.chunk (still useful from lodash) -function chunk(array: T[], size: number): T[][] { - return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => - array.slice(i * size, i * size + size) - ) -} - -// _.groupBy -function groupBy(array: T[], key: keyof T): Record { - return array.reduce( - (groups, item) => { - const group = String(item[key]) - groups[group] = groups[group] ?? [] - groups[group].push(item) - return groups - }, - {} as Record - ) -} - -// Object.groupBy (ES2024) -const grouped = Object.groupBy(users, (user) => user.role) - -// _.pick / _.omit -const { password, ...userWithoutPassword } = user // omit -const { id, name } = user // pick -``` - -**When Lodash is still valuable:** - -- `_.debounce`, `_.throttle` - complex timing logic -- `_.cloneDeep` - deep object cloning -- `_.merge` - deep object merging -- `_.get` with default values (but optional chaining often suffices) - -Reference: [You Don't Need Lodash](https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore) diff --git a/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md b/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md deleted file mode 100644 index d5961f86dbc..00000000000 --- a/.agents/skills/typescript/references/runtime-use-for-of-for-iteration.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Use for-of for Simple Iteration -impact: LOW-MEDIUM -impactDescription: reduces iteration boilerplate by 30-50% -tags: runtime, loops, iteration, for-of, readability ---- - -## Use for-of for Simple Iteration - -`for-of` provides clean syntax for array iteration with performance comparable to traditional `for` loops. Use it when you don't need the index and aren't modifying the array. - -**Incorrect (index-based when index isn't needed):** - -```typescript -function calculateTotal(orders: Order[]): number { - let total = 0 - for (let i = 0; i < orders.length; i++) { - total += orders[i].amount - } - return total -} - -function processUsers(users: User[]): void { - for (let i = 0; i < users.length; i++) { - sendNotification(users[i]) - } -} -``` - -**Correct (for-of for clean iteration):** - -```typescript -function calculateTotal(orders: Order[]): number { - let total = 0 - for (const order of orders) { - total += order.amount - } - return total -} - -function processUsers(users: User[]): void { - for (const user of users) { - sendNotification(user) - } -} -``` - -**When to use each pattern:** - -```typescript -// for-of: when you only need values -for (const item of items) { - process(item) -} - -// forEach: when you want functional style (but can't break/return) -items.forEach((item) => process(item)) - -// for-in: only for object keys (never for arrays) -for (const key in config) { - console.log(key, config[key]) -} - -// Traditional for: when you need index, or need to modify loop -for (let i = 0; i < items.length; i++) { - if (items[i].id === targetId) { - items[i] = updatedItem // Modifying array - break // Early exit - } -} - -// entries(): when you need both index and value -for (const [index, item] of items.entries()) { - console.log(`${index}: ${item.name}`) -} -``` - -**Avoid for-in for arrays:** - -```typescript -// NEVER do this -for (const index in items) { - // index is a string, not number - // Iterates inherited properties - // Wrong order not guaranteed -} -``` - -Reference: [MDN for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of) diff --git a/.agents/skills/typescript/references/runtime-use-set-for-lookups.md b/.agents/skills/typescript/references/runtime-use-set-for-lookups.md deleted file mode 100644 index eb4734134c7..00000000000 --- a/.agents/skills/typescript/references/runtime-use-set-for-lookups.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Use Set/Map for O(1) Lookups -impact: LOW-MEDIUM -impactDescription: O(n) to O(1) per lookup -tags: runtime, set, map, lookup, performance ---- - -## Use Set/Map for O(1) Lookups - -Array methods like `.includes()` and `.find()` are O(n) operations. For frequent lookups, convert arrays to Set or Map for O(1) access. - -**Incorrect (O(n) per lookup):** - -```typescript -const allowedRoles = ['admin', 'editor', 'viewer', 'moderator'] - -function hasPermission(userRole: string): boolean { - return allowedRoles.includes(userRole) // O(n) every call -} - -// In a loop, this becomes O(n × m) -function filterAuthorizedUsers(users: User[]): User[] { - return users.filter((user) => allowedRoles.includes(user.role)) - // 1000 users × 4 roles = 4000 comparisons -} -``` - -**Correct (O(1) per lookup):** - -```typescript -const allowedRoles = new Set(['admin', 'editor', 'viewer', 'moderator']) - -function hasPermission(userRole: string): boolean { - return allowedRoles.has(userRole) // O(1) every call -} - -function filterAuthorizedUsers(users: User[]): User[] { - return users.filter((user) => allowedRoles.has(user.role)) - // 1000 users × O(1) = 1000 operations -} -``` - -**For object lookups by key:** - -```typescript -// Incorrect - O(n) search -const users: User[] = [ - /* ... */ -] -function findUserById(id: string): User | undefined { - return users.find((u) => u.id === id) // Scans entire array -} - -// Correct - O(1) lookup -const userById = new Map(users.map((u) => [u.id, u])) -function findUserById(id: string): User | undefined { - return userById.get(id) -} -``` - -**When to stick with arrays:** - -- Small collections (< 10 items) -- One-time lookups where conversion cost exceeds benefit -- When you need array methods like `.map()`, `.filter()`, `.slice()` - -Reference: [MDN Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) diff --git a/.agents/skills/typescript/references/runtime-use-string-methods.md b/.agents/skills/typescript/references/runtime-use-string-methods.md deleted file mode 100644 index e908fb95a9e..00000000000 --- a/.agents/skills/typescript/references/runtime-use-string-methods.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use Modern String Methods -impact: LOW-MEDIUM -impactDescription: 2-5× faster than regex for simple patterns -tags: runtime, strings, methods, performance, readability ---- - -## Use Modern String Methods - -Modern string methods like `startsWith()`, `endsWith()`, `includes()`, and `padStart()` are clearer and often faster than regex or manual substring operations. - -**Incorrect (regex or substring for simple checks):** - -```typescript -function isImageFile(filename: string): boolean { - return /\.(jpg|png|gif)$/.test(filename) -} - -function hasHttpPrefix(url: string): boolean { - return url.substring(0, 7) === 'http://' || url.substring(0, 8) === 'https://' -} - -function containsSearchTerm(text: string, term: string): boolean { - return text.indexOf(term) !== -1 -} - -function formatOrderId(id: number): string { - return ('000000' + id).slice(-6) // Pad to 6 digits -} -``` - -**Correct (modern string methods):** - -```typescript -function isImageFile(filename: string): boolean { - return filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif') -} - -function hasHttpPrefix(url: string): boolean { - return url.startsWith('http://') || url.startsWith('https://') -} - -function containsSearchTerm(text: string, term: string): boolean { - return text.includes(term) -} - -function formatOrderId(id: number): string { - return String(id).padStart(6, '0') -} -``` - -**Additional useful methods:** - -```typescript -// replaceAll (no global regex needed) -const sanitized = input.replaceAll('<', '<').replaceAll('>', '>') - -// at() for negative indexing -const lastChar = filename.at(-1) // Last character -const extension = filename.split('.').at(-1) // Last segment - -// trimStart/trimEnd for directional trimming -const trimmedLeft = ' text '.trimStart() // 'text ' -const trimmedRight = ' text '.trimEnd() // ' text' - -// repeat for string multiplication -const separator = '-'.repeat(40) -const indent = ' '.repeat(depth) -``` - -**When regex is still needed:** - -- Complex pattern matching -- Capture groups -- Case-insensitive matching (`/pattern/i`) -- Multiple conditions in one check - -Reference: [MDN String Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) diff --git a/.agents/skills/typescript/references/safety-assertion-functions.md b/.agents/skills/typescript/references/safety-assertion-functions.md deleted file mode 100644 index e9472e4522e..00000000000 --- a/.agents/skills/typescript/references/safety-assertion-functions.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Use Assertion Functions for Validation -impact: MEDIUM-HIGH -impactDescription: reduces validation boilerplate by 50-70% -tags: safety, assertion-functions, asserts, validation, narrowing ---- - -## Use Assertion Functions for Validation - -Assertion functions (`asserts` return type) tell TypeScript that if the function returns, the condition is true. This narrows types in the calling scope without explicit if-checks. - -**Incorrect (repeated if-throw pattern):** - -```typescript -function processOrder(order: Order | null): void { - if (!order) { - throw new Error('Order is required') - } - if (order.status !== 'pending') { - throw new Error('Order must be pending') - } - if (!order.items.length) { - throw new Error('Order must have items') - } - - // Finally can use order safely - submitOrder(order) -} - -// Same checks repeated in every function that needs a valid order -function shipOrder(order: Order | null): void { - if (!order) throw new Error('Order is required') - if (order.status !== 'pending') throw new Error('Order must be pending') - // ...duplicate validation -} -``` - -**Correct (assertion function):** - -```typescript -interface ValidOrder extends Order { - status: 'pending' - items: [OrderItem, ...OrderItem[]] // Non-empty array -} - -function assertValidOrder(order: Order | null): asserts order is ValidOrder { - if (!order) { - throw new Error('Order is required') - } - if (order.status !== 'pending') { - throw new Error('Order must be pending') - } - if (!order.items.length) { - throw new Error('Order must have items') - } -} - -function processOrder(order: Order | null): void { - assertValidOrder(order) - // order is now typed as ValidOrder - submitOrder(order) // Type-safe -} - -function shipOrder(order: Order | null): void { - assertValidOrder(order) - // Reuses validation, order is ValidOrder - ship(order) -} -``` - -**For generic assertions:** - -```typescript -function assertDefined(value: T | null | undefined, name: string): asserts value is T { - if (value === null || value === undefined) { - throw new Error(`${name} must be defined`) - } -} - -function processUser(user: User | null): void { - assertDefined(user, 'user') - // user is now User, not User | null - console.log(user.email) -} -``` - -**Benefits:** - -- Centralizes validation logic -- Automatic type narrowing after assertion -- Clearer intent than if-throw patterns - -Reference: [TypeScript 3.7 Assertion Functions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) diff --git a/.agents/skills/typescript/references/safety-const-assertions.md b/.agents/skills/typescript/references/safety-const-assertions.md deleted file mode 100644 index 3f978ccc13d..00000000000 --- a/.agents/skills/typescript/references/safety-const-assertions.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Use const Assertions for Literal Types -impact: MEDIUM-HIGH -impactDescription: preserves literal types, enables better inference -tags: safety, const-assertion, literals, inference, readonly ---- - -## Use const Assertions for Literal Types - -The `as const` assertion preserves literal types and makes arrays/objects readonly. This enables precise type inference and prevents accidental mutations. - -**Incorrect (widened types):** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - retries: 3, - methods: ['GET', 'POST'] -} -// Type: { apiUrl: string; retries: number; methods: string[] } - -function makeRequest(method: 'GET' | 'POST'): void {} - -makeRequest(config.methods[0]) -// Error: Argument of type 'string' is not assignable to 'GET' | 'POST' - -const STATUS = { - PENDING: 'pending', - ACTIVE: 'active' -} -// Type: { PENDING: string; ACTIVE: string } -``` - -**Correct (const assertion preserves literals):** - -```typescript -const config = { - apiUrl: 'https://api.example.com', - retries: 3, - methods: ['GET', 'POST'] -} as const -// Type: { readonly apiUrl: 'https://api.example.com'; readonly retries: 3; readonly methods: readonly ['GET', 'POST'] } - -function makeRequest(method: 'GET' | 'POST'): void {} - -makeRequest(config.methods[0]) // Works: 'GET' is assignable to 'GET' | 'POST' - -const STATUS = { - PENDING: 'pending', - ACTIVE: 'active' -} as const -// Type: { readonly PENDING: 'pending'; readonly ACTIVE: 'active' } - -type StatusType = (typeof STATUS)[keyof typeof STATUS] // 'pending' | 'active' -``` - -**For function parameters:** - -```typescript -// Incorrect - tuple becomes array -function setCoordinates(coords: [number, number]): void {} -setCoordinates([10, 20]) // Error: number[] not assignable to [number, number] - -// Correct - const preserves tuple -setCoordinates([10, 20] as const) // Works - -// Or inline -function setCoordinates(coords: readonly [number, number]): void {} -``` - -**When to use const assertions:** - -- Configuration objects that shouldn't change -- Enum-like objects with string values -- Array/tuple literals passed to functions expecting specific types -- Creating type-safe lookup tables - -Reference: [TypeScript 3.4 Const Assertions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions) diff --git a/.agents/skills/typescript/references/safety-exhaustive-checks.md b/.agents/skills/typescript/references/safety-exhaustive-checks.md deleted file mode 100644 index 9bc887ebd30..00000000000 --- a/.agents/skills/typescript/references/safety-exhaustive-checks.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Use Exhaustive Checks for Union Types -impact: MEDIUM-HIGH -impactDescription: prevents 100% of missing case errors at compile time -tags: safety, exhaustive, never, discriminated-unions, switch ---- - -## Use Exhaustive Checks for Union Types - -Exhaustive checks ensure all union members are handled. When a new member is added, TypeScript errors on unhandled cases rather than falling through silently at runtime. - -**Incorrect (missing case compiles but fails at runtime):** - -```typescript -type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' - -function getStatusMessage(status: OrderStatus): string { - switch (status) { - case 'pending': - return 'Order received' - case 'processing': - return 'Preparing your order' - case 'shipped': - return 'On the way' - // 'delivered' case missing - no compile error - // Returns undefined at runtime - } -} - -// Later, someone adds 'cancelled' to OrderStatus -// This function silently returns undefined for 'cancelled' and 'delivered' -``` - -**Correct (exhaustive check with never):** - -```typescript -type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' - -function assertNever(value: never): never { - throw new Error(`Unhandled value: ${value}`) -} - -function getStatusMessage(status: OrderStatus): string { - switch (status) { - case 'pending': - return 'Order received' - case 'processing': - return 'Preparing your order' - case 'shipped': - return 'On the way' - case 'delivered': - return 'Order complete' - default: - return assertNever(status) // Compile error if case missed - } -} - -// Adding 'cancelled' to OrderStatus now causes compile error: -// Argument of type 'string' is not assignable to parameter of type 'never' -``` - -**For object mapping (alternative pattern):** - -```typescript -const statusMessages: Record = { - pending: 'Order received', - processing: 'Preparing your order', - shipped: 'On the way', - delivered: 'Order complete' - // Missing key causes: Property 'cancelled' is missing in type -} - -function getStatusMessage(status: OrderStatus): string { - return statusMessages[status] -} -``` - -**Benefits:** - -- Compile-time error when union expands -- Self-documenting: all cases explicitly handled -- Runtime safety via assertNever fallback - -Reference: [TypeScript Handbook - Exhaustiveness Checking](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking) diff --git a/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md b/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md deleted file mode 100644 index 125bccb029d..00000000000 --- a/.agents/skills/typescript/references/safety-no-unchecked-indexed-access.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Enable noUncheckedIndexedAccess -impact: MEDIUM-HIGH -impactDescription: prevents 100% of unchecked index access errors at compile time -tags: safety, noUncheckedIndexedAccess, strict, arrays, undefined ---- - -## Enable noUncheckedIndexedAccess - -With `noUncheckedIndexedAccess` enabled, TypeScript adds `undefined` to the type of array elements and index signature properties. This catches one of the most common sources of runtime errors — accessing elements that may not exist. - -**Incorrect (without noUncheckedIndexedAccess):** - -```json -{ - "compilerOptions": { - "strict": true - } -} -``` - -```typescript -const users = ['Alice', 'Bob', 'Charlie'] -const first = users[0] // Type: string (lies — could be undefined) -console.log(first.toUpperCase()) // No error, but crashes if array is empty - -const scores: Record = { math: 95 } -const science = scores['science'] // Type: number (lies — key doesn't exist) -console.log(science.toFixed(2)) // No error, but crashes at runtime -``` - -**Correct (with noUncheckedIndexedAccess):** - -```json -{ - "compilerOptions": { - "strict": true, - "noUncheckedIndexedAccess": true - } -} -``` - -```typescript -const users = ['Alice', 'Bob', 'Charlie'] -const first = users[0] // Type: string | undefined -console.log(first.toUpperCase()) // Error: 'first' is possibly undefined - -// Handle the undefined case -if (first) { - console.log(first.toUpperCase()) // OK after narrowing -} - -const scores: Record = { math: 95 } -const science = scores['science'] // Type: number | undefined -if (science !== undefined) { - console.log(science.toFixed(2)) // OK after narrowing -} -``` - -**Common patterns with noUncheckedIndexedAccess:** - -```typescript -// Array destructuring — first element is T | undefined -const [head, ...rest] = items -if (head) { - processItem(head) -} - -// Use non-null assertion only when you've validated -function getRequired(items: string[], index: number): string { - if (index < 0 || index >= items.length) { - throw new RangeError(`Index ${index} out of bounds`) - } - return items[index]! // Safe — bounds checked above -} - -// Array.at() returns T | undefined regardless of this flag -const last = items.at(-1) // Already T | undefined -``` - -**When to disable:** - -- Legacy codebases with heavy array indexing (migration cost too high) -- Performance-critical inner loops where the narrowing pattern adds overhead - -Reference: [TypeScript tsconfig - noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html) diff --git a/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md b/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md deleted file mode 100644 index a60218b55e6..00000000000 --- a/.agents/skills/typescript/references/safety-prefer-unknown-over-any.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Prefer unknown Over any -impact: MEDIUM-HIGH -impactDescription: forces type narrowing, prevents runtime errors -tags: safety, unknown, any, type-narrowing, type-safety ---- - -## Prefer unknown Over any - -The `any` type disables all type checking, allowing unsafe operations to pass silently. Use `unknown` to require explicit type narrowing before operations. - -**Incorrect (any bypasses all checks):** - -```typescript -function processApiResponse(data: any): string { - return data.user.name.toUpperCase() - // No error even if data is null, has no user, or name isn't a string - // Runtime: TypeError: Cannot read property 'name' of undefined -} - -async function fetchData(): Promise { - const response = await fetch('/api/data') - return response.json() // Returns Promise, loses all type info -} -``` - -**Correct (unknown requires narrowing):** - -```typescript -interface ApiResponse { - user: { - name: string - } -} - -function isApiResponse(data: unknown): data is ApiResponse { - return ( - typeof data === 'object' && - data !== null && - 'user' in data && - typeof (data as ApiResponse).user?.name === 'string' - ) -} - -function processApiResponse(data: unknown): string { - if (!isApiResponse(data)) { - throw new Error('Invalid API response') - } - return data.user.name.toUpperCase() // Type-safe access -} -``` - -**For JSON parsing:** - -```typescript -// Incorrect -const config = JSON.parse(configString) as AppConfig // Unsafe assertion - -// Correct -function parseConfig(configString: string): AppConfig { - const parsed: unknown = JSON.parse(configString) - - if (!isValidConfig(parsed)) { - throw new Error('Invalid config format') - } - - return parsed -} -``` - -**When any is acceptable:** - -- Migrating JavaScript to TypeScript incrementally -- Third-party library workarounds (with `// @ts-expect-error`) -- Truly dynamic code where type is unknowable - -Reference: [TypeScript Handbook - Unknown](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-unknown-type) diff --git a/.agents/skills/typescript/references/safety-strict-null-checks.md b/.agents/skills/typescript/references/safety-strict-null-checks.md deleted file mode 100644 index c155bb32e0e..00000000000 --- a/.agents/skills/typescript/references/safety-strict-null-checks.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Enable strictNullChecks -impact: MEDIUM-HIGH -impactDescription: prevents null/undefined runtime errors -tags: safety, strictNullChecks, null, undefined, strict ---- - -## Enable strictNullChecks - -With `strictNullChecks`, TypeScript distinguishes between `T`, `T | null`, and `T | undefined`. This catches null pointer exceptions at compile time instead of runtime. - -**Incorrect (strictNullChecks disabled):** - -```typescript -// tsconfig.json: { "strictNullChecks": false } - -function getUser(id: string): User { - return userMap.get(id) // Returns User | undefined, but typed as User -} - -const user = getUser('123') -console.log(user.email) // No error, but crashes if user is undefined -``` - -**Correct (strictNullChecks enabled):** - -```typescript -// tsconfig.json: { "strict": true } (includes strictNullChecks) - -function getUser(id: string): User | undefined { - return userMap.get(id) // Correctly typed as User | undefined -} - -const user = getUser('123') -console.log(user.email) // Error: 'user' is possibly 'undefined' - -// Must handle the undefined case -if (user) { - console.log(user.email) // Type narrowed to User -} - -// Or use optional chaining -console.log(user?.email) // string | undefined - -// Or assert when you're certain -const confirmedUser = getUser('123')! // Non-null assertion (use sparingly) -``` - -**Common patterns with strictNullChecks:** - -```typescript -// Default values -function greet(name: string | undefined): string { - return `Hello, ${name ?? 'Guest'}` -} - -// Guard clauses -function processOrder(order: Order | null): void { - if (!order) { - throw new Error('Order is required') - } - // order is narrowed to Order - ship(order) -} - -// Optional chaining with nullish coalescing -const street = user?.address?.street ?? 'Unknown' -``` - -**Note:** Always enable `strict: true` which includes `strictNullChecks` along with other safety checks. - -Reference: [TypeScript Handbook - Strict Null Checks](https://www.typescriptlang.org/tsconfig#strictNullChecks) diff --git a/.agents/skills/typescript/references/safety-use-type-guards.md b/.agents/skills/typescript/references/safety-use-type-guards.md deleted file mode 100644 index 7039d85dbac..00000000000 --- a/.agents/skills/typescript/references/safety-use-type-guards.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Use Type Guards for Runtime Type Checking -impact: MEDIUM-HIGH -impactDescription: eliminates type assertions, catches errors at boundaries -tags: safety, type-guards, narrowing, predicates, validation ---- - -## Use Type Guards for Runtime Type Checking - -Type guards provide runtime validation that TypeScript can use for static narrowing. They replace unsafe type assertions with checked operations. - -**Incorrect (type assertions without validation):** - -```typescript -interface User { - id: string - email: string - role: 'admin' | 'user' -} - -function handleUserEvent(event: MessageEvent): void { - const user = event.data as User // Unsafe assertion - sendEmail(user.email) // Crashes if data isn't actually a User -} - -function processResponse(data: unknown): User[] { - return data as User[] // No runtime check -} -``` - -**Correct (type guard with validation):** - -```typescript -function isUser(value: unknown): value is User { - return ( - typeof value === 'object' && - value !== null && - typeof (value as User).id === 'string' && - typeof (value as User).email === 'string' && - ['admin', 'user'].includes((value as User).role) - ) -} - -function handleUserEvent(event: MessageEvent): void { - if (!isUser(event.data)) { - console.error('Invalid user data received') - return - } - sendEmail(event.data.email) // Type-safe: event.data is User -} - -function processResponse(data: unknown): User[] { - if (!Array.isArray(data)) return [] - return data.filter(isUser) -} -``` - -**For discriminated unions:** - -```typescript -interface SuccessResult { - status: 'success' - data: User -} - -interface ErrorResult { - status: 'error' - message: string -} - -type ApiResult = SuccessResult | ErrorResult - -function isSuccess(result: ApiResult): result is SuccessResult { - return result.status === 'success' -} - -function handleResult(result: ApiResult): void { - if (isSuccess(result)) { - console.log(result.data.email) // Type narrowed to SuccessResult - } else { - console.error(result.message) // Type narrowed to ErrorResult - } -} -``` - -Reference: [TypeScript Handbook - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) diff --git a/.agents/skills/typescript/references/tscfg-enable-incremental.md b/.agents/skills/typescript/references/tscfg-enable-incremental.md deleted file mode 100644 index eda2a96df89..00000000000 --- a/.agents/skills/typescript/references/tscfg-enable-incremental.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Enable Incremental Compilation -impact: CRITICAL -impactDescription: 50-90% faster rebuilds -tags: tscfg, incremental, tsconfig, compilation, caching ---- - -## Enable Incremental Compilation - -Incremental compilation caches project graph information between builds in a `.tsbuildinfo` file. Subsequent compilations only recheck changed files and their dependents. - -**Incorrect (full rebuild every time):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true - } -} -``` - -```bash -tsc # 15 seconds every build -``` - -**Correct (incremental builds):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true, - "incremental": true, - "tsBuildInfoFile": "./dist/.tsbuildinfo" - } -} -``` - -```bash -tsc # 15s first build, 1-3s subsequent builds -``` - -**For monorepos (composite projects):** - -```json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true - }, - "references": [{ "path": "../shared" }, { "path": "../utils" }] -} -``` - -**Note:** The `composite` flag implies `incremental: true` and requires `declaration: true`. - -**When to disable incremental:** - -- CI environments where cache isn't preserved between runs -- One-off type-checking scripts - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#incremental-project-emit) diff --git a/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md b/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md deleted file mode 100644 index f8c2dfc7baf..00000000000 --- a/.agents/skills/typescript/references/tscfg-erasable-syntax-only.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Use erasableSyntaxOnly for Node.js Native TypeScript -impact: HIGH -impactDescription: prevents 100% of Node.js type-stripping runtime errors -tags: tscfg, erasableSyntaxOnly, node, type-stripping, enums ---- - -## Use erasableSyntaxOnly for Node.js Native TypeScript - -The `erasableSyntaxOnly` flag (TypeScript 5.8+) ensures your code only uses TypeScript syntax that can be removed by erasing type annotations — no code generation required. This is mandatory for Node.js `--experimental-strip-types` which strips types but cannot transform enums, namespaces, or parameter properties. - -**Incorrect (non-erasable syntax fails with Node.js type-stripping):** - -```json -{ - "compilerOptions": { - "erasableSyntaxOnly": true - } -} -``` - -```typescript -// Error: non-erasable syntax -export enum OrderStatus { - Pending = 'pending', - Shipped = 'shipped', - Delivered = 'delivered' -} - -// Error: non-erasable syntax -namespace Validation { - export function isValid(input: string): boolean { - return input.length > 0 - } -} - -// Error: non-erasable parameter property -class UserService { - constructor(private readonly repository: UserRepository) {} -} -``` - -**Correct (erasable alternatives):** - -```typescript -// Union type instead of enum -export type OrderStatus = 'pending' | 'shipped' | 'delivered' - -// Object constant for runtime values -export const OrderStatus = { - Pending: 'pending', - Shipped: 'shipped', - Delivered: 'delivered' -} as const satisfies Record - -// Module-level functions instead of namespace -export function isValid(input: string): boolean { - return input.length > 0 -} - -// Explicit property assignment instead of parameter property -class UserService { - readonly repository: UserRepository - constructor(repository: UserRepository) { - this.repository = repository - } -} -``` - -**Recommended configuration for Node.js native TS:** - -```json -{ - "compilerOptions": { - "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true, - "isolatedModules": true - } -} -``` - -**When NOT to use this flag:** - -- Projects using a bundler (esbuild, swc, Vite) that supports enum transformation -- Libraries that need to support both bundled and unbundled consumers -- Codebases with extensive enum usage where migration cost is high - -Reference: [TypeScript 5.8 - erasableSyntaxOnly](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html) diff --git a/.agents/skills/typescript/references/tscfg-exclude-properly.md b/.agents/skills/typescript/references/tscfg-exclude-properly.md deleted file mode 100644 index a96ce5db3a6..00000000000 --- a/.agents/skills/typescript/references/tscfg-exclude-properly.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Configure Include and Exclude Properly -impact: CRITICAL -impactDescription: prevents scanning thousands of unnecessary files -tags: tscfg, include, exclude, tsconfig, file-discovery ---- - -## Configure Include and Exclude Properly - -TypeScript walks through all included directories to discover files. Overly broad `include` patterns or missing `exclude` patterns force the compiler to scan irrelevant directories, significantly slowing startup. - -**Incorrect (scans entire project tree):** - -```json -{ - "compilerOptions": { - "outDir": "dist" - }, - "include": ["**/*"] -} -``` - -```bash -# Scans node_modules, dist, coverage, .git... -# Discovery time: 5+ seconds on large projects -``` - -**Correct (targeted include with explicit exclude):** - -```json -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "coverage", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"] -} -``` - -```bash -# Only scans src/ directory -# Discovery time: <1 second -``` - -**For separate test configuration:** - -```json -// tsconfig.json (production) -{ - "include": ["src/**/*"], - "exclude": ["**/*.test.ts"] -} - -// tsconfig.test.json -{ - "extends": "./tsconfig.json", - "include": ["src/**/*", "tests/**/*"] -} -``` - -**Diagnostic commands:** - -```bash -# List all files TypeScript will compile -tsc --listFiles - -# Explain why each file was included -tsc --explainFiles -``` - -**Common files to exclude:** - -- `node_modules` (always) -- Build output directories (`dist`, `build`, `out`) -- Test files for production builds -- Generated files (`.generated.ts`) -- Coverage reports (`coverage`) - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#configuring-tsconfigjson-or-jsconfigjson) diff --git a/.agents/skills/typescript/references/tscfg-isolate-modules.md b/.agents/skills/typescript/references/tscfg-isolate-modules.md deleted file mode 100644 index 813d0dc0ab2..00000000000 --- a/.agents/skills/typescript/references/tscfg-isolate-modules.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Use isolatedModules for Single-File Transpilation -impact: CRITICAL -impactDescription: 80-90% faster transpilation with bundlers -tags: tscfg, isolatedModules, transpilation, bundlers, performance ---- - -## Use isolatedModules for Single-File Transpilation - -The `isolatedModules` flag ensures each file can be transpiled independently, enabling parallel transpilation by bundlers like esbuild, swc, or Babel. This bypasses TypeScript's slower multi-file analysis. - -**Incorrect (requires cross-file analysis):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext" - } -} -``` - -```typescript -// constants.ts -export const enum Status { - Active = 'active', - Inactive = 'inactive' -} - -// user.ts -import { Status } from './constants' -const status = Status.Active // Requires reading constants.ts to inline -``` - -**Correct (single-file transpilable):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "isolatedModules": true, - "verbatimModuleSyntax": true - } -} -``` - -```typescript -// constants.ts -export enum Status { - // Regular enum, not const enum - Active = 'active', - Inactive = 'inactive' -} - -// user.ts -import { Status } from './constants' -const status = Status.Active // Reference preserved, no cross-file read -``` - -**Build pipeline integration:** - -```javascript -// vite.config.ts -export default { - esbuild: { - // esbuild transpiles files in parallel - // TypeScript only runs type-checking - } -} -``` - -**Code patterns blocked by isolatedModules:** - -- `const enum` (use regular `enum` or union types instead) -- `export =` / `import =` syntax -- Re-exporting types without `type` keyword - -**Note:** With `erasableSyntaxOnly` (TypeScript 5.8+), regular enums are also blocked. Use union types (`type Status = 'active' | 'inactive'`) as the universal safe alternative. See `tscfg-erasable-syntax-only.md`. - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#isolated-file-emit) diff --git a/.agents/skills/typescript/references/tscfg-isolated-declarations.md b/.agents/skills/typescript/references/tscfg-isolated-declarations.md deleted file mode 100644 index bac3fd9aae2..00000000000 --- a/.agents/skills/typescript/references/tscfg-isolated-declarations.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Enable isolatedDeclarations for Parallel Declaration Emit -impact: CRITICAL -impactDescription: enables parallel .d.ts generation without type-checker -tags: tscfg, isolatedDeclarations, declarations, parallel, performance ---- - -## Enable isolatedDeclarations for Parallel Declaration Emit - -The `isolatedDeclarations` flag (TypeScript 5.5+) ensures each file's exports are annotated sufficiently for tools to generate `.d.ts` files without running the type-checker. This enables parallel declaration emit via bundlers and dramatically speeds up builds in large codebases. - -**Incorrect (declaration emit requires full type-check):** - -```json -{ - "compilerOptions": { - "declaration": true - } -} -``` - -```typescript -// utils.ts -export function calculateTotal(items: CartItem[]) { - // Return type inferred — requires type-checker to generate .d.ts - return items.reduce((sum, item) => sum + item.price * item.quantity, 0) -} -``` - -**Correct (explicit annotations enable parallel emit):** - -```json -{ - "compilerOptions": { - "declaration": true, - "isolatedDeclarations": true - } -} -``` - -```typescript -// utils.ts -export function calculateTotal(items: CartItem[]): number { - // Explicit return type — .d.ts can be generated per-file, in parallel - return items.reduce((sum, item) => sum + item.price * item.quantity, 0) -} -``` - -**What requires annotation under isolatedDeclarations:** - -- Exported function return types -- Exported variable types when not inferable from a literal -- Exported class method return types - -**What does NOT need annotation:** - -- Local variables and functions (not exported) -- Function parameters (already required by TypeScript) -- Exports initialized with literals (`export const MAX = 100` is fine) - -**Pair with isolatedModules for maximum build speed:** - -```json -{ - "compilerOptions": { - "isolatedModules": true, - "isolatedDeclarations": true, - "declaration": true - } -} -``` - -Reference: [TypeScript 5.5 - Isolated Declarations](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html#isolated-declarations) diff --git a/.agents/skills/typescript/references/tscfg-project-references.md b/.agents/skills/typescript/references/tscfg-project-references.md deleted file mode 100644 index 634771bc870..00000000000 --- a/.agents/skills/typescript/references/tscfg-project-references.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Use Project References for Large Codebases -impact: CRITICAL -impactDescription: 60-80% faster incremental builds -tags: tscfg, project-references, monorepo, tsconfig, compilation ---- - -## Use Project References for Large Codebases - -Project references split a codebase into independent compilation units. Each project compiles separately, enabling parallel builds and preventing the compiler from loading the entire codebase at once. - -**Incorrect (monolithic tsconfig):** - -```text -my-app/ -├── tsconfig.json # Single config for entire app -├── packages/ -│ ├── api/src/ -│ ├── web/src/ -│ └── shared/src/ -``` - -```json -{ - "compilerOptions": { "outDir": "dist" }, - "include": ["packages/*/src/**/*"] -} -``` - -```bash -# Loads ALL files into memory for every change -# Change in api/ triggers full recompile -``` - -**Correct (project references):** - -```text -my-app/ -├── tsconfig.json # Root config with references -├── packages/ -│ ├── api/ -│ │ └── tsconfig.json # References shared -│ ├── web/ -│ │ └── tsconfig.json # References shared -│ └── shared/ -│ └── tsconfig.json # No references (leaf) -``` - -```json -// packages/shared/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "outDir": "dist" - }, - "include": ["src/**/*"] -} -``` - -```json -// packages/api/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "declaration": true, - "outDir": "dist" - }, - "references": [{ "path": "../shared" }], - "include": ["src/**/*"] -} -``` - -```json -// tsconfig.json (root) -{ - "files": [], - "references": [ - { "path": "packages/shared" }, - { "path": "packages/api" }, - { "path": "packages/web" } - ] -} -``` - -```bash -tsc --build # Builds only changed projects -``` - -**Benefits:** - -- Parallel compilation of independent projects -- Change in `shared/` only rebuilds dependents -- Declaration files used as API boundaries - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-project-references) diff --git a/.agents/skills/typescript/references/tscfg-skip-lib-check.md b/.agents/skills/typescript/references/tscfg-skip-lib-check.md deleted file mode 100644 index d25db6e1d7e..00000000000 --- a/.agents/skills/typescript/references/tscfg-skip-lib-check.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Enable skipLibCheck for Faster Builds -impact: CRITICAL -impactDescription: 20-40% faster compilation -tags: tscfg, skipLibCheck, tsconfig, declaration-files, performance ---- - -## Enable skipLibCheck for Faster Builds - -The `skipLibCheck` option skips type-checking of declaration files (`.d.ts`). Since these files are pre-verified by library authors, checking them is redundant and wastes compilation time. - -**Incorrect (checks all declaration files):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true - } -} -``` - -```bash -# Checks thousands of .d.ts files in node_modules -# Compilation time: 25 seconds -``` - -**Correct (skips declaration file checks):** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "strict": true, - "skipLibCheck": true - } -} -``` - -```bash -# Only checks your source files -# Compilation time: 15 seconds (40% faster) -``` - -**Alternative (more conservative):** - -```json -{ - "compilerOptions": { - "skipDefaultLibCheck": true - } -} -``` - -This only skips checking the default library files (lib.d.ts), not third-party declarations. - -**When to disable skipLibCheck:** - -- Debugging type conflicts between declaration files -- Publishing a library where you want to verify `.d.ts` output -- Encountering mysterious type errors that might originate in declarations - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#skipping-d-ts-checking) diff --git a/.agents/skills/typescript/references/tscfg-strict-function-types.md b/.agents/skills/typescript/references/tscfg-strict-function-types.md deleted file mode 100644 index 8e3bef2ed3b..00000000000 --- a/.agents/skills/typescript/references/tscfg-strict-function-types.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Enable strictFunctionTypes for Faster Variance Checks -impact: CRITICAL -impactDescription: enables optimized variance checking -tags: tscfg, strict, strictFunctionTypes, variance, performance ---- - -## Enable strictFunctionTypes for Faster Variance Checks - -With `strictFunctionTypes` enabled, TypeScript uses fast variance-based checking for function parameters. Without it, TypeScript falls back to slower structural comparison for every function type. - -**Incorrect (slow structural checking):** - -```json -{ - "compilerOptions": { - "strict": false, - "strictFunctionTypes": false - } -} -``` - -```typescript -type Handler = (event: T) => void - -// Without strictFunctionTypes, TypeScript uses bidirectional -// (bivariant) checking - comparing structures both ways -const handler: Handler = (e: Event) => {} // Allowed but unsafe -``` - -**Correct (fast variance checking):** - -```json -{ - "compilerOptions": { - "strict": true - } -} -``` - -```typescript -type Handler = (event: T) => void - -// With strictFunctionTypes, TypeScript uses contravariant -// checking for parameters - faster and type-safe -const handler: Handler = (e: Event) => {} // Error: Event is not MouseEvent -``` - -**Note:** The `strict` flag enables `strictFunctionTypes` along with other strict options. Enable `strict` for all new projects. - -**When bivariance is needed:** - -```typescript -// Use method syntax for intentional bivariance -interface EventEmitter { - emit(event: T): void // Method syntax = bivariant -} - -// vs property syntax for contravariance -interface StrictEmitter { - emit: (event: T) => void // Property syntax = contravariant -} -``` - -Reference: [TypeScript 2.6 - Strict Function Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types) diff --git a/.agents/skills/typescript/references/type-avoid-deep-generics.md b/.agents/skills/typescript/references/type-avoid-deep-generics.md deleted file mode 100644 index 405f87aaee0..00000000000 --- a/.agents/skills/typescript/references/type-avoid-deep-generics.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Avoid Deeply Nested Generic Types -impact: CRITICAL -impactDescription: prevents exponential instantiation cost -tags: type, generics, nesting, instantiation, performance ---- - -## Avoid Deeply Nested Generic Types - -Each layer of generic nesting multiplies type instantiation cost. Flatten generic hierarchies or use intermediate type aliases to reduce the combinatorial explosion of type checking. - -**Incorrect (deeply nested generics):** - -```typescript -type ApiResponse = { - data: T - meta: ResponseMeta -} - -type PaginatedResponse = ApiResponse<{ - items: T[] - pagination: PaginationInfo -}> - -type CachedResponse = PaginatedResponse<{ - value: T - cachedAt: Date -}> - -// Usage creates 4+ levels of nesting -function fetchUsers(): CachedResponse {} -// Compiler must resolve: CachedResponse → PaginatedResponse<...> → ApiResponse<...> -``` - -**Correct (flattened with composition):** - -```typescript -interface PaginationInfo { - page: number - totalPages: number -} - -interface CacheInfo { - cachedAt: Date -} - -interface PaginatedData { - items: T[] - pagination: PaginationInfo -} - -interface ApiResponse { - data: T - meta: ResponseMeta -} - -// Compose at usage site instead of nesting -type UserListResponse = ApiResponse & CacheInfo> - -function fetchUsers(): UserListResponse {} -// Single-level generic instantiation -``` - -**Alternative (builder pattern for complex responses):** - -```typescript -interface ResponseBuilder { - data: T - meta: ResponseMeta -} - -function withPagination(items: T[], pagination: PaginationInfo): PaginatedData { - return { items, pagination } -} - -function withCache(value: T): T & CacheInfo { - return { ...value, cachedAt: new Date() } -} -``` - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.agents/skills/typescript/references/type-avoid-large-unions.md b/.agents/skills/typescript/references/type-avoid-large-unions.md deleted file mode 100644 index 54967bbed91..00000000000 --- a/.agents/skills/typescript/references/type-avoid-large-unions.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Avoid Large Union Types -impact: CRITICAL -impactDescription: quadratic O(n²) comparison cost -tags: type, unions, compilation, performance, discriminated-unions ---- - -## Avoid Large Union Types - -Union type checking is quadratic — TypeScript compares each union member pairwise. Unions with 50+ elements cause measurable compilation slowdowns and IDE lag. This commonly occurs with generated types (GraphQL schemas, API response codes, database enums). - -**Incorrect (large generated union, O(n²) checks):** - -```typescript -// Auto-generated from GraphQL schema — 200+ event types -type AnalyticsEvent = - | 'page_view' - | 'button_click' - | 'form_submit' - | 'scroll_depth' - | 'video_play' - | 'video_pause' - | 'video_complete' - | 'ad_impression' -// ... 200 more event types from analytics schema -// 200 members = 40,000 pairwise comparisons per usage -``` - -**Correct (branded string type with runtime validation):** - -```typescript -type AnalyticsEvent = string & { readonly __brand: 'AnalyticsEvent' } - -const VALID_EVENTS = new Set(['page_view', 'button_click', 'form_submit' /* ... */]) - -function createEvent(name: string): AnalyticsEvent { - if (!VALID_EVENTS.has(name)) { - throw new Error(`Unknown event: ${name}`) - } - return name as AnalyticsEvent -} -``` - -**For moderately large unions (20-50 members), use discriminated unions:** - -```typescript -// Group related values into categories -type UserEvent = { category: 'user'; action: 'login' | 'logout' | 'signup' } -type PageEvent = { category: 'page'; action: 'view' | 'scroll' | 'leave' } -type FormEvent = { category: 'form'; action: 'submit' | 'validate' | 'reset' } - -type AppEvent = UserEvent | PageEvent | FormEvent -// Small union of 3 interfaces instead of 9+ string literals -``` - -**When flat unions are fine:** - -- Small unions (< 20 members) have negligible cost -- Unions of primitive literals used in few places -- `string | number | boolean` style utility unions - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-base-types-over-unions) diff --git a/.agents/skills/typescript/references/type-explicit-return-types.md b/.agents/skills/typescript/references/type-explicit-return-types.md deleted file mode 100644 index 202155bab1a..00000000000 --- a/.agents/skills/typescript/references/type-explicit-return-types.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Add Explicit Return Types to Exported Functions -impact: CRITICAL -impactDescription: 30-50% faster declaration emit -tags: type, return-types, exports, inference, performance ---- - -## Add Explicit Return Types to Exported Functions - -Explicit return types accelerate compilation by eliminating inference overhead. Named types are more compact than inferred anonymous types, speeding up declaration file generation and consumption. - -**Incorrect (inferred return type, slow declaration emit):** - -```typescript -export function fetchUserProfile(userId: string) { - // Compiler must analyze entire function body to infer return type - return fetch(`/api/users/${userId}`) - .then((res) => res.json()) - .then((data) => ({ - id: data.id as string, - name: data.name as string, - email: data.email as string, - createdAt: new Date(data.created_at), - permissions: data.permissions as Permission[] - })) -} -// Inferred: Promise<{ id: string; name: string; email: string; createdAt: Date; permissions: Permission[] }> -``` - -**Correct (explicit return type, fast compilation):** - -```typescript -interface UserProfile { - id: string - name: string - email: string - createdAt: Date - permissions: Permission[] -} - -export function fetchUserProfile(userId: string): Promise { - return fetch(`/api/users/${userId}`) - .then((res) => res.json()) - .then((data) => ({ - id: data.id, - name: data.name, - email: data.email, - createdAt: new Date(data.created_at), - permissions: data.permissions - })) -} -``` - -**When to skip explicit return types:** - -- Private/internal functions with simple returns -- Arrow functions in local scope -- Functions where the return type is obvious (e.g., `(): void`) - -**Benefits:** - -- Declaration files use named type instead of expanded inline type -- Faster incremental compilation when function body changes -- Better error messages pointing to return type mismatch - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations) diff --git a/.agents/skills/typescript/references/type-extract-conditional-types.md b/.agents/skills/typescript/references/type-extract-conditional-types.md deleted file mode 100644 index 5b2ea57cbd8..00000000000 --- a/.agents/skills/typescript/references/type-extract-conditional-types.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Extract Conditional Types to Named Aliases -impact: CRITICAL -impactDescription: enables compiler caching, prevents re-evaluation -tags: type, conditional-types, generics, caching, performance ---- - -## Extract Conditional Types to Named Aliases - -Inline conditional types are re-evaluated on every function call. Extracting them to named type aliases allows the compiler to cache results and reuse them across multiple call sites. - -**Incorrect (inline conditional, re-evaluated each call):** - -```typescript -function processResponse( - response: T -): T extends { data: infer D } ? (D extends Array ? Item[] : D) : never { - // Compiler re-computes this complex conditional on every call - return response.data -} - -function getFirstItem(collection: T): T extends Array ? U : T { - // Re-evaluated for each getFirstItem() usage -} -``` - -**Correct (extracted, cacheable):** - -```typescript -type ExtractData = T extends { data: infer D } - ? D extends Array - ? Item[] - : D - : never - -function processResponse(response: T): ExtractData { - // Compiler caches ExtractData resolution - return response.data -} - -type UnwrapArray = T extends Array ? U : T - -function getFirstItem(collection: T): UnwrapArray { - // Reuses cached UnwrapArray computation -} -``` - -**Benefits:** - -- Type alias acts as a cache boundary -- Reduces duplicate computation across multiple call sites -- Improves IDE responsiveness for autocomplete - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-aliases) diff --git a/.agents/skills/typescript/references/type-interfaces-over-intersections.md b/.agents/skills/typescript/references/type-interfaces-over-intersections.md deleted file mode 100644 index 2b11365d653..00000000000 --- a/.agents/skills/typescript/references/type-interfaces-over-intersections.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Prefer Interfaces Over Type Intersections -impact: CRITICAL -impactDescription: 2-5× faster type resolution -tags: type, interfaces, intersections, compilation, performance ---- - -## Prefer Interfaces Over Type Intersections - -Interfaces create a single flat object type that detects property conflicts at declaration. Intersections recursively merge properties on every use, forcing the compiler to recompute the combined type repeatedly. - -**Incorrect (recursive intersection merging):** - -```typescript -type UserWithPermissions = User & Permissions & AuditInfo -// Compiler merges all properties on every reference - -type ExtendedOrder = Order & { - metadata: OrderMetadata -} & Timestamps -// Each intersection adds another layer of computation -``` - -**Correct (single flat interface):** - -```typescript -interface UserWithPermissions extends User, Permissions, AuditInfo {} -// Single flat type, computed once - -interface ExtendedOrder extends Order, Timestamps { - metadata: OrderMetadata -} -// Extends create efficient inheritance chain -``` - -**When to use intersections:** - -- Combining function types or primitives (interfaces cannot extend these) -- Creating mapped or conditional types -- One-off type combinations not reused elsewhere - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections) diff --git a/.agents/skills/typescript/references/type-limit-recursion-depth.md b/.agents/skills/typescript/references/type-limit-recursion-depth.md deleted file mode 100644 index 585f3a3a766..00000000000 --- a/.agents/skills/typescript/references/type-limit-recursion-depth.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Limit Type Recursion Depth -impact: HIGH -impactDescription: prevents exponential type expansion when applicable -tags: type, recursion, generics, depth, performance ---- - -## Limit Type Recursion Depth - -Recursive types without depth limits can cause exponential type expansion, leading to compilation hangs or out-of-memory errors. Add explicit depth counters or use tail-recursive patterns. - -**Incorrect (unbounded recursion):** - -```typescript -type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] -} -// No depth limit - deeply nested objects cause exponential expansion - -type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue } -// Infinite recursion potential -``` - -**Correct (bounded recursion with depth counter):** - -```typescript -type DeepPartial = Depth['length'] extends 5 - ? T // Stop at depth 5 - : { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] - } - -type JSONValue = Depth['length'] extends 10 - ? unknown - : - | string - | number - | boolean - | null - | JSONValue<[...Depth, 1]>[] - | { [key: string]: JSONValue<[...Depth, 1]> } -``` - -**Alternative (use built-in utilities):** - -```typescript -// For simple cases, prefer built-in Partial over custom DeepPartial -type Config = Partial - -// Use libraries like ts-toolbelt for complex recursive types -// They implement optimized depth-limited versions -``` - -**When unbounded recursion is acceptable:** - -- Types with guaranteed shallow depth (max 2-3 levels) -- Internal types not exposed in public APIs - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.agents/skills/typescript/references/type-simplify-mapped-types.md b/.agents/skills/typescript/references/type-simplify-mapped-types.md deleted file mode 100644 index f4781762738..00000000000 --- a/.agents/skills/typescript/references/type-simplify-mapped-types.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Simplify Complex Mapped Types -impact: HIGH -impactDescription: reduces type computation by 50-80% when applicable -tags: type, mapped-types, simplification, utility-types, performance ---- - -## Simplify Complex Mapped Types - -Overly complex mapped types with multiple conditional branches slow compilation significantly. Break them into smaller, focused utility types and compose them. - -**Incorrect (monolithic mapped type):** - -```typescript -type ComplexTransform = { - [K in keyof T]: T[K] extends Function - ? T[K] - : T[K] extends Array - ? U extends object - ? ComplexTransform[] - : T[K] - : T[K] extends object - ? T[K] extends Date - ? string - : ComplexTransform - : T[K] extends number - ? string - : T[K] -} -// Multiple nested conditionals evaluated for every property -``` - -**Correct (composed utility types):** - -```typescript -type TransformValue = T extends Date ? string : T extends number ? string : T - -type TransformObject = { - [K in keyof T]: TransformProperty -} - -type TransformProperty = T extends Function - ? T - : T extends Array - ? TransformArray - : T extends object - ? TransformObject - : TransformValue - -type TransformArray = T extends object ? TransformObject[] : T[] - -// Each utility is cached independently -type TransformedUser = TransformObject -``` - -**Benefits:** - -- Each small utility type is cached separately -- Easier to debug type errors -- More reusable across the codebase - -**When complex mapped types are acceptable:** - -- Internal utility types used in few places -- Types that genuinely require complex logic - -Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance) diff --git a/.claude/skills/auto-review-fix b/.claude/skills/auto-review-fix deleted file mode 120000 index ddcc07a3dd0..00000000000 --- a/.claude/skills/auto-review-fix +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/auto-review-fix \ No newline at end of file diff --git a/.claude/skills/electron b/.claude/skills/electron deleted file mode 120000 index d04d1cd05cf..00000000000 --- a/.claude/skills/electron +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/electron \ No newline at end of file diff --git a/.claude/skills/react-useeffect b/.claude/skills/react-useeffect deleted file mode 120000 index 05a484a5139..00000000000 --- a/.claude/skills/react-useeffect +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/react-useeffect \ No newline at end of file diff --git a/.claude/skills/review-and-submit/SKILL.md b/.claude/skills/review-and-submit/SKILL.md deleted file mode 100644 index e9794d483d1..00000000000 --- a/.claude/skills/review-and-submit/SKILL.md +++ /dev/null @@ -1,278 +0,0 @@ ---- -name: review-and-submit -description: Lightweight review-fix loop (2 rounds, 1 agent each), then create PR and merge ---- - -# review-and-submit - -Lightweight autonomous pipeline: review code with a single agent, fix issues (up to 2 rounds), then create PR and merge. Designed for smaller PRs where full parallel review is overkill. - -**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. - ---- - -## Process Overview - -``` - REVIEW-FIX (max 2 rounds, 1 agent each) → CREATE PR → WAIT FOR CI → MERGE -``` - ---- - -## Step 1: Review-Fix Loop (Max 2 Rounds) - -### Round Setup (first round only) - -Get the diff to understand what changed: - -```bash -MERGE_BASE=$(git merge-base origin/main HEAD) -git diff $MERGE_BASE --stat -git diff $MERGE_BASE -``` - -### Each Round: Single-Agent Review - -Spawn **one** review agent that covers all review concerns: - -``` -Agent( - subagent_type: "general-purpose", - model: "opus", - description: "Review all changes on branch", - prompt: """ - Review all code changes on this branch vs origin/main. - - Run: git diff $(git merge-base origin/main HEAD) - - Review for: - - Correctness and logical bugs - - Security issues - - Type safety issues - - Error handling gaps - - Performance problems - - Dead code or unused imports - - **SCOPE**: Only report issues in changed code or directly caused by the changes. - Do NOT report pre-existing issues unrelated to this PR. - - For each finding, output: - - File: path/to/file.ts - - Line: NN - - Severity: Critical|High|Medium|Low - - Issue: [description] - - Fix: [suggested fix] - - If no issues found, say "No issues found." - """ -) -``` - -### Fix Phase - -If the review found issues: - -1. **Skip Low severity issues** — only fix Critical, High, and Medium -2. Fix issues via a single fix agent: - -``` -Agent( - subagent_type: "general-purpose", - model: "opus", - description: "Fix review issues", - prompt: """ - Fix the following review issues: - - [LIST ALL CRITICAL/HIGH/MEDIUM ISSUES FROM REVIEW] - - Instructions: - 1. Read each file - 2. Apply fixes using the Edit tool - 3. Verify fixes don't break syntax - 4. Report what was fixed - """ -) -``` - -3. After fixes, run typecheck: - -```bash -pnpm typecheck 2>&1 -``` - -If typecheck fails, fix type errors (up to 2 attempts). - -### Exit Conditions - -``` -IF (review found no Critical/High/Medium issues): - → EXIT loop — code is clean - -IF (fixes applied): - → Run another round to verify (unless already at round 2) - -IF (round 2 reached): - → EXIT loop -``` - -### Commit Changes - -After the review-fix loop completes, commit any changes: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address review findings" -fi -``` - -**Continue to Step 2 — do not stop here.** - ---- - -## Step 2: Create PR and Merge - -### 2a. Remove local design docs - -Design/planning docs are local-only scratch files and must not be checked in. -Delete any untracked markdown files in `docs/` that match design/plan -naming patterns before pushing: - -```bash -git ls-files --others --exclude-standard -- 'docs/*-design.md' 'docs/*-plan.md' 'docs/design-*.md' \ - | xargs -I{} rm -f {} -``` - -### 2b. Create PR - -**FIRST**: Push the branch to the remote so `gh pr create` doesn't fail with -`aborted: you must first push the current branch to a remote`. The -`create-pr` skill rebases locally but does not always push before invoking -`gh pr create`, and `gh` refuses to create a PR for an un-pushed branch. - -```bash -git push --force-with-lease -u origin HEAD -``` - -Then invoke: - -``` -Use the Skill tool: skill: "create-pr" -``` - -After `/create-pr` completes, extract PR info: - -```bash -gh pr view --json number,url --jq '.number, .url' -``` - -### 2c. Wait for CI Checks - -Wait **2.5 minutes** before the first poll: - -```bash -echo "Waiting 2.5 minutes for CI checks..." && sleep 150 -``` - -**CRITICAL**: Set `timeout: 210000` (3.5 minutes) on the Bash tool call for this sleep. - -### Poll for results (up to 5 polls, 90s apart) - -For each poll: - -```bash -CHECKS=$(gh pr checks --json name,state,bucket 2>&1) -echo "$CHECKS" - -if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then - echo "RESULT:NO_CHECKS" -elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then - echo "RESULT:PASSED" -elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then - echo "RESULT:FAILED" -else - echo "RESULT:PENDING" -fi -``` - -- `PASSED` → Proceed to merge -- `FAILED` → Attempt one quick fix iteration (diagnose from `gh run view --log-failed`, fix, push, re-poll) -- `PENDING` → Wait 90s (`sleep 90`, `timeout: 150000`), then poll again -- `NO_CHECKS` → Proceed to merge - -### 2d. Merge - -```bash -IS_WORKTREE=false -if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then - IS_WORKTREE=true -fi - -BRANCH=$(git branch --show-current) - -if [ "$IS_WORKTREE" = "true" ]; then - gh pr merge --admin --squash - git push origin --delete "$BRANCH" 2>/dev/null || true -else - gh pr merge --admin --squash --delete-branch -fi -``` - ---- - -## CI Fix Loop (if checks fail) - -If CI fails after PR creation, run **one** fix iteration: - -1. Diagnose: - -```bash -FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId') -if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then - gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200 -fi -``` - -2. Fix the issues, run `pnpm typecheck`, commit and push: - -```bash -if [ -n "$(git status --porcelain)" ]; then - git add -A && git commit -m "fix: address CI failures" - git push --force-with-lease -fi -``` - -3. Wait 2.5 minutes and re-poll. If still failing after one fix attempt, merge with `--admin` anyway. - ---- - -## Progress Tracking - -``` -╔════════════════════════════════════════════════════════════╗ -║ REVIEW AND SUBMIT ║ -╠════════════════════════════════════════════════════════════╣ -║ Review-Fix Loop: ║ -║ Round 1: ✅ X issues found, Y fixed ║ -║ Round 2: ✅ Clean review ║ -║ Create PR: ✅ PR #123 ║ -║ CI Checks: ✅ Passed ║ -║ Merge: ✅ Merged with --admin --squash ║ -╠════════════════════════════════════════════════════════════╣ -║ PR URL: https://github.com/org/repo/pull/123 ║ -╚════════════════════════════════════════════════════════════╝ -``` - ---- - -## Critical Instructions - -1. **DO NOT ask for user confirmation** — Execute autonomously -2. **Max 2 review rounds**, 1 agent per round — keep it lightweight -3. **Only fix Critical/High/Medium** — skip Low severity -4. **DO run typecheck** after fixes -5. **DO use opus model** for review and fix agents -6. **2.5 minute initial CI wait** (not 8 minutes) — this repo's CI is fast -7. **DO use --admin --squash** for merge -8. **DO set Bash timeouts** for sleep commands -9. **Continue through all steps** — don't stop after review or PR creation -10. **One CI fix iteration max** — don't loop forever on CI failures diff --git a/.claude/skills/typescript b/.claude/skills/typescript deleted file mode 120000 index 93516ac2445..00000000000 --- a/.claude/skills/typescript +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/typescript \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2a2c6fee2d..b2f2a7f6582 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,7 @@ docs/design-*.md # Playwright test-results/ playwright-report/ + +# Agent skill installations (machine-local, populated by agent tooling) +/.claude/skills/ +/.agents/skills/ diff --git a/orca.yaml b/orca.yaml index 0a6d170e0a2..b9cd94b6b56 100644 --- a/orca.yaml +++ b/orca.yaml @@ -1,3 +1,7 @@ scripts: setup: | pnpm install + # Internal Stably dev setup: populates .claude/skills and .agents/skills + # from a private repo. Silent no-op for public contributors (env unset). + [ -n "$ORCA_INTERNAL_DEV_SETUP" ] && [ -x "$ORCA_INTERNAL_DEV_SETUP" ] && \ + "$ORCA_INTERNAL_DEV_SETUP" "$ORCA_WORKTREE_PATH" || true From 8d1a6cbbb251a58150bae75f878f489c5c53c204 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:58:27 -0700 Subject: [PATCH 029/259] chore: remove tracked skills-lock.json (#1255) Follow-up to #1254. The lockfile tracks skill source/hash metadata which now lives alongside the skill sources in the internal delivery repo; keeping a parallel copy here in the public tree is misleading (entries can drift) without being useful to anyone. - Remove skills-lock.json from the tree. - Gitignore it so local tooling can still write one without making git status dirty. Co-authored-by: Orca --- .gitignore | 1 + skills-lock.json | 20 -------------------- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 skills-lock.json diff --git a/.gitignore b/.gitignore index b2f2a7f6582..b81df4cb28a 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ playwright-report/ # Agent skill installations (machine-local, populated by agent tooling) /.claude/skills/ /.agents/skills/ +/skills-lock.json diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 1fbc7c007e7..00000000000 --- a/skills-lock.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 1, - "skills": { - "electron": { - "source": "vercel-labs/agent-browser", - "sourceType": "github", - "computedHash": "587f7d9187c237d909b9f446cf3ac357184342d54ceb7c430dec56fdbac6816c" - }, - "react-useeffect": { - "source": "softaworks/agent-toolkit", - "sourceType": "github", - "computedHash": "8c24990b50f431a570f3ad1f60632dee154431861547103047f6ce80092ca734" - }, - "typescript": { - "source": "pproenca/dot-skills", - "sourceType": "github", - "computedHash": "3475c8e2a2e556833213dc2d4dff19e09ef166375a203b256b0d7f04f7d5d424" - } - } -} From cfda453c583a53e66f25eda92a073f8591592533 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:16:09 +0000 Subject: [PATCH 030/259] release: 1.3.25-rc.2 [rc-slot:2026-04-29-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 092f8d82d86..1c42699174e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.25-rc.1", + "version": "1.3.25-rc.2", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 3a585050c3ef774b5744d7436838a044a0985f31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:20:57 -0700 Subject: [PATCH 031/259] chore: run internal skills hook before pnpm install (#1257) Skill install is cheap (<1s, just symlinks) whereas pnpm install can take minutes on a cold cache. Run the skills hook first so devs have skills available immediately instead of waiting for deps. No impact on public contributors: the env-gated `|| true` guard still silently no-ops before pnpm install runs as normal. Co-authored-by: Orca --- orca.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/orca.yaml b/orca.yaml index b9cd94b6b56..585a445d5a4 100644 --- a/orca.yaml +++ b/orca.yaml @@ -1,7 +1,9 @@ scripts: setup: | - pnpm install # Internal Stably dev setup: populates .claude/skills and .agents/skills - # from a private repo. Silent no-op for public contributors (env unset). + # from a private repo. Runs first (cheap, just symlinks) so skills are + # available before the long-running pnpm install below. Silent no-op for + # public contributors (env unset). [ -n "$ORCA_INTERNAL_DEV_SETUP" ] && [ -x "$ORCA_INTERNAL_DEV_SETUP" ] && \ "$ORCA_INTERNAL_DEV_SETUP" "$ORCA_WORKTREE_PATH" || true + pnpm install From 5086973c20ed5d34c06c2c1b0073be9985f4c61c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:22:17 -0700 Subject: [PATCH 032/259] chore: trim orca.yaml comments (#1258) Co-authored-by: Orca --- orca.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/orca.yaml b/orca.yaml index 585a445d5a4..5dad55e8200 100644 --- a/orca.yaml +++ b/orca.yaml @@ -1,9 +1,5 @@ scripts: setup: | - # Internal Stably dev setup: populates .claude/skills and .agents/skills - # from a private repo. Runs first (cheap, just symlinks) so skills are - # available before the long-running pnpm install below. Silent no-op for - # public contributors (env unset). [ -n "$ORCA_INTERNAL_DEV_SETUP" ] && [ -x "$ORCA_INTERNAL_DEV_SETUP" ] && \ "$ORCA_INTERNAL_DEV_SETUP" "$ORCA_WORKTREE_PATH" || true pnpm install From 68acd18fc62a4a037b43b29d9f7d77cb9de9f887 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:47:18 -0700 Subject: [PATCH 033/259] fix(terminal): resolve "unknown" label in terminal sessions popover (#1239) Co-authored-by: Orca --- .../status-bar/SessionsStatusSegment.tsx | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx index cbbbe48a8cd..72739372326 100644 --- a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx @@ -20,7 +20,7 @@ function shortCwd(cwd: string): string { return parts.length > 2 ? parts.slice(-2).join(separator) : cwd } -function sessionLabel(session: DaemonSession): string { +function sessionLabel(session: DaemonSession, tabTitle?: string): string { if (session.cwd) { return shortCwd(session.cwd) } @@ -33,6 +33,17 @@ function sessionLabel(session: DaemonSession): string { const worktreeId = session.id.slice(0, sep) return shortCwd(worktreeId) } + // Why: for sessions with legacy numeric IDs (e.g. from LocalPtyProvider) or + // sessions whose shells haven't emitted OSC 7, use the tab title from the + // renderer store — it carries the live terminal title shown in the tab bar. + // Fall back to the daemon-reported title (typically "shell") so orphan + // sessions without a bound tab still display something meaningful. + if (tabTitle) { + return tabTitle + } + if (session.title) { + return session.title + } return 'unknown' } @@ -40,12 +51,14 @@ function SessionRow({ session, isBound, tabId, + tabTitle, onKill, onNavigate }: { session: DaemonSession isBound: boolean tabId: string | null + tabTitle: string | undefined onKill: (id: string) => void onNavigate: (tabId: string) => void }): React.JSX.Element { @@ -60,7 +73,9 @@ function SessionRow({ className={`size-1.5 shrink-0 rounded-full ${isBound ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`} />
-
{sessionLabel(session)}
+
+ {sessionLabel(session, tabTitle)} +
{!isBound && (
) : null, + matchesSettingsSearch(searchQuery, GENERAL_GEMINI_SEARCH_ENTRIES) ? ( +
+
+

Gemini

+

Configure Gemini provider settings.

+
+ + +
+ +

+ Extracts OAuth credentials from your local Gemini CLI installation to authenticate + with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break + if Google updates the CLI. Use at your own risk. +

+
+ +
+
+ ) : null, + matchesSettingsSearch(searchQuery, GENERAL_OPENCODE_SEARCH_ENTRIES) ? ( +
+
+

OpenCode Go

+

Configure OpenCode Go provider settings.

+
+ + + +
+ updateSettings({ opencodeSessionCookie: e.target.value })} + placeholder="Fe26.2**… token or auth=Fe26.2**… header" + spellCheck={false} + className="flex-1 text-xs" + /> + {settings.opencodeSessionCookie && ( + + )} +
+

+ Paste either the raw token value (e.g. Fe26.2**…) or + the full cookie header (e.g. auth=Fe26.2**…). Find it + in your browser's DevTools → Network → any opencode.ai request → Cookie header. +

+
+ + + +
+ updateSettings({ opencodeWorkspaceId: e.target.value })} + placeholder="wrk_… (leave blank for automatic lookup)" + spellCheck={false} + className="flex-1 text-xs" + /> + {settings.opencodeWorkspaceId && ( + + )} +
+

+ Find this in the URL after logging into opencode.ai (e.g.{' '} + opencode.ai/workspace/wrk_…/go). +

+
+
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_UPDATE_SEARCH_ENTRIES) ? (
diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 06a03927d33..45e58db24d3 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -86,6 +86,28 @@ export const GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [ } ] +export const GENERAL_GEMINI_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Use Gemini CLI credentials', + description: + 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.', + keywords: ['gemini', 'cli', 'oauth', 'credentials', 'experimental', 'rate limit', 'status bar'] + } +] + +export const GENERAL_OPENCODE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'OpenCode Go Session Cookie', + description: 'Paste your opencode.ai session cookie for rate limit fetching.', + keywords: ['opencode', 'cookie', 'session', 'rate limit', 'status bar'] + }, + { + title: 'OpenCode Go Workspace ID', + description: 'Optional workspace ID override if the automatic lookup fails.', + keywords: ['opencode', 'workspace', 'id', 'wrk', 'rate limit', 'status bar'] + } +] + export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Default Agent', @@ -119,6 +141,8 @@ export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ ...GENERAL_CACHE_TIMER_SEARCH_ENTRIES, ...GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES, ...GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES, + ...GENERAL_GEMINI_SEARCH_ENTRIES, + ...GENERAL_OPENCODE_SEARCH_ENTRIES, ...GENERAL_UPDATE_SEARCH_ENTRIES, ...GENERAL_SUPPORT_SEARCH_ENTRIES ] diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 4d652f12e68..31e6096854e 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -28,7 +28,8 @@ import type { } from '../../../../shared/types' import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' import { ProviderIcon, ProviderPanel } from './tooltip' -import { ClaudeIcon, OpenAIIcon } from './icons' +import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { SshStatusSegment } from './SshStatusSegment' import { SessionsStatusSegment } from './SessionsStatusSegment' @@ -221,6 +222,10 @@ function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React // Provider segment // --------------------------------------------------------------------------- +// Why: only Flash and the latest Pro are shown in the status bar — +// the rest (Flash Lite, experimental) are secondary and would clutter the bar. +const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) + function ProviderSegment({ p, compact @@ -273,13 +278,40 @@ function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' + + if (p.buckets && p.buckets.length > 0) { + const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.name)) + return ( + + + {visibleBuckets.map((bucket, i) => { + const left = Math.max(0, Math.round(100 - bucket.usedPercent)) + return ( + + {i > 0 && ·} + + {bucket.name} {left}% + + + ) + })} + {visibleBuckets.length === 0 && p.session && ( + + )} + {isStale && } + + ) + } + return ( {p.session && !compact && } - {p.session && } + {p.session && ( + + )} {p.session && p.weekly && ·} - {p.weekly && } + {p.weekly && } {isStale && } ) @@ -525,7 +557,13 @@ function ProviderDetailsMenu({ className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`} /> - {provider.provider === 'claude' ? 'C' : 'X'} + {provider.provider === 'claude' + ? 'C' + : provider.provider === 'gemini' + ? 'G' + : provider.provider === 'opencode-go' + ? 'O' + : 'X'} ) : ( @@ -608,7 +646,7 @@ function StatusBarInner(): React.JSX.Element | null { return null } - const { claude, codex } = rateLimits + const { claude, codex, gemini, opencodeGo } = rateLimits // Why: hiding `unavailable` providers makes the status bar appear to lose a // provider at random after refreshes or wake/resume. Keeping the slot visible @@ -616,11 +654,20 @@ function StatusBarInner(): React.JSX.Element | null { // configured but currently unavailable. const showClaude = claude && statusBarItems.includes('claude') const showCodex = codex && statusBarItems.includes('codex') + // Why: hide only when the state hasn't loaded yet (null), not when unavailable. + // Gemini shows if credentials exist; OpenCode Go shows always so users can see + // the provider and know to configure the cookie in Settings. + const showGemini = gemini !== null && statusBarItems.includes('gemini') + const showOpencodeGo = opencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') const showSessions = statusBarItems.includes('sessions') const showMemory = statusBarItems.includes('memory') - const anyVisible = showClaude || showCodex - const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' + const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showMemory + const anyFetching = + claude?.status === 'fetching' || + codex?.status === 'fetching' || + gemini?.status === 'fetching' || + opencodeGo?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -646,6 +693,22 @@ function StatusBarInner(): React.JSX.Element | null {
{showClaude && } {showCodex && } + {showGemini && ( + + )} + {showOpencodeGo && ( + + )} {anyVisible && ( @@ -701,6 +764,20 @@ function StatusBarInner(): React.JSX.Element | null { Codex Usage + toggleStatusBarItem('gemini')} + > + + Gemini Usage + + toggleStatusBarItem('opencode-go')} + > + + OpenCode Go Usage + toggleStatusBarItem('ssh')} diff --git a/src/renderer/src/components/status-bar/icons.tsx b/src/renderer/src/components/status-bar/icons.tsx index 3f9bca4e80d..8e03bcd45a0 100644 --- a/src/renderer/src/components/status-bar/icons.tsx +++ b/src/renderer/src/components/status-bar/icons.tsx @@ -1,3 +1,5 @@ +import React from 'react' + export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element { return ( `gi-${++_geminiIconCount}`) + const star = + 'M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z' + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export function OpenCodeGoIcon({ size = 14 }: { size?: number }): React.JSX.Element { + return ( + + + + + + + + + + + + + + + + + ) +} + export function ClaudeIcon({ size = 14 }: { size?: number }): React.JSX.Element { return ( diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts new file mode 100644 index 00000000000..02c0a68fbeb --- /dev/null +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { getWindowSections } from './tooltip' + +describe('getWindowSections', () => { + it('returns buckets as sections when present', () => { + const p: ProviderRateLimits = { + provider: 'gemini', + session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + buckets: [ + { + name: 'Pro', + usedPercent: 30, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Flash', + usedPercent: 80, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Pro', window: p.buckets![0] }, + { label: 'Flash', window: p.buckets![1] }, + { label: 'Weekly', window: null } + ]) + }) + + it('returns session and weekly when buckets are absent', () => { + const p: ProviderRateLimits = { + provider: 'claude', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 20, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: p.weekly } + ]) + }) + + it('returns session and weekly for empty buckets array', () => { + const p: ProviderRateLimits = { + provider: 'gemini', + session: { usedPercent: 50, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: null } + ]) + }) + + it('does not expose bucket names via session window in compact rendering path', () => { + // Why: ProviderSegment (compact mode) reads only p.session — never p.buckets. + // This test locks the contract: getWindowSections returns buckets for detail + // views, while the plain session value remains independently available for + // compact rendering without bucket names bleeding through. + const p: ProviderRateLimits = { + provider: 'gemini', + session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + buckets: [ + { + name: 'Pro', + usedPercent: 30, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + name: 'Flash', + usedPercent: 80, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + // Compact path uses p.session directly — independent of getWindowSections. + expect(p.session?.usedPercent).toBe(80) + // getWindowSections (detail path) returns bucket rows, not session label. + const sections = getWindowSections(p) + const labels = sections.map((s) => s.label) + expect(labels).toContain('Pro') + expect(labels).toContain('Flash') + expect(labels).not.toContain('Session') + }) + + it('preserves reset metadata inside bucket windows', () => { + const p: ProviderRateLimits = { + provider: 'gemini', + session: null, + weekly: null, + buckets: [ + { + name: 'Pro', + usedPercent: 45, + windowMinutes: 300, + resetsAt: 18000000, + resetDescription: '5:00 PM' + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toHaveLength(2) + expect(sections[0].label).toBe('Pro') + expect(sections[0].window!.resetsAt).toBe(18000000) + expect(sections[0].window!.resetDescription).toBe('5:00 PM') + }) +}) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index d1b1305bc6a..b7cf6f5e242 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,5 +1,5 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' -import { ClaudeIcon, OpenAIIcon } from './icons' +import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' // --------------------------------------------------------------------------- // Formatting helpers @@ -44,14 +44,23 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'codex') { return } + if (provider === 'gemini') { + return + } + if (provider === 'opencode-go') { + return + } return } function ErrorMessage({ message, + stale = false, inverted = false }: { message: string + /** When true, prior data is still visible — show a softer "refresh failed" label. */ + stale?: boolean inverted?: boolean }): React.JSX.Element { const labelClass = inverted ? 'text-background/80' : 'text-foreground/85' @@ -59,12 +68,35 @@ function ErrorMessage({ return (
-
Usage unavailable
+
+ {stale ? 'Refresh failed — showing cached data' : 'Usage unavailable'} +
{message}
) } +// --------------------------------------------------------------------------- +// Window section derivation +// --------------------------------------------------------------------------- + +export function getWindowSections( + p: ProviderRateLimits +): { label: string; window: RateLimitWindow | null }[] { + if (p.buckets?.length) { + const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) + return [...bucketSections, { label: 'Weekly', window: p.weekly }] + } + const sections: { label: string; window: RateLimitWindow | null }[] = [ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: p.weekly } + ] + if (p.monthly !== undefined && p.monthly !== null) { + sections.push({ label: 'Monthly', window: p.monthly }) + } + return sections +} + // --------------------------------------------------------------------------- // Tooltip — progress bar section for a single window // --------------------------------------------------------------------------- @@ -125,7 +157,16 @@ export function ProviderTooltip({ p }: { p: ProviderRateLimits | null }): React. return No data available } - const name = p.provider === 'claude' ? 'Claude' : 'Codex' + const name = + p.provider === 'claude' + ? 'Claude' + : p.provider === 'codex' + ? 'Codex' + : p.provider === 'gemini' + ? 'Gemini' + : p.provider === 'opencode-go' + ? 'OpenCode Go' + : p.provider if (p.status === 'unavailable') { return ( @@ -134,12 +175,12 @@ export function ProviderTooltip({ p }: { p: ProviderRateLimits | null }): React. {name}
-
{p.error ?? 'CLI not found'}
+
{p.error ?? 'Unavailable'}
) } - if (p.status === 'error' && !p.session && !p.weekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.monthly) { return (
@@ -167,14 +208,14 @@ export function ProviderTooltip({ p }: { p: ProviderRateLimits | null }): React. {/* Divider */}
- {/* Session window */} - + {getWindowSections(p).map((s) => ( + + ))} - {/* Weekly window */} - - - {/* Stale data warning */} - {p.error ? : null} + {/* Stale data warning — softer label when prior data is still shown */} + {p.error ? ( + + ) : null}
) } @@ -198,7 +239,16 @@ export function ProviderPanel({ return No data available } - const name = p.provider === 'claude' ? 'Claude' : 'Codex' + const name = + p.provider === 'claude' + ? 'Claude' + : p.provider === 'codex' + ? 'Codex' + : p.provider === 'gemini' + ? 'Gemini' + : p.provider === 'opencode-go' + ? 'OpenCode Go' + : p.provider if (p.status === 'unavailable') { return ( @@ -207,12 +257,12 @@ export function ProviderPanel({ {name}
-
{p.error ?? 'CLI not found'}
+
{p.error ?? 'Unavailable'}
) } - if (p.status === 'error' && !p.session && !p.weekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.monthly) { return (
@@ -270,10 +320,17 @@ export function ProviderPanel({
- - + {getWindowSections(p).map((s) => ( + + ))} - {p.error ? : null} + {p.error ? ( + + ) : null}
) } diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts new file mode 100644 index 00000000000..730afbbd5ad --- /dev/null +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { formatWindowLabel } from './window-label-formatter' + +describe('formatWindowLabel', () => { + it('returns "5h" for 300 minutes', () => { + expect(formatWindowLabel(300)).toBe('5h') + }) + + it('returns "1h" for 60 minutes', () => { + expect(formatWindowLabel(60)).toBe('1h') + }) + + it('returns "wk" for 10080 minutes (7 days)', () => { + expect(formatWindowLabel(10080)).toBe('wk') + }) + + it('returns "1d" for 1440 minutes (1 day)', () => { + expect(formatWindowLabel(1440)).toBe('1d') + }) + + it('returns "2h" for 120 minutes', () => { + expect(formatWindowLabel(120)).toBe('2h') + }) + + it('returns "45m" for 45 minutes', () => { + expect(formatWindowLabel(45)).toBe('45m') + }) + + it('returns "2wk" for 20160 minutes (14 days)', () => { + expect(formatWindowLabel(20160)).toBe('2wk') + }) + + it('returns "30m" for 30 minutes', () => { + expect(formatWindowLabel(30)).toBe('30m') + }) + + it('returns "3d" for 4320 minutes (3 days)', () => { + expect(formatWindowLabel(4320)).toBe('3d') + }) +}) diff --git a/src/renderer/src/lib/window-label-formatter.ts b/src/renderer/src/lib/window-label-formatter.ts new file mode 100644 index 00000000000..1fbbcdb54f5 --- /dev/null +++ b/src/renderer/src/lib/window-label-formatter.ts @@ -0,0 +1,30 @@ +/** + * Returns a short human-readable label for a usage window duration. + * + * Why: 10080 minutes (7 days) is hard-coded as "wk" for backward + * compatibility with the original StatusBar implementation. + */ +export function formatWindowLabel(windowMinutes: number): string { + if (windowMinutes === 10080) { + return 'wk' + } + if (windowMinutes === 300) { + return '5h' + } + if (windowMinutes === 60) { + return '1h' + } + if (windowMinutes < 60) { + return `${windowMinutes}m` + } + if (windowMinutes % (60 * 24 * 7) === 0) { + return `${windowMinutes / (60 * 24 * 7)}wk` + } + if (windowMinutes % (60 * 24) === 0) { + return `${windowMinutes / (60 * 24)}d` + } + if (windowMinutes % 60 === 0) { + return `${windowMinutes / 60}h` + } + return `${windowMinutes}m` +} diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index f393ac92b7d..d3be60d9085 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -10,7 +10,7 @@ export type RateLimitSlice = { } export const createRateLimitSlice: StateCreator = (set) => ({ - rateLimits: { claude: null, codex: null }, + rateLimits: { claude: null, codex: null, gemini: null, opencodeGo: null }, fetchRateLimits: async () => { try { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5b580ea9cff..a0ead7d4a5e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -73,6 +73,8 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'claude', 'codex', + 'gemini', + 'opencode-go', 'ssh', 'sessions', 'memory' @@ -170,6 +172,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { defaultTaskSource: 'github', defaultRepoSelection: null, defaultLinearTeamSelection: null, + opencodeSessionCookie: '', + opencodeWorkspaceId: '', + geminiCliOAuthEnabled: false, agentCmdOverrides: {}, // Why: 'auto' runs a layout-aware probe at boot (see // src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 53f3b91ad82..7931cea4b21 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -11,12 +11,20 @@ export type RateLimitWindow = { export type ProviderRateLimitStatus = 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +export type RateLimitBucket = RateLimitWindow & { + name: string +} + export type ProviderRateLimits = { - provider: 'claude' | 'codex' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ weekly: RateLimitWindow | null + /** 30-day monthly window (OpenCode Go only), null if not available. */ + monthly?: RateLimitWindow | null + /** Named per-model buckets (Gemini only). */ + buckets?: RateLimitBucket[] /** Unix ms timestamp of the last successful data update. */ updatedAt: number /** Human-readable error message, null when status is 'ok'. */ @@ -27,4 +35,6 @@ export type ProviderRateLimits = { export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null } diff --git a/src/shared/types.ts b/src/shared/types.ts index d5ff5d01271..96bef5728a6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -984,6 +984,14 @@ export type GlobalSettings = { * Same nullable-array pattern as `defaultRepoSelection`: `null` = sticky-all, * `string[]` = frozen subset of team IDs. */ defaultLinearTeamSelection: string[] | null + /** Session cookie for OpenCode Go rate-limit fetching. Stored encrypted. */ + opencodeSessionCookie: string + /** Optional workspace ID override for OpenCode Go. When set, skips the + * workspaces lookup and fetches usage directly for this workspace. */ + opencodeWorkspaceId: string + /** Whether to extract OAuth credentials from the local Gemini CLI installation + * for rate-limit fetching. Disabled by default for explicit opt-in. */ + geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Why: macOS terminals must choose between letting Option compose layout @@ -1054,8 +1062,14 @@ export type NotificationDispatchResult = { export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment' -export type StatusBarItem = 'claude' | 'codex' | 'ssh' | 'sessions' | 'memory' - +export type StatusBarItem = + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'ssh' + | 'sessions' + | 'memory' export type PersistedUIState = { lastActiveRepoId: string | null lastActiveWorktreeId: string | null From 10a0566b1340daad0b08963ff73f12ef2e53de79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:59:09 -0700 Subject: [PATCH 037/259] refactor(shortcuts): remove dead Cmd+Shift+D dashboard shortcut (#1260) Co-authored-by: Orca --- src/renderer/src/App.tsx | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4f691466b70..d5374304b3f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -710,37 +710,6 @@ function App(): React.JSX.Element { return } - // Cmd/Ctrl+Shift+D — open right sidebar (agent dashboard is now - // docked at the sidebar bottom, so only toggle visibility). - // Why: skip when a terminal is focused — that combo is the terminal - // split-pane shortcut (see terminal-shortcut-policy.ts). Both listeners - // share the window capture phase and registration order can vary with - // React effect re-runs, so a DOM check is the reliable coordination - // mechanism (same pattern as Cmd+Shift+G above). xterm-helper-textarea - // is the focus target the terminal handler itself uses to detect - // terminal focus (see keyboard-handlers.ts isEditableTarget). - if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { - // Why: read the experimental toggle at keypress time via getState() so - // flipping the setting takes effect immediately without re-binding the - // window-level listener (which would require adding `settings` to the - // effect deps and tearing down/rebinding on every unrelated settings - // change, like theme or font adjustments). Gated behind the key/modifier - // check so getState() isn't invoked on every unrelated keystroke. - const dashboardExperimentEnabled = - useAppStore.getState().settings?.experimentalAgentDashboard === true - if (!dashboardExperimentEnabled) { - return - } - const active = document.activeElement as HTMLElement | null - if (active?.classList.contains('xterm-helper-textarea')) { - return - } - dispatchClearModifierHints() - e.preventDefault() - actions.setRightSidebarOpen(true) - return - } - // Cmd+Shift+I — toggle right sidebar / ports tab (macOS only). // Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux; // intercepting it would break an essential developer tool. From 9fe66f531b786b68bd96da24d343d440dc4d4eab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:14:37 -0700 Subject: [PATCH 038/259] perf(new-workspace): cache + dedupe Create-from work-item fetches (#1263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(new-workspace): cache + dedupe Create-from work-item fetches The Create-from tab was slow because it bypassed the renderer's existing 60s work-items cache: every sub-tab switch or modal re-open fired a fresh `gh pr list --search is:pr is:open` / `gh issue list --search is:issue is:open`, which also forces the main-side slow path (`listQueriedWorkItems`, uncached) instead of the fast `listRecentWorkItems` path that wraps `gh api --cache 120s`. - Route the PR + issue effects through the store's `fetchWorkItems` so repeat opens / sub-tab toggles hit the 60s renderer cache + inflight dedupe, and show cached data instantly while revalidating. - Drop the `is:pr is:open` / `is:issue is:open` qualifiers when no query is typed so the backend takes the fast cached recent-items path. Both effects now share the same cache key on empty query, so PR ↔ Issue switching issues a single underlying gh call. - Add a small module-scoped 60s branches cache so `searchBaseRefs` isn't reshelled out every time the Branches sub-tab becomes visible. Measured on a cold orca repo: first render dropped from ~1,230 ms (`is:pr` + `is:issue` sequential) to ~45 ms for the unqualified list, with subsequent opens and sub-tab swaps serving from cache in <5 ms. Co-authored-by: Orca * perf(new-workspace): finish create-from launch sooner --------- Co-authored-by: Orca --- .../new-workspace/CreateFromTab.tsx | 104 +++++++++++--- .../src/lib/launch-work-item-direct.ts | 133 ++++++++++-------- 2 files changed, 156 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/components/new-workspace/CreateFromTab.tsx b/src/renderer/src/components/new-workspace/CreateFromTab.tsx index e2f25f6aed4..4974983b12f 100644 --- a/src/renderer/src/components/new-workspace/CreateFromTab.tsx +++ b/src/renderer/src/components/new-workspace/CreateFromTab.tsx @@ -71,6 +71,17 @@ const SUB_TABS: { const PR_LIST_LIMIT = 36 const ISSUE_LIST_LIMIT = 36 +const BRANCH_CACHE_TTL_MS = 60_000 +// Why: branches change rarely during a composer session. A module-scoped +// cache keyed on repoId + query (60s TTL) means switching sub-tabs or +// re-opening the modal returns the prior result instantly instead of +// re-shelling out to `git for-each-ref` (which can be slow on large repos). +const branchCache = new Map() +// Why: the unqualified empty-query path returns PRs + issues merged and +// sliced to `limit`. Use the sum so each tab can render up to its own cap +// after filtering by type client-side — and because both effects pass the +// same limit, they share one cached IPC call and one gh invocation. +const COMBINED_WORK_ITEM_LIMIT = PR_LIST_LIMIT + ISSUE_LIST_LIMIT const LINEAR_LIST_LIMIT = 36 const SEARCH_DEBOUNCE_MS = 200 @@ -86,7 +97,9 @@ export default function CreateFromTab({ listLinearIssues, searchLinearIssues, rememberedSubTab, - setRememberedSubTab + setRememberedSubTab, + fetchWorkItems, + getCachedWorkItems } = useAppStore( useShallow((s) => ({ activeRepoId: s.activeRepoId, @@ -95,7 +108,9 @@ export default function CreateFromTab({ listLinearIssues: s.listLinearIssues, searchLinearIssues: s.searchLinearIssues, rememberedSubTab: s.createFromSubTab, - setRememberedSubTab: s.setCreateFromSubTab + setRememberedSubTab: s.setCreateFromSubTab, + fetchWorkItems: s.fetchWorkItems, + getCachedWorkItems: s.getCachedWorkItems })) ) @@ -221,22 +236,38 @@ export default function CreateFromTab({ return // handled by direct-lookup effect below } const trimmed = debouncedQuery.trim() - const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : 'is:pr is:open' + // Why: when the user hasn't typed anything, use the unqualified listing — + // the backend routes that to `listRecentWorkItems` which hits + // `gh api --cache 120s` (fast, cached). Adding `is:pr is:open` forces the + // slow `gh pr list --search` path every time and skips the 60s renderer + // cache in the store. Same shortcut is used for the issues effect below. + const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : '' + // Why: empty-query path returns PRs+issues merged, so use the combined + // cap so the PR and Issue effects collapse onto the same cache key and + // dedupe into a single gh invocation via the store's inflight tracker. + const effectiveLimit = trimmed ? PR_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT + + // Why: route through the store so the 60s workItemsCache + inflight dedup + // kick in. Re-opening the modal or toggling PR↔Issue sub-tabs returns + // cached data instantly instead of re-running gh search. + const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) + if (cached) { + setPrItems(cached.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) + setPrLoading(false) + setPrError(null) + } let stale = false - setPrLoading(true) + if (!cached) { + setPrLoading(true) + } setPrError(null) - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: PR_LIST_LIMIT, query: q }) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) .then((items) => { if (stale) { return } - setPrItems( - items - .filter((i) => i.type === 'pr') - .map((i) => ({ ...i, repoId: selectedRepo.id })) as unknown as GitHubWorkItem[] - ) + setPrItems(items.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) setPrLoading(false) }) .catch((err) => { @@ -256,7 +287,9 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.directNumber + normalizedGhQuery.directNumber, + fetchWorkItems, + getCachedWorkItems ]) // --------------------------------------------------------------------- @@ -274,22 +307,30 @@ export default function CreateFromTab({ return } const trimmed = debouncedQuery.trim() - const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : 'is:issue is:open' + // Why: empty query → backend's fast cached path (see PR effect above). + // When no query is typed this is the SAME IPC call as the PR effect, so + // the store's inflight dedup collapses them into a single gh invocation. + const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : '' + const effectiveLimit = trimmed ? ISSUE_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT + + const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) + if (cached) { + setIssueItems(cached.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) + setIssueLoading(false) + setIssueError(null) + } let stale = false - setIssueLoading(true) + if (!cached) { + setIssueLoading(true) + } setIssueError(null) - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: ISSUE_LIST_LIMIT, query: q }) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) .then((items) => { if (stale) { return } - setIssueItems( - items - .filter((i) => i.type === 'issue') - .map((i) => ({ ...i, repoId: selectedRepo.id })) as unknown as GitHubWorkItem[] - ) + setIssueItems(items.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) setIssueLoading(false) }) .catch((err) => { @@ -309,7 +350,9 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.directNumber + normalizedGhQuery.directNumber, + fetchWorkItems, + getCachedWorkItems ]) // --------------------------------------------------------------------- @@ -371,11 +414,26 @@ export default function CreateFromTab({ return } const trimmed = debouncedQuery.trim() + const cacheKey = `${selectedRepo.id}::${trimmed}` + const cached = branchCache.get(cacheKey) + const fresh = cached && Date.now() - cached.fetchedAt < BRANCH_CACHE_TTL_MS + if (cached) { + // Why: show stale cache immediately (SWR-style); the fetch below keeps + // the list current. Only skip the loader flash if the entry is fresh. + setBranches(cached.data) + if (fresh) { + setBranchesLoading(false) + return + } + } let stale = false - setBranchesLoading(true) + if (!cached) { + setBranchesLoading(true) + } void window.api.repos .searchBaseRefs({ repoId: selectedRepo.id, query: trimmed, limit: 30 }) .then((results) => { + branchCache.set(cacheKey, { data: results, fetchedAt: Date.now() }) if (!stale) { setBranches(results) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 5aa0f6baa6b..eb645d53b52 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -98,6 +98,39 @@ async function resolveSetupDecision( } } +async function pasteWorkItemDraftWhenAgentReady(args: { + primaryTabId: string + startupPlan: NonNullable> + content: string +}): Promise { + const { primaryTabId, startupPlan, content } = args + const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { + timeoutMs: 5000 + }) + if (!readyResult.ready) { + toast.message( + 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' + ) + return + } + + const finalState = useAppStore.getState() + const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] + if (!ptyId) { + return + } + + // Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can + // interpret our paste markers. `title-idle` means the TUI has fully rendered + // its input box and enabled paste mode; weaker signals (`foreground-match`, + // `child-process`) only confirm the binary is running — the TUI's input + // setup may still be in-flight, especially on slow shell environments. + const graceMs = readyResult.reason === 'title-idle' ? 150 : 600 + await new Promise((resolve) => window.setTimeout(resolve, graceMs)) + + window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) +} + /** * "Use" flow: create the workspace, activate it, launch the default agent, * and paste the work item URL into the agent's prompt as a draft (no submit). @@ -121,8 +154,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } const settings = store.settings - const detectedIds = new Set(await store.ensureDetectedAgents()) - const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + // Why: agent detection shells out and can be cold/slow. Start it now, but + // don't let it serialize setup-policy resolution or git worktree creation. + const detectedAgentsPromise = store.ensureDetectedAgents() const setupResolution = await resolveSetupDecision(repoId, repo) if (setupResolution.kind === 'needs-modal') { @@ -137,22 +171,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom linkedPR: item.type === 'pr' ? (item.number ?? null) : null }) - // Why: launch the agent with no prompt so the first frame it draws is the - // empty input box. The URL paste below populates that input buffer, which - // gives the user a reviewable draft instead of a submitted request. - const startupPlan = - effectiveAgent === null - ? null - : buildAgentStartupPlan({ - agent: effectiveAgent, - prompt: '', - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true - }) - let worktreeId: string let primaryTabId: string | null + let startupPlan: ReturnType = null try { const result = await store.createWorktree( repoId, @@ -162,6 +183,22 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ) worktreeId = result.worktree.id + const detectedIds = new Set(await detectedAgentsPromise) + const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + // Why: launch the agent with no prompt so the first frame it draws is the + // empty input box. The URL paste below populates that input buffer, which + // gives the user a reviewable draft instead of a submitted request. + startupPlan = + effectiveAgent === null + ? null + : buildAgentStartupPlan({ + agent: effectiveAgent, + prompt: '', + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + const activation = activateAndRevealWorktree(worktreeId, { setup: result.setup, ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) @@ -193,11 +230,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom meta.linkedLinearIssue = item.linearIdentifier } if (Object.keys(meta).length > 0) { - try { - await store.updateWorktreeMeta(worktreeId, meta) - } catch { + void store.updateWorktreeMeta(worktreeId, meta).catch(() => { // Meta update is non-critical for the draft flow — continue. - } + }) } store.setSidebarOpen(true) @@ -213,32 +248,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return } - const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { - timeoutMs: 5000 - }) - if (!readyResult.ready) { - toast.message( - 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' - ) - return - } - - const finalState = useAppStore.getState() - const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] - if (!ptyId) { - return - } - - // Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can - // interpret our paste markers. `title-idle` means the TUI has fully rendered - // its input box and enabled paste mode; weaker signals (`foreground-match`, - // `child-process`) only confirm the binary is running — the TUI's input - // setup may still be in-flight, especially on slow shell environments. - const graceMs = readyResult.reason === 'title-idle' ? 150 : 600 - await new Promise((resolve) => window.setTimeout(resolve, graceMs)) - const content = item.pasteContent ?? item.url - window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) + // Why: the workspace is already created and visible; waiting up to 5s for + // agent readiness here kept the Create-from modal in "Creating workspace…". + // Continue the draft paste in the background so selection latency ends when + // the worktree is ready, not when the TUI input buffer is ready. + void pasteWorkItemDraftWhenAgentReady({ primaryTabId, startupPlan, content }) } export type LaunchFromBranchArgs = { @@ -264,8 +279,9 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise Date: Wed, 29 Apr 2026 17:23:07 -0700 Subject: [PATCH 039/259] feat(agent-cockpit): bold-until-viewed weight on dashboard rows (#1262) Co-authored-by: Orca --- src/renderer/src/App.tsx | 2 + .../dashboard/DashboardAgentRow.tsx | 39 +++-- .../dashboard/DashboardWorktreeCard.tsx | 87 ++++++++-- .../components/sidebar/AgentStatusHover.tsx | 36 +++- .../src/hooks/useAutoAckViewedAgent.ts | 155 ++++++++++++++++++ .../slices/agent-status-ack-cleanup.test.ts | 118 +++++++++++++ src/renderer/src/store/slices/agent-status.ts | 59 ++++++- src/renderer/src/store/slices/ui.ts | 36 ++++ 8 files changed, 499 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/hooks/useAutoAckViewedAgent.ts create mode 100644 src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d5374304b3f..a1b3c4d600d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -27,6 +27,7 @@ import { ZoomOverlay } from './components/ZoomOverlay' import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' +import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled @@ -175,6 +176,7 @@ function App(): React.JSX.Element { // of tying reloads to the Explorer UI lifecycle. useEditorExternalWatch() useGlobalFileDrop() + useAutoAckViewedAgent() // Why: sidebar open/close flips width instantaneously. useLayoutEffect // runs synchronously after React commits the DOM but before paint, so diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index cf52937fe90..748d40214d2 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -63,8 +63,10 @@ function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { type Props = { agent: DashboardAgentRowData onDismiss: (paneKey: string) => void - /** Navigate directly to the tab this agent lives in. */ - onActivate: (tabId: string) => void + /** Navigate directly to the tab this agent lives in. paneKey is passed + * through so the caller can acknowledge (mark-visited) the specific row + * that was clicked, without having to re-derive it from the tab id. */ + onActivate: (tabId: string, paneKey: string) => void /** * Why: the relative-time labels ("Xm ago") need a periodic re-render to stay * honest. We accept `now` from a parent container so a single 30s tick owned @@ -73,13 +75,25 @@ type Props = { * tick (AgentDashboard for the dashboard, AgentStatusHover for hovercards). */ now: number + /** + * Why: bold weight for the prompt rides on the enclosing worktree's + * isUnread (unvisited) signal, not on the per-agent state. Passed in from + * DashboardWorktreeCard so the workspace name and its agent rows share + * the same "you haven't looked at this yet" rule — visiting the worktree + * clears isUnread, and the next render mutes both in lockstep. + * + * Optional so other callers can opt out and default to muted when their + * surface carries the unread signal elsewhere. + */ + isUnvisited?: boolean } const DashboardAgentRow = React.memo(function DashboardAgentRow({ agent, onDismiss, onActivate, - now + now, + isUnvisited = false }: Props) { const [expanded, setExpanded] = useState(false) // Why: stop propagation so clicking the X doesn't also fire the worktree @@ -120,9 +134,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const handleActivate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - onActivate(agent.tab.id) + onActivate(agent.tab.id, agent.paneKey) }, - [onActivate, agent.tab.id] + [onActivate, agent.tab.id, agent.paneKey] ) const startedAt = agent.startedAt > 0 ? agent.startedAt : null const doneAt = lastEnteredDoneAt(agent) @@ -223,12 +237,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ overflow-hidden so the truncate→wrap class flip stays clipped during the interpolation. - Done and waiting rows get "unread" weight (semibold + full - foreground) so the user can tell at a glance which rows need - their attention. Working/idle stay at the quieter baseline - since the state dot (spinner or neutral) is already doing the - work. Mirrors the convention in Slack/Gmail/Linear where the - row text thickens to signal "you haven't dealt with this yet." + Weight tracks the workspace's unvisited signal (isUnvisited): + bold + full foreground for agents inside a workspace the user + hasn't looked at yet, normal + muted once they've visited. This + keeps the prompt row's weight in lockstep with the workspace + name above it — one attention axis, not two. Rendered unconditionally with a state-label fallback so rows without a prompt (fresh/unknown) still have a human-readable @@ -238,9 +251,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ 'block min-w-0 flex-1 overflow-hidden text-[11px] leading-snug', 'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]', expanded ? 'h-auto whitespace-pre-wrap break-words' : 'h-[1lh] truncate', - agent.state === 'done' || agent.state === 'waiting' || agent.state === 'blocked' - ? 'font-semibold text-foreground' - : 'font-medium text-foreground/90' + isUnvisited ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground' )} // Why: tooltip should only reveal truncated prompt text — not echo state-word fallbacks // (e.g. "Working"/"Done") that already fit on one line and never overflow. diff --git a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx index 7669b5bb397..3a99343f62c 100644 --- a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx +++ b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import DashboardAgentRow from './DashboardAgentRow' @@ -39,26 +39,62 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({ }: Props) { const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const setActiveView = useAppStore((s) => s.setActiveView) + const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents) - // Why: clicking a worktree row only navigates. It does NOT dismiss retained - // done agents — the user may have multiple agents done (e.g. Claude + Codex) - // and silently dropping any of them on row click erases a signal they were - // about to click through to investigate. Dismissal happens only through the - // explicit X button on each agent row. + const paneKeys = useMemo(() => card.agents.map((a) => a.paneKey), [card.agents]) + // Why: subscribe to the ack map's single reference (cheap Object.is check + // via Zustand's default equality) and derive the per-card slice locally. + // A useShallow selector here would allocate a fresh object on every + // store change — including unrelated ones like terminal output — + // multiplied across every card on screen. Reading the reference and + // memoizing the per-card slice collapses that to one allocation per + // card per genuine ack change. acknowledgeAgents in ui.ts preserves the + // map reference when no ack is actually moving forward, so unrelated + // clicks do not invalidate this memo either. + const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) + const ackByPaneKey = useMemo(() => { + const out: Record = {} + for (const paneKey of paneKeys) { + out[paneKey] = acknowledgedAgentsByPaneKey[paneKey] ?? 0 + } + return out + }, [paneKeys, acknowledgedAgentsByPaneKey]) + + // Why: an agent counts as "unvisited" when it has no ack OR the agent's + // current state began after the last ack (a new turn/state transition is + // a fresh signal the user hasn't seen). Using stateStartedAt (not + // updatedAt) means within-state tool/prompt pings don't re-trigger the + // unread highlight; only genuine state changes do. + const isAgentUnvisited = useCallback( + (paneKey: string, stateStartedAt: number) => { + const ackAt = ackByPaneKey[paneKey] ?? 0 + return ackAt < stateStartedAt + }, + [ackByPaneKey] + ) + + // Why: clicking a worktree row navigates AND acknowledges every agent + // currently shown under it. The user looking at the card counts as + // "seeing" all its rows, even ones they don't click individually — + // otherwise a workspace with five done agents would stay bold forever + // after the user scrolled past it. Dismissal of done rows still requires + // the explicit X; ack only changes the visual weight. const handleClick = useCallback(() => { setActiveWorktree(card.worktree.id) setActiveView('terminal') - }, [card.worktree.id, setActiveWorktree, setActiveView]) + acknowledgeAgents(paneKeys) + }, [card.worktree.id, paneKeys, setActiveWorktree, setActiveView, acknowledgeAgents]) - // Why: clicking an agent row only navigates to that agent's tab. It must not - // call onCheck() — that would mark the worktree as checked AND dismiss all - // retained done rows in it, which erases the signal the user was clicking - // through to investigate. Only the X button on a done row should dismiss it. + // Why: clicking an agent row navigates to that agent's tab AND acks the + // row so it fades to the visited weight. Scoped to a single paneKey so + // sibling rows (other agents on the same workspace) remain bold until the + // user looks at them. Dismissal still requires the explicit X. const handleActivateAgent = useCallback( - (tabId: string) => { + (tabId: string, paneKey: string) => { onActivateAgentTab(card.worktree.id, tabId) + acknowledgeAgents([paneKey]) }, - [card.worktree.id, onActivateAgentTab] + [card.worktree.id, onActivateAgentTab, acknowledgeAgents] ) // Why: React's onFocus handler receives a SyntheticEvent, but the parent @@ -70,6 +106,17 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({ const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '') ?? '' + // Why: workspace-level bold/muted weight tracks whether ANY of this card's + // agents are unvisited — so the workspace header stays bold while even one + // row inside it needs the user's attention, and fades once the user has + // clicked through every agent. Per-agent granularity lives on the rows + // themselves (DashboardAgentRow isUnvisited prop). If a workspace has no + // agents (edge case during spin-up), default to muted so the row doesn't + // read louder than it has value to. + const anyAgentUnvisited = card.agents.some((a) => + isAgentUnvisited(a.paneKey, a.entry.stateStartedAt) + ) + // Why: the card is a clickable *surface* but NOT a `role="button"` — its // children (DashboardAgentRow) render real
))} diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts new file mode 100644 index 00000000000..646f0db0700 --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -0,0 +1,155 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' + +// Why: an agent row counts as "already seen" when the user is actually looking +// at the tab it lives on. Without this effect, ack only fires via an explicit +// click in the dashboard — which misses the common case where the user is +// already on the terminal tab when the agent finishes or blocks. That leaves +// the dashboard bolded for an event the user literally just watched happen. +// +// The effect subscribes directly to the store (not via React selectors) so it +// sees every state change with no re-render amplification up the component +// tree. A reference-equality guard inside the callback bails out immediately +// when none of the five slices we care about (activeView, activeTabId, +// agentStatusByPaneKey, acknowledgedAgentsByPaneKey, settings) have changed — +// so the Object.entries walk only runs for updates that could legitimately +// affect the ack decision. +// +// It acks whenever: +// - activeView is 'terminal' (the user isn't on Settings/Tasks), AND +// - activeTabId identifies a live tab, AND +// - at least one agentStatusByPaneKey entry has paneKey prefixed by +// `${activeTabId}:` AND its ackAt < stateStartedAt. +// +// The ack ALSO requires the OS window to be visible and focused +// (document.visibilityState === 'visible' && document.hasFocus()) — +// otherwise a transition that arrives while the user is away would silently +// clear the bold-until-viewed signal for an event they never saw. A +// visibilitychange / focus listener re-runs the scan when the user returns +// so any transitions that failed the gate while away get acked the moment +// focus actually comes back. +// +// We ack ALL matching panes in one call (a tab can host split panes, each +// with its own paneKey) so acknowledgeAgents' identity-preserving guard +// collapses the no-op path. +export function useAutoAckViewedAgent(): void { + useEffect(() => { + // Why: the root zustand store is created with plain `create()` (no + // subscribeWithSelector middleware), so subscribe has no selector form. + // Track the slice references we actually depend on and early-return on + // unrelated updates — terminal output, tab state, settings, etc. would + // otherwise invoke the scan on every store change. Initialize to + // `undefined` so the first call always runs at least once. + let lastActiveView: unknown = undefined + let lastActiveTabId: unknown = undefined + let lastAgentStatus: unknown = undefined + let lastAcknowledged: unknown = undefined + // Why: settings is tracked so flipping `experimentalAgentDashboard` + // alone re-evaluates the feature gate below — without this, a pure + // settings change would be swallowed by the fast-path until some other + // tracked slice changed. Tracking the whole settings reference (rather + // than the one specific boolean) is acceptable because settings changes + // are rare compared to agent-status updates, and it matches the existing + // pattern of tracking whole slice references. + let lastSettings: unknown = undefined + + const maybeAck = (): void => { + const s = useAppStore.getState() + if ( + s.activeView === lastActiveView && + s.activeTabId === lastActiveTabId && + s.agentStatusByPaneKey === lastAgentStatus && + s.acknowledgedAgentsByPaneKey === lastAcknowledged && + s.settings === lastSettings + ) { + return + } + + // Why: mirror the dashboard's visibility gate — if the experimental + // agent dashboard is off, nothing in the UI reads the ack map, so + // accumulating entries for unseen agents is wasted memory and the + // Object.entries scan below is pure overhead. The subscribe callback + // fires on any store change, so flipping the setting naturally + // re-evaluates this guard without a separate subscription. + if (s.settings?.experimentalAgentDashboard !== true) { + return + } + + if (s.activeView !== 'terminal') { + return + } + // Why: the auto-ack represents "the user saw this row" — but tab-active is + // only a proxy. If the OS window is hidden, minimized, or another app has + // focus, the user is demonstrably not looking at the dashboard even with the + // terminal tab set. Without this gate, an agent finishing while the user is + // away silently clears the bold-until-viewed signal and the user returns to + // a dashboard with no indication anything transitioned. + if (typeof document !== 'undefined') { + if (document.visibilityState !== 'visible') { + return + } + if (!document.hasFocus()) { + return + } + } + const activeTabId = s.activeTabId + if (!activeTabId) { + return + } + // Why: advance the refs ONLY after all gates have passed — if the visibility + // or feature gate caused an early return, we must leave the refs stale so + // the next call (e.g. triggered by the focus listener on return) sees a + // diff and actually runs the scan. Updating refs before the gates would + // consume the diff silently and leave the user returning to a dashboard + // whose bold-until-viewed rows stay bold until some unrelated store change + // happens to bump the refs again. + lastActiveView = s.activeView + lastActiveTabId = s.activeTabId + lastAgentStatus = s.agentStatusByPaneKey + lastAcknowledged = s.acknowledgedAgentsByPaneKey + lastSettings = s.settings + const prefix = `${activeTabId}:` + const toAck: string[] = [] + for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { + if (!paneKey.startsWith(prefix)) { + continue + } + const ackAt = s.acknowledgedAgentsByPaneKey[paneKey] ?? 0 + // Why: use stateStartedAt (not updatedAt) so tool/prompt pings + // within the same state don't re-trigger ack work on every event — + // acknowledgeAgents short-circuits anyway when the value is + // unchanged, but keeping the comparison in sync with the + // "is-unvisited" rule in DashboardWorktreeCard avoids a stutter + // where we ack on an updatedAt-bump that didn't cross a state + // transition. + if (ackAt < entry.stateStartedAt) { + toAck.push(paneKey) + } + } + if (toAck.length > 0) { + s.acknowledgeAgents(toAck) + } + } + // Why: run once on mount to catch the case where the app restores to a + // session whose current state already has agents on the visible tab. + maybeAck() + // Why: store.subscribe fires on every state change. The reference- + // equality guard above bails out immediately for the common case + // (terminal output, timers, etc.) so the Object.entries walk only runs + // when one of the five slices we read has actually changed. + const unsubscribe = useAppStore.subscribe(maybeAck) + // Why: focus/visibility don't flow through the zustand store, so a + // late-arriving transition that failed the gate above never re-evaluates + // when focus returns. Subscribe to the two DOM events so the ack scan + // reruns the moment the user is actually back on the window. + const onVisibility = (): void => maybeAck() + const onFocus = (): void => maybeAck() + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('focus', onFocus) + return () => { + unsubscribe() + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('focus', onFocus) + } + }, []) +} diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts new file mode 100644 index 00000000000..8759d34e9ab --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' + +// Why: paneKeys are generated from `${tabId}:${paneId}`, and both ids are +// freshly minted on creation, so a reused paneKey is extremely unlikely to +// surface through ordinary UI flow. These tests exercise the cleanup path +// directly by simulating the "close and re-create the same paneKey" case — +// this is the only guarantee that closed panes can't leak stale acks that +// silently suppress the "unvisited" signal on future paneKey collisions. + +describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('removeAgentStatus drops the ack entry for the closed pane', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' }) + store.getState().acknowledgeAgents(['tab-1:0']) + expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeGreaterThan(0) + + store.getState().removeAgentStatus('tab-1:0') + + expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined() + }) + + it('removeAgentStatusByTabPrefix drops every ack entry whose paneKey starts with the tab prefix', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' }) + store + .getState() + .setAgentStatus('tab-1:1', { state: 'working', prompt: 'p', agentType: 'claude' }) + store + .getState() + .setAgentStatus('tab-10:0', { state: 'working', prompt: 'p', agentType: 'claude' }) + store.getState().acknowledgeAgents(['tab-1:0', 'tab-1:1', 'tab-10:0']) + + store.getState().removeAgentStatusByTabPrefix('tab-1') + + const ack = store.getState().acknowledgedAgentsByPaneKey + expect(ack['tab-1:0']).toBeUndefined() + expect(ack['tab-1:1']).toBeUndefined() + // Why: the ":" delimiter on the prefix guards against false-prefix matches + // across tab ids that share a leading substring (tab-1 vs tab-10). + expect(ack['tab-10:0']).toBeGreaterThan(0) + }) + + it('dropAgentStatus drops the ack entry even when the pane had no live entry', () => { + vi.useFakeTimers() + const store = createTestStore() + // Why: simulate a dismissed/retained-only row — no live entry but an ack + // was planted earlier in the session. Without cleanup on this path, the + // ack would outlive every lifecycle and silently suppress a future + // paneKey collision's unvisited signal. + store.getState().acknowledgeAgents(['tab-2:0']) + expect(store.getState().acknowledgedAgentsByPaneKey['tab-2:0']).toBeGreaterThan(0) + + store.getState().dropAgentStatus('tab-2:0') + + expect(store.getState().acknowledgedAgentsByPaneKey['tab-2:0']).toBeUndefined() + }) + + it('dropAgentStatusByTabPrefix drops ack entries even when no live or retained entries matched', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().acknowledgeAgents(['tab-3:0', 'tab-3:1']) + expect(Object.keys(store.getState().acknowledgedAgentsByPaneKey)).toEqual( + expect.arrayContaining(['tab-3:0', 'tab-3:1']) + ) + + store.getState().dropAgentStatusByTabPrefix('tab-3') + + const ack = store.getState().acknowledgedAgentsByPaneKey + expect(ack['tab-3:0']).toBeUndefined() + expect(ack['tab-3:1']).toBeUndefined() + }) + + it('a paneKey reused after teardown reads as unvisited (no leaked ack suppresses the signal)', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-29T12:00:00.000Z')) + const store = createTestStore() + + // Session 1: agent runs on tab-1:0, user acks it, tab closes. + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'first', agentType: 'claude' }) + store.getState().acknowledgeAgents(['tab-1:0']) + const firstAck = store.getState().acknowledgedAgentsByPaneKey['tab-1:0'] + expect(firstAck).toBeGreaterThan(0) + + store.getState().dropAgentStatusByTabPrefix('tab-1') + expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined() + + // Session 2: a brand-new tab+pane happens to collide on the same paneKey, + // some time later. The ack from session 1 must NOT suppress the + // unvisited signal for this new agent. + vi.setSystemTime(new Date('2026-04-29T12:05:00.000Z')) + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'second', agentType: 'codex' }) + const newEntry = store.getState().agentStatusByPaneKey['tab-1:0'] + const ackAt = store.getState().acknowledgedAgentsByPaneKey['tab-1:0'] ?? 0 + + // Why: the unvisited rule (DashboardWorktreeCard's isAgentUnvisited) is + // `ackAt < stateStartedAt`. A leaked session-1 ack would still be + // greater than the second paneKey's fresh stateStartedAt only by + // accident of wall-clock ordering, but more robustly: after cleanup, + // ackAt is 0, which is strictly less than any stateStartedAt. + expect(ackAt).toBe(0) + expect(ackAt < newEntry.stateStartedAt).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 7ab880ae627..db80001473a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -251,11 +251,24 @@ export const createAgentStatusSlice: StateCreator { const next = { ...s.agentStatusByPaneKey } delete next[paneKey] + // Why: acknowledgedAgentsByPaneKey is written per user-ack but owned + // lifecycle-wise by the pane — drop the ack entry in lockstep with the + // live-map entry so closed panes don't leave stale ack timestamps that + // could silently suppress "unvisited" signals on future paneKey + // collisions. + let nextAck = s.acknowledgedAgentsByPaneKey + if (paneKey in nextAck) { + nextAck = { ...nextAck } + delete nextAck[paneKey] + } // Why: bump sortEpoch in lockstep with agentStatusEpoch — removing an // agent can legitimately change worktree sort order, same rationale // as setAgentStatus. return { agentStatusByPaneKey: next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } @@ -275,12 +288,24 @@ export const createAgentStatusSlice: StateCreator k.startsWith(prefix)) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const k of ackKeys) { + delete nextAck[k] + } + } // Why: bump sortEpoch in lockstep with agentStatusEpoch — removing // agents can legitimately change worktree sort order, same rationale // as setAgentStatus. The pre-check guards against spurious bumps when // no keys matched the prefix. return { agentStatusByPaneKey: next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } @@ -298,13 +323,25 @@ export const createAgentStatusSlice: StateCreator k.startsWith(prefix) ) + // See removeAgentStatus for rationale on ack cleanup. Apply this + // regardless of live/retained presence — ack entries are owned by + // the pane lifecycle independently of live/retained state. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter((k) => k.startsWith(prefix)) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const k of ackKeys) { + delete nextAck[k] + } + } if (liveKeys.length === 0 && retainedKeys.length === 0) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { acknowledgedAgentsByPaneKey: nextAck } + } return s } hadLive = liveKeys.length > 0 @@ -425,6 +479,9 @@ export const createAgentStatusSlice: StateCreator void setSidebarOpen: (open: boolean) => void setSidebarWidth: (width: number) => void + /** Per-agent "I've looked at this" timestamps, keyed by paneKey. Set when + * the user clicks an agent row or its parent workspace card from the + * dashboard. A row is considered unvisited when no ack exists OR the + * agent's current stateStartedAt is newer than the last ack (i.e. the + * agent has transitioned state since the user last saw it). Session-only + * — restart resets everyone to unvisited, which is harmless since the + * first visit after launch is a legitimate "need to see" moment. */ + acknowledgedAgentsByPaneKey: Record + acknowledgeAgents: (paneKeys: string[]) => void activeView: 'terminal' | 'settings' | 'tasks' previousViewBeforeTasks: 'terminal' | 'settings' previousViewBeforeSettings: 'terminal' | 'tasks' @@ -193,6 +202,33 @@ export const createUISlice: StateCreator = (set, get) setSidebarOpen: (open) => set({ sidebarOpen: open }), setSidebarWidth: (width) => set({ sidebarWidth: width }), + acknowledgedAgentsByPaneKey: {}, + acknowledgeAgents: (paneKeys) => + set((s) => { + if (paneKeys.length === 0) { + return s + } + const now = Date.now() + // Why: only allocate a new map (and emit a store update) if at least + // one ack is actually moving forward. Comparing `prev < now` instead + // of `prev !== now` matters because stored values are historical + // timestamps and `Date.now()` advances every millisecond — a strict- + // inequality guard would fire on every call and rewrite the map on + // every dashboard click or auto-ack tick, forcing every subscriber + // (all agent rows, the SidebarHeader count, etc.) to re-render. + let next: Record | null = null + for (const key of paneKeys) { + const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 + if (prev < now) { + if (next === null) { + next = { ...s.acknowledgedAgentsByPaneKey } + } + next[key] = now + } + } + return next ? { acknowledgedAgentsByPaneKey: next } : s + }), + activeView: 'terminal', previousViewBeforeTasks: 'terminal', previousViewBeforeSettings: 'terminal', From b5b5e1b7278f74b41224b20a14a5cac8d44e6653 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:31:11 -0700 Subject: [PATCH 040/259] refactor(settings): move provider accounts into dedicated Agent Accounts pane (#1264) * refactor(settings): move provider accounts into dedicated Agent Accounts pane Extracts the Claude, Codex, Gemini, and OpenCode Go account/provider sections out of General and into a new "Agent Accounts" sidebar section so related controls live together and General stays focused on workspace/editor/updates. Co-authored-by: Orca * feat(settings): show provider logos on Agent Accounts headings Adds the Claude, Codex, Gemini, and OpenCode Go brand icons to each provider section heading so the Agent Accounts pane reads as a provider list at a glance instead of a wall of text. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../src/components/settings/AccountsPane.tsx | 810 ++++++++++++++++++ .../src/components/settings/GeneralPane.tsx | 756 +--------------- .../src/components/settings/Settings.tsx | 21 +- .../components/settings/accounts-search.ts | 51 ++ .../src/components/settings/general-search.ts | 47 - .../src/components/status-bar/StatusBar.tsx | 8 +- src/renderer/src/store/slices/ui.ts | 1 + 7 files changed, 888 insertions(+), 806 deletions(-) create mode 100644 src/renderer/src/components/settings/AccountsPane.tsx create mode 100644 src/renderer/src/components/settings/accounts-search.ts diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx new file mode 100644 index 00000000000..8a99966cebe --- /dev/null +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -0,0 +1,810 @@ +/* eslint-disable max-lines -- Why: AccountsPane owns all per-provider account UI + (Claude, Codex, Gemini, OpenCode Go, and future providers). Each provider's + add/select/reauth/remove flow is tightly coupled to the provider-specific + error handling and restart prompts below; splitting them into separate files + would scatter those flows without a meaningful abstraction boundary. */ +import { useEffect, useState } from 'react' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, + GlobalSettings +} from '../../../../shared/types' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Separator } from '../ui/separator' +import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { useAppStore } from '../../store' +import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' +import { toast } from 'sonner' +import { + ACCOUNTS_CLAUDE_SEARCH_ENTRIES, + ACCOUNTS_CODEX_SEARCH_ENTRIES, + ACCOUNTS_GEMINI_SEARCH_ENTRIES, + ACCOUNTS_OPENCODE_SEARCH_ENTRIES, + ACCOUNTS_PANE_SEARCH_ENTRIES +} from './accounts-search' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' +import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +export { ACCOUNTS_PANE_SEARCH_ENTRIES } + +type AccountsPaneProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +function getCodexAccountLabel( + state: CodexRateLimitAccountsState, + accountId: string | null | undefined +): string { + if (accountId == null) { + return 'System default' + } + return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' +} + +function getClaudeAccountLabel( + state: ClaudeRateLimitAccountsState, + accountId: string | null | undefined +): string { + if (accountId == null) { + return 'System default' + } + return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account' +} + +function getCodexAccountErrorDescription(error: unknown): string { + const message = String((error as Error)?.message ?? error) + .replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '') + .replace(/^Error invoking remote method 'codexAccounts:[^']+':\s*/i, '') + .replace(/^Error:\s*/i, '') + .trim() + const normalizedMessage = message.toLowerCase() + + // Why: Codex account actions cross the Electron IPC boundary, and invoke() + // failures often include transport-level wrapper text that is useful in + // devtools but noisy in product UI. Normalize the handful of expected auth + // failures here so users see actionable sign-in guidance instead of IPC + // internals or raw upstream wording. + if (normalizedMessage.includes('timed out waiting for codex login to finish')) { + return 'Codex sign-in took too long to finish. Please try again.' + } + if (normalizedMessage.includes('codex sign-in took too long to finish')) { + return 'Codex sign-in took too long to finish. Please try again.' + } + if ( + normalizedMessage.includes('auth error 502') || + normalizedMessage.includes('gateway') || + normalizedMessage.includes('bad gateway') + ) { + return 'Codex sign-in is temporarily unavailable. Please try again in a minute.' + } + if (normalizedMessage.startsWith('codex login failed:')) { + const loginMessage = message.slice('Codex login failed:'.length).trim() + return loginMessage || 'Codex sign-in failed. Please try again.' + } + + return message || 'Codex sign-in failed. Please try again.' +} + +function getClaudeAccountErrorDescription(error: unknown): string { + return ( + String((error as Error)?.message ?? error) + .replace(/^Error occurred in handler for 'claudeAccounts:[^']+':\s*/i, '') + .replace(/^Error invoking remote method 'claudeAccounts:[^']+':\s*/i, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Claude sign-in failed. Please try again.' + ) +} + +export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): React.JSX.Element { + const searchQuery = useAppStore((s) => s.settingsSearchQuery) + const fetchSettings = useAppStore((s) => s.fetchSettings) + + const [codexAccounts, setCodexAccounts] = useState({ + accounts: [], + activeAccountId: null + }) + const [codexAction, setCodexAction] = useState< + 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` + >('idle') + const [claudeAccounts, setClaudeAccounts] = useState({ + accounts: [], + activeAccountId: null + }) + const [claudeAction, setClaudeAction] = useState< + 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` + >('idle') + const [removeAccountId, setRemoveAccountId] = useState(null) + const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) + + useEffect(() => { + let stale = false + + const loadCodexAccounts = async (): Promise => { + try { + const nextCodex = await window.api.codexAccounts.list() + if (!stale) { + setCodexAccounts(nextCodex) + } + } catch (error) { + if (!stale) { + toast.error('Could not load Codex accounts.', { + description: String((error as Error)?.message ?? error) + }) + } + } + } + + const loadClaudeAccounts = async (): Promise => { + try { + const nextClaude = await window.api.claudeAccounts.list() + if (!stale) { + setClaudeAccounts(nextClaude) + } + } catch (error) { + if (!stale) { + toast.error('Could not load Claude accounts.', { + description: String((error as Error)?.message ?? error) + }) + } + } + } + + void loadCodexAccounts() + void loadClaudeAccounts() + + return () => { + stale = true + } + }, []) + + const syncCodexAccounts = async (next: CodexRateLimitAccountsState): Promise => { + setCodexAccounts(next) + await fetchSettings() + } + + const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise => { + setClaudeAccounts(next) + await fetchSettings() + } + + const formatAccountTimestamp = (timestamp: number): string => { + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) + } + + const runCodexAccountAction = async ( + action: typeof codexAction, + operation: () => Promise + ): Promise => { + const previousActiveAccountId = codexAccounts.activeAccountId + setCodexAction(action) + try { + const next = await operation() + await syncCodexAccounts(next) + const shouldPromptRestart = + action === 'adding' || + (action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) || + (action.startsWith('reauth:') && + next.activeAccountId !== null && + action === `reauth:${next.activeAccountId}`) || + (action.startsWith('remove:') && previousActiveAccountId !== next.activeAccountId) + if (shouldPromptRestart) { + void markLiveCodexSessionsForRestart({ + previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), + nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) + }) + } + } catch (error) { + toast.error('Codex account update failed.', { + description: getCodexAccountErrorDescription(error) + }) + } finally { + setCodexAction('idle') + } + } + + const runClaudeAccountAction = async ( + action: typeof claudeAction, + operation: () => Promise + ): Promise => { + const previousActiveAccountId = claudeAccounts.activeAccountId + setClaudeAction(action) + try { + const next = await operation() + await syncClaudeAccounts(next) + if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { + toast.info('Claude account updated.', { + description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` + }) + } + } catch (error) { + toast.error('Claude account update failed.', { + description: getClaudeAccountErrorDescription(error) + }) + } finally { + setClaudeAction('idle') + } + } + + const visibleSections = [ + matchesSettingsSearch(searchQuery, ACCOUNTS_CLAUDE_SEARCH_ENTRIES) ? ( +
+
+

+ + Claude +

+

+ Add and switch Claude Code accounts without moving chat sessions to account-specific + config directories. +

+
+ + +
+
+ +

+ Orca swaps Claude auth only; config and chat history stay in the shared Claude root. +

+
+ +
+ +
+ + {claudeAccounts.accounts.length === 0 ? ( +
+ No managed Claude accounts yet. Orca will use your system default Claude login until + you add one here. +
+ ) : ( + claudeAccounts.accounts.map((account) => { + const isActive = claudeAccounts.activeAccountId === account.id + const isReauthing = claudeAction === `reauth:${account.id}` + const isBusy = claudeAction !== 'idle' + + return ( + + +
+
+ + ) + }) + )} +
+ + + ) : null, + matchesSettingsSearch(searchQuery, ACCOUNTS_CODEX_SEARCH_ENTRIES) ? ( +
+
+

+ + Codex +

+

+ Add and switch between Codex accounts in Orca. +

+

+ Each account keeps its own local sign-in context in Orca. Account auth stays on this + device. +

+
+ + + {/* Why: Settings deep-links can target this subsection directly from + the status-bar account switcher. Keeping a stable DOM anchor here + avoids dumping the user at the top of Accounts and making them hunt + for the actual Codex account controls. */} +
+
+ +

+ Add a Codex account to use it in Orca. +

+
+ +
+ + {codexAccounts.accounts.length === 0 ? ( +
+ No managed Codex accounts yet. Orca will use your system default Codex login until you + add one here. +
+ ) : ( +
+ + {codexAccounts.accounts.map((account) => { + const isActive = codexAccounts.activeAccountId === account.id + const isReauthing = codexAction === `reauth:${account.id}` + const isRemoving = codexAction === `remove:${account.id}` + const isBusy = codexAction !== 'idle' + + return ( + + +
+ + + ) + })} + + )} +
+
+ ) : null, + matchesSettingsSearch(searchQuery, ACCOUNTS_GEMINI_SEARCH_ENTRIES) ? ( +
+
+

+ + Gemini +

+

Configure Gemini provider settings.

+
+ + +
+ +

+ Extracts OAuth credentials from your local Gemini CLI installation to authenticate + with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break + if Google updates the CLI. Use at your own risk. +

+
+ +
+
+ ) : null, + matchesSettingsSearch(searchQuery, ACCOUNTS_OPENCODE_SEARCH_ENTRIES) ? ( +
+
+

+ + OpenCode Go +

+

Configure OpenCode Go provider settings.

+
+ + + +
+ updateSettings({ opencodeSessionCookie: e.target.value })} + placeholder="Fe26.2**… token or auth=Fe26.2**… header" + spellCheck={false} + className="flex-1 text-xs" + /> + {settings.opencodeSessionCookie && ( + + )} +
+

+ Paste either the raw token value (e.g. Fe26.2**…) or + the full cookie header (e.g. auth=Fe26.2**…). Find it + in your browser's DevTools → Network → any opencode.ai request → Cookie header. +

+
+ + + +
+ updateSettings({ opencodeWorkspaceId: e.target.value })} + placeholder="wrk_… (leave blank for automatic lookup)" + spellCheck={false} + className="flex-1 text-xs" + /> + {settings.opencodeWorkspaceId && ( + + )} +
+

+ Find this in the URL after logging into opencode.ai (e.g.{' '} + opencode.ai/workspace/wrk_…/go). +

+
+
+ ) : null + ].filter(Boolean) + + return ( +
+ !open && setRemoveAccountId(null)} + > + + + Remove Codex Account? + + Orca will delete the managed Codex home for this saved account. If it is currently + active, Orca falls back to the system default Codex login. + + + + + + + + + !open && setRemoveClaudeAccountId(null)} + > + + + Remove Claude Account? + + Orca will delete the managed Claude auth for this saved account. If it is currently + active, Orca falls back to the system default Claude login. + + + + + + + + + {visibleSections.map((section, index) => ( +
+ {index > 0 ? : null} + {section} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index c258da3a8d1..7c15c287725 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -2,17 +2,12 @@ splitting individual settings into separate files would scatter related controls without a meaningful abstraction boundary. */ import { useEffect, useRef, useState } from 'react' -import type { - ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState, - GlobalSettings -} from '../../../../shared/types' -import { Badge } from '../ui/badge' +import type { GlobalSettings } from '../../../../shared/types' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' -import { Download, FolderOpen, Loader2, Plus, RefreshCw, Star, Timer, Trash2 } from 'lucide-react' +import { Download, FolderOpen, Loader2, RefreshCw, Star, Timer } from 'lucide-react' import { useAppStore } from '../../store' import { CliSection } from './CliSection' import { toast } from 'sonner' @@ -23,13 +18,9 @@ import { } from '../../../../shared/constants' import { clampNumber } from '@/lib/terminal-theme' import { - GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES, - GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES, GENERAL_CACHE_TIMER_SEARCH_ENTRIES, GENERAL_CLI_SEARCH_ENTRIES, GENERAL_EDITOR_SEARCH_ENTRIES, - GENERAL_GEMINI_SEARCH_ENTRIES, - GENERAL_OPENCODE_SEARCH_ENTRIES, GENERAL_PANE_SEARCH_ENTRIES, GENERAL_SUPPORT_SEARCH_ENTRIES, GENERAL_UPDATE_SEARCH_ENTRIES, @@ -38,15 +29,6 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' -import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' export { GENERAL_PANE_SEARCH_ENTRIES } @@ -55,74 +37,9 @@ type GeneralPaneProps = { updateSettings: (updates: Partial) => void } -function getCodexAccountLabel( - state: CodexRateLimitAccountsState, - accountId: string | null | undefined -): string { - if (accountId == null) { - return 'System default' - } - return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' -} - -function getClaudeAccountLabel( - state: ClaudeRateLimitAccountsState, - accountId: string | null | undefined -): string { - if (accountId == null) { - return 'System default' - } - return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account' -} - -function getCodexAccountErrorDescription(error: unknown): string { - const message = String((error as Error)?.message ?? error) - .replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '') - .replace(/^Error invoking remote method 'codexAccounts:[^']+':\s*/i, '') - .replace(/^Error:\s*/i, '') - .trim() - const normalizedMessage = message.toLowerCase() - - // Why: Codex account actions cross the Electron IPC boundary, and invoke() - // failures often include transport-level wrapper text that is useful in - // devtools but noisy in product UI. Normalize the handful of expected auth - // failures here so users see actionable sign-in guidance instead of IPC - // internals or raw upstream wording. - if (normalizedMessage.includes('timed out waiting for codex login to finish')) { - return 'Codex sign-in took too long to finish. Please try again.' - } - if (normalizedMessage.includes('codex sign-in took too long to finish')) { - return 'Codex sign-in took too long to finish. Please try again.' - } - if ( - normalizedMessage.includes('auth error 502') || - normalizedMessage.includes('gateway') || - normalizedMessage.includes('bad gateway') - ) { - return 'Codex sign-in is temporarily unavailable. Please try again in a minute.' - } - if (normalizedMessage.startsWith('codex login failed:')) { - const loginMessage = message.slice('Codex login failed:'.length).trim() - return loginMessage || 'Codex sign-in failed. Please try again.' - } - - return message || 'Codex sign-in failed. Please try again.' -} - -function getClaudeAccountErrorDescription(error: unknown): string { - return ( - String((error as Error)?.message ?? error) - .replace(/^Error occurred in handler for 'claudeAccounts:[^']+':\s*/i, '') - .replace(/^Error invoking remote method 'claudeAccounts:[^']+':\s*/i, '') - .replace(/^Error:\s*/i, '') - .trim() || 'Claude sign-in failed. Please try again.' - ) -} - export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const updateStatus = useAppStore((s) => s.updateStatus) - const fetchSettings = useAppStore((s) => s.fetchSettings) // Why: the 'error' variant of UpdateStatus does not carry a `version` field. // The main process emits `{ state: 'error' }` for both check failures (no // version known yet) and download/install failures (version was known from @@ -151,22 +68,6 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState( String(settings.editorAutoSaveDelayMs) ) - const [codexAccounts, setCodexAccounts] = useState({ - accounts: [], - activeAccountId: null - }) - const [codexAction, setCodexAction] = useState< - 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` - >('idle') - const [claudeAccounts, setClaudeAccounts] = useState({ - accounts: [], - activeAccountId: null - }) - const [claudeAction, setClaudeAction] = useState< - 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` - >('idle') - const [removeAccountId, setRemoveAccountId] = useState(null) - const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) // Why: the star state is derived from gh, not from settings, so it does not // live in the global settings store. 'hidden' covers the gh-unavailable and // already-starred-on-a-previous-session cases so the section drops out for @@ -221,47 +122,6 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea setAutoSaveDelayDraft(String(settings.editorAutoSaveDelayMs)) }, [settings.editorAutoSaveDelayMs]) - useEffect(() => { - let stale = false - - const loadCodexAccounts = async (): Promise => { - try { - const nextCodex = await window.api.codexAccounts.list() - if (!stale) { - setCodexAccounts(nextCodex) - } - } catch (error) { - if (!stale) { - toast.error('Could not load Codex accounts.', { - description: String((error as Error)?.message ?? error) - }) - } - } - } - - const loadClaudeAccounts = async (): Promise => { - try { - const nextClaude = await window.api.claudeAccounts.list() - if (!stale) { - setClaudeAccounts(nextClaude) - } - } catch (error) { - if (!stale) { - toast.error('Could not load Claude accounts.', { - description: String((error as Error)?.message ?? error) - }) - } - } - } - - void loadCodexAccounts() - void loadClaudeAccounts() - - return () => { - stale = true - } - }, []) - const handleBrowseWorkspace = async () => { const path = await window.api.repos.pickFolder() if (path) { @@ -299,79 +159,6 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea void window.api.updater.quitAndInstall().catch(console.error) } - const syncCodexAccounts = async (next: CodexRateLimitAccountsState): Promise => { - setCodexAccounts(next) - await fetchSettings() - } - - const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise => { - setClaudeAccounts(next) - await fetchSettings() - } - - const formatAccountTimestamp = (timestamp: number): string => { - return new Date(timestamp).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) - } - - const runCodexAccountAction = async ( - action: typeof codexAction, - operation: () => Promise - ): Promise => { - const previousActiveAccountId = codexAccounts.activeAccountId - setCodexAction(action) - try { - const next = await operation() - await syncCodexAccounts(next) - const shouldPromptRestart = - action === 'adding' || - (action.startsWith('select:') && previousActiveAccountId !== next.activeAccountId) || - (action.startsWith('reauth:') && - next.activeAccountId !== null && - action === `reauth:${next.activeAccountId}`) || - (action.startsWith('remove:') && previousActiveAccountId !== next.activeAccountId) - if (shouldPromptRestart) { - void markLiveCodexSessionsForRestart({ - previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, next.activeAccountId) - }) - } - } catch (error) { - toast.error('Codex account update failed.', { - description: getCodexAccountErrorDescription(error) - }) - } finally { - setCodexAction('idle') - } - } - - const runClaudeAccountAction = async ( - action: typeof claudeAction, - operation: () => Promise - ): Promise => { - const previousActiveAccountId = claudeAccounts.activeAccountId - setClaudeAction(action) - try { - const next = await operation() - await syncClaudeAccounts(next) - if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { - toast.info('Claude account updated.', { - description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` - }) - } - } catch (error) { - toast.error('Claude account update failed.', { - description: getClaudeAccountErrorDescription(error) - }) - } finally { - setClaudeAction('idle') - } - } - const visibleSections = [ matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
@@ -670,477 +457,6 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea )}
) : null, - matchesSettingsSearch(searchQuery, GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES) ? ( -
-
-

Claude Accounts

-

- Add and switch Claude Code accounts without moving chat sessions to account-specific - config directories. -

-
- - -
-
- -

- Orca swaps Claude auth only; config and chat history stay in the shared Claude root. -

-
- -
- -
- - {claudeAccounts.accounts.length === 0 ? ( -
- No managed Claude accounts yet. Orca will use your system default Claude login until - you add one here. -
- ) : ( - claudeAccounts.accounts.map((account) => { - const isActive = claudeAccounts.activeAccountId === account.id - const isReauthing = claudeAction === `reauth:${account.id}` - const isBusy = claudeAction !== 'idle' - - return ( - - -
- - - ) - }) - )} - -
-
- ) : null, - matchesSettingsSearch(searchQuery, GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES) ? ( -
-
-

Codex Accounts

-

- Add and switch between Codex accounts in Orca. -

-

- Each account keeps its own local sign-in context in Orca. Account auth stays on this - device. -

-
- - - {/* Why: Settings deep-links can target this subsection directly from - the status-bar account switcher. Keeping a stable DOM anchor here - avoids dumping the user at the top of General and making them hunt - for the actual Codex account controls. */} -
-
- -

- Add a Codex account to use it in Orca. -

-
- -
- - {codexAccounts.accounts.length === 0 ? ( -
- No managed Codex accounts yet. Orca will use your system default Codex login until you - add one here. -
- ) : ( -
- - {codexAccounts.accounts.map((account) => { - const isActive = codexAccounts.activeAccountId === account.id - const isReauthing = codexAction === `reauth:${account.id}` - const isRemoving = codexAction === `remove:${account.id}` - const isBusy = codexAction !== 'idle' - - return ( - - -
- - - ) - })} - - )} -
-
- ) : null, - matchesSettingsSearch(searchQuery, GENERAL_GEMINI_SEARCH_ENTRIES) ? ( -
-
-

Gemini

-

Configure Gemini provider settings.

-
- - -
- -

- Extracts OAuth credentials from your local Gemini CLI installation to authenticate - with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break - if Google updates the CLI. Use at your own risk. -

-
- -
-
- ) : null, - matchesSettingsSearch(searchQuery, GENERAL_OPENCODE_SEARCH_ENTRIES) ? ( -
-
-

OpenCode Go

-

Configure OpenCode Go provider settings.

-
- - - -
- updateSettings({ opencodeSessionCookie: e.target.value })} - placeholder="Fe26.2**… token or auth=Fe26.2**… header" - spellCheck={false} - className="flex-1 text-xs" - /> - {settings.opencodeSessionCookie && ( - - )} -
-

- Paste either the raw token value (e.g. Fe26.2**…) or - the full cookie header (e.g. auth=Fe26.2**…). Find it - in your browser's DevTools → Network → any opencode.ai request → Cookie header. -

-
- - - -
- updateSettings({ opencodeWorkspaceId: e.target.value })} - placeholder="wrk_… (leave blank for automatic lookup)" - spellCheck={false} - className="flex-1 text-xs" - /> - {settings.opencodeWorkspaceId && ( - - )} -
-

- Find this in the URL after logging into opencode.ai (e.g.{' '} - opencode.ai/workspace/wrk_…/go). -

-
-
- ) : null, matchesSettingsSearch(searchQuery, GENERAL_UPDATE_SEARCH_ENTRIES) ? (
@@ -1261,74 +577,6 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea return (
- !open && setRemoveAccountId(null)} - > - - - Remove Codex Account? - - Orca will delete the managed Codex home for this saved account. If it is currently - active, Orca falls back to the system default Codex login. - - - - - - - - - !open && setRemoveClaudeAccountId(null)} - > - - - Remove Claude Account? - - Orca will delete the managed Claude auth for this saved account. If it is currently - active, Orca falls back to the system default Claude login. - - - - - - - - {visibleSections.map((section, index) => (
{index > 0 ? : null} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index a959430f611..293993ffe15 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -13,7 +13,8 @@ import { Server, SlidersHorizontal, Blocks, - SquareTerminal + SquareTerminal, + UserCog } from 'lucide-react' import type { OrcaHooks } from '../../../../shared/types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' @@ -36,6 +37,7 @@ import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './Notifica import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane' import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane' import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' +import { AccountsPane, ACCOUNTS_PANE_SEARCH_ENTRIES } from './AccountsPane' import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane' import { @@ -49,6 +51,7 @@ import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-sear type SettingsNavTarget = | 'general' | 'integrations' + | 'accounts' | 'browser' | 'git' | 'appearance' @@ -308,6 +311,13 @@ function Settings(): React.JSX.Element { icon: Bot, searchEntries: AGENTS_PANE_SEARCH_ENTRIES }, + { + id: 'accounts', + title: 'Agent Accounts', + description: 'Sign in and switch between Claude, Codex, Gemini, and OpenCode Go accounts.', + icon: UserCog, + searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES + }, { id: 'git', title: 'Git', @@ -592,6 +602,15 @@ function Settings(): React.JSX.Element { + + + + { openSettingsTarget({ - pane: 'general', + pane: 'accounts', repoId: null, - sectionId: 'general-claude-accounts' + sectionId: 'accounts-claude' }) openSettingsPage() }} @@ -513,9 +513,9 @@ function CodexSwitcherMenu({ { openSettingsTarget({ - pane: 'general', + pane: 'accounts', repoId: null, - sectionId: 'general-codex-accounts' + sectionId: 'accounts-codex' }) openSettingsPage() }} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 028187804d1..6e030613909 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -112,6 +112,7 @@ export type UISlice = { | 'shortcuts' | 'repo' | 'agents' + | 'accounts' | 'experimental' | 'ssh' repoId: string | null From 2329f5618065d944f25819490eb558d6a3b9515b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:48:24 -0700 Subject: [PATCH 041/259] Exit beta: SSH badge removal + always-on daemon PTY (#1253) Co-authored-by: Orca --- .../runtime-home-service.test.ts | 2 - src/main/codex-accounts/service.test.ts | 2 - src/main/daemon/daemon-init.test.ts | 302 ------------------ src/main/daemon/daemon-init.ts | 24 -- src/main/index.ts | 47 +-- src/main/ipc/app.ts | 28 -- src/main/ipc/pty.test.ts | 6 + src/preload/api-types.ts | 15 +- src/preload/index.ts | 4 +- src/renderer/src/App.tsx | 65 +--- .../components/settings/ExperimentalPane.tsx | 91 +----- .../src/components/settings/Settings.tsx | 4 +- .../settings/experimental-search.ts | 16 - .../terminal-pane/pty-connection.test.ts | 58 +--- .../terminal-pane/pty-connection.ts | 15 +- .../slices/store-session-cascades.test.ts | 9 +- src/renderer/src/store/slices/terminals.ts | 39 +-- src/shared/constants.ts | 2 - src/shared/types.ts | 10 - 19 files changed, 50 insertions(+), 689 deletions(-) delete mode 100644 src/main/daemon/daemon-init.test.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d5ce0b49525..3f4a4a883b8 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -92,8 +92,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false, experimentalAgentDashboard: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 8b9c340d532..92c69a275da 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -86,8 +86,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, - experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false, experimentalAgentDashboard: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts deleted file mode 100644 index b02a51ed76a..00000000000 --- a/src/main/daemon/daemon-init.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'os' -import { join } from 'path' -import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs' -import { createServer, type Server, type Socket } from 'net' -import type { SubprocessHandle } from './session' -import type * as DaemonInitModule from './daemon-init' - -const { getPathMock } = vi.hoisted(() => ({ - getPathMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { - getPath: getPathMock, - getAppPath: () => process.cwd(), - isPackaged: false - } -})) - -// Why: we want the real DaemonServer + DaemonClient but not electron-based -// subprocess spawning. createTestDaemon() wires a mock subprocess harness -// compatible with daemon-spawner.test.ts. -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb - } - } -} - -async function importFreshDaemonInit(): Promise { - vi.resetModules() - return import('./daemon-init') -} - -function writeNdjson(socket: Socket, message: unknown): void { - socket.write(`${JSON.stringify(message)}\n`) -} - -async function startLegacyDaemonStub( - socketPath: string, - tokenPath: string, - protocolVersion = 1 -): Promise<{ shutdown: () => Promise; shutdownRequested: () => boolean }> { - mkdirSync(join(tokenPath, '..'), { recursive: true }) - writeFileSync(tokenPath, 'legacy-token', { mode: 0o600 }) - let shutdownRequested = false - - const server = createServer((socket) => { - let buffer = '' - let greeted = false - socket.on('data', (chunk) => { - buffer += chunk.toString() - for (;;) { - const newlineIdx = buffer.indexOf('\n') - if (newlineIdx === -1) { - break - } - const line = buffer.slice(0, newlineIdx) - buffer = buffer.slice(newlineIdx + 1) - const message = JSON.parse(line) - if (!greeted) { - greeted = true - expect(message).toMatchObject({ - type: 'hello', - version: protocolVersion, - token: 'legacy-token' - }) - writeNdjson(socket, { type: 'hello', ok: true }) - continue - } - if (message.type === 'listSessions') { - writeNdjson(socket, { - id: message.id, - ok: true, - payload: { sessions: [{ sessionId: 'legacy', isAlive: true }] } - }) - } else if (message.type === 'shutdown') { - shutdownRequested = true - writeNdjson(socket, { id: message.id, ok: true, payload: {} }) - setTimeout(() => { - socket.destroy() - server.close(() => { - if (process.platform !== 'win32') { - try { - unlinkSync(socketPath) - } catch { - // Best-effort - } - } - }) - }, 0) - } - } - }) - }) - - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, () => { - server.off('error', reject) - resolve() - }) - }) - - return { - shutdown: () => closeServer(server), - shutdownRequested: () => shutdownRequested - } -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => { - if (!server.listening) { - resolve() - return - } - server.close(() => resolve()) - }) -} - -describe('cleanupOrphanedDaemon', () => { - let userDataDir: string - - beforeEach(() => { - userDataDir = mkdtempSync(join(tmpdir(), 'daemon-init-test-')) - getPathMock.mockImplementation(() => userDataDir) - }) - - afterEach(() => { - rmSync(userDataDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('returns cleaned=false when no daemon socket exists', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - - const result = await cleanupOrphanedDaemon() - expect(result.cleaned).toBe(false) - expect(result.killedCount).toBe(0) - }) - - it('removes stale pid files when no daemon socket exists', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath } = await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const pidPath = getDaemonPidPath(runtimeDir) - writeFileSync(pidPath, '999999', { mode: 0o600 }) - - const result = await cleanupOrphanedDaemon() - - expect(result.cleaned).toBe(false) - expect(existsSync(pidPath)).toBe(false) - }) - - it('kills live sessions and shuts down a running daemon', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { DaemonSpawner, getDaemonSocketPath } = await import('./daemon-spawner') - const { startDaemon } = await import('./daemon-main') - const { DaemonClient } = await import('./client') - - const runtimeDir = join(userDataDir, 'daemon') - const { mkdirSync } = await import('fs') - mkdirSync(runtimeDir, { recursive: true }) - - // Spin up a real daemon exactly where cleanupOrphanedDaemon will look. - const daemonHandles: { shutdown: () => Promise }[] = [] - const spawner = new DaemonSpawner({ - runtimeDir, - launcher: async (socketPath, tokenPath) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - daemonHandles.push(handle) - return { shutdown: () => handle.shutdown() } - } - }) - const info = await spawner.ensureRunning() - - // Create two sessions so killedCount is non-zero. - const client = new DaemonClient({ - socketPath: info.socketPath, - tokenPath: info.tokenPath - }) - await client.ensureConnected() - await client.request('createOrAttach', { sessionId: 'a', cols: 80, rows: 24 }) - await client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) - client.disconnect() - - // Now the daemon looks "orphaned" from cleanupOrphanedDaemon's POV. - const result = await cleanupOrphanedDaemon() - expect(result.cleaned).toBe(true) - expect(result.killedCount).toBeGreaterThanOrEqual(2) - - // Socket file should be gone so a later opt-in relaunch can bind cleanly. - if (process.platform !== 'win32') { - expect(existsSync(getDaemonSocketPath(runtimeDir))).toBe(false) - } - - // Best-effort teardown of any surviving handles from the spawner side. - for (const handle of daemonHandles) { - await handle.shutdown().catch(() => {}) - } - }) - - it('cleans up previous protocol daemons after a protocol bump', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const legacySocketPath = getDaemonSocketPath(runtimeDir, 1) - const legacyTokenPath = getDaemonTokenPath(runtimeDir, 1) - const legacyPidPath = getDaemonPidPath(runtimeDir, 1) - writeFileSync(legacyPidPath, String(process.pid), { mode: 0o600 }) - const legacyDaemon = await startLegacyDaemonStub(legacySocketPath, legacyTokenPath) - - try { - const result = await cleanupOrphanedDaemon() - - expect(result).toEqual({ cleaned: true, killedCount: 1 }) - expect(legacyDaemon.shutdownRequested()).toBe(true) - expect(existsSync(legacyPidPath)).toBe(false) - } finally { - await legacyDaemon.shutdown() - } - }) - - it('cleans up v2 daemon sessions when daemon mode is disabled', async () => { - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const v2SocketPath = getDaemonSocketPath(runtimeDir, 2) - const v2TokenPath = getDaemonTokenPath(runtimeDir, 2) - const v2PidPath = getDaemonPidPath(runtimeDir, 2) - writeFileSync(v2PidPath, String(process.pid), { mode: 0o600 }) - const v2Daemon = await startLegacyDaemonStub(v2SocketPath, v2TokenPath, 2) - - try { - const result = await cleanupOrphanedDaemon() - - expect(result).toEqual({ cleaned: true, killedCount: 1 }) - expect(v2Daemon.shutdownRequested()).toBe(true) - expect(existsSync(v2PidPath)).toBe(false) - } finally { - await v2Daemon.shutdown() - } - }) - - it('does not report cleaned when fallback cleanup preserves an unowned live socket', async () => { - if (process.platform === 'win32') { - return - } - - const { cleanupOrphanedDaemon } = await importFreshDaemonInit() - const { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } = - await import('./daemon-spawner') - - const runtimeDir = join(userDataDir, 'daemon') - mkdirSync(runtimeDir, { recursive: true }) - const socketPath = getDaemonSocketPath(runtimeDir) - const tokenPath = getDaemonTokenPath(runtimeDir) - const server = createServer((socket) => { - socket.once('data', () => socket.end('not daemon protocol\n')) - }) - writeFileSync(tokenPath, 'bad-token', { mode: 0o600 }) - writeFileSync(getDaemonPidPath(runtimeDir), String(process.pid), { mode: 0o600 }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, () => { - server.off('error', reject) - resolve() - }) - }) - - try { - const result = await cleanupOrphanedDaemon() - - expect(result).toEqual({ cleaned: false, killedCount: 0 }) - expect(existsSync(socketPath)).toBe(true) - } finally { - await closeServer(server) - } - }) -}) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 329cc6dd2d7..7a0cad3ef3e 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -343,27 +343,3 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise { - const runtimeDir = getRuntimeDir() - let cleaned = false - let killedCount = 0 - - for (const protocolVersion of [PROTOCOL_VERSION, ...PREVIOUS_DAEMON_PROTOCOL_VERSIONS]) { - const result = await cleanupDaemonForProtocol(runtimeDir, protocolVersion) - cleaned ||= result.cleaned - killedCount += result.killedCount - } - - return { cleaned, killedCount } -} diff --git a/src/main/index.ts b/src/main/index.ts index ab60f0fac25..e73131da3c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,12 +11,8 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - cleanupOrphanedDaemon -} from './daemon/daemon-init' -import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app' +import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' +import { setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { triggerStartupNotificationRegistration } from './ipc/notifications' @@ -397,40 +393,15 @@ app.whenReady().then(async () => { userDataPath: app.getPath('userData') }) - // Why: persistent terminal sessions (the out-of-process daemon) are gated - // behind an experimental setting that defaults to OFF. Users on v1.3.0 had - // the daemon on by default, so on upgrade we may need to clean up a live - // daemon from their previous session before continuing with the local - // provider. `registerPtyHandlers` (called inside openMainWindow) relies on - // the provider being set, so whichever branch runs must complete first. - const daemonEnabled = store.getSettings().experimentalTerminalDaemon === true - let daemonStarted = false - if (daemonEnabled) { - // Why: catch so the app still opens even if the daemon fails. The local - // PTY provider remains as the fallback — terminals will still work, just - // without cross-restart persistence. - try { - await initDaemonPtyProvider() - daemonStarted = true - } catch (error) { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) - } - } else { - // Why: stash the cleanup result so the renderer's one-shot transition - // toast can tell the user how many background sessions were stopped. Only - // record when `cleaned: true` — i.e. an orphan daemon was actually found. - // Fresh installs (no socket) skip the toast entirely. - try { - const result = await cleanupOrphanedDaemon() - if (result.cleaned) { - recordPendingDaemonTransitionNotice({ killedCount: result.killedCount }) - } - } catch (error) { - console.error('[daemon] Failed to clean up orphaned daemon:', error) - } + // Why: the persistent-terminal daemon is always started. If it fails, the + // LocalPtyProvider (initialized at module load in ipc/pty.ts) remains as the + // implicit fallback — terminals work, just without cross-restart persistence. + try { + await initDaemonPtyProvider() + } catch (error) { + console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) } setAppRuntimeFlags({ - daemonEnabledAtStartup: daemonStarted, agentDashboardEnabledAtStartup: agentDashboardEnabled }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 4910184bf23..10c51f19490 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -6,10 +6,6 @@ import { isWslAvailable } from '../wsl' const execFileAsync = promisify(execFile) export type AppRuntimeFlags = { - /** Whether the persistent terminal daemon was actually started this session. - * The renderer compares this against the current setting to decide whether - * a "restart required" banner needs to be shown on the Experimental pane. */ - daemonEnabledAtStartup: boolean /** Whether the experimental agent dashboard setting was enabled when this * session booted. When true, Claude/Codex/Gemini managed hook installation * was attempted at startup (individual install failures are logged but do @@ -20,41 +16,17 @@ export type AppRuntimeFlags = { agentDashboardEnabledAtStartup: boolean } -export type DaemonTransitionNotice = { - /** Number of live daemon PTY sessions that were killed when the app booted - * with `experimentalTerminalDaemon: false` but discovered a leftover daemon - * from a previous session. Non-zero values are surfaced in a one-shot - * toast so the user knows background work was stopped. */ - killedCount: number -} - let runtimeFlags: AppRuntimeFlags = { - daemonEnabledAtStartup: false, agentDashboardEnabledAtStartup: false } -let pendingDaemonTransitionNotice: DaemonTransitionNotice | null = null export function setAppRuntimeFlags(flags: AppRuntimeFlags): void { runtimeFlags = flags } -export function recordPendingDaemonTransitionNotice(notice: DaemonTransitionNotice): void { - pendingDaemonTransitionNotice = notice -} - export function registerAppHandlers(): void { ipcMain.handle('app:getRuntimeFlags', (): AppRuntimeFlags => runtimeFlags) - ipcMain.handle('app:consumeDaemonTransitionNotice', (): DaemonTransitionNotice | null => { - // Why: one-shot consumption — clear after reading so the renderer's - // post-hydration effect can't fire the same toast twice (e.g. after a - // window reload during dev). The persisted `experimentalTerminalDaemonNoticeShown` - // flag is the cross-session guard; this clear handles within-session races. - const notice = pendingDaemonTransitionNotice - pendingDaemonTransitionNotice = null - return notice - }) - ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 91fdfe1b78e..8e34cb0f8ea 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -112,8 +112,11 @@ describe('registerPtyHandlers', () => { } } + const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR + beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR + delete process.env.PI_CODING_AGENT_DIR handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -170,6 +173,9 @@ describe('registerPtyHandlers', () => { afterEach(() => { unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) + if (savedPiAgentDir !== undefined) { + process.env.PI_CODING_AGENT_DIR = savedPiAgentDir + } }) function createMockProc() { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b6ccff97b76..f20d74a1ca3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -272,25 +272,14 @@ export type CodexUsageApi = { } export type AppRuntimeFlags = { - daemonEnabledAtStartup: boolean agentDashboardEnabledAtStartup: boolean } -export type DaemonTransitionNotice = { - killedCount: number -} - export type AppApi = { - /** Returns flags about the main-process state that was set at startup - * (e.g. whether the persistent terminal daemon actually started). The - * renderer uses this to show a "restart required" banner when the user + /** Returns flags about the main-process state that was set at startup. + * The renderer uses this to show a "restart required" banner when the user * toggles a setting that only applies across a full relaunch. */ getRuntimeFlags: () => Promise - /** Reads and clears any pending one-shot notice about a daemon cleanup - * that ran during startup (e.g. when upgrading from v1.3.0 where the - * daemon was on by default to a build where it's opt-in). Returns null - * when there is nothing to show. */ - consumeDaemonTransitionNotice: () => Promise /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used * by the "Restart now" button on the Experimental settings pane. */ relaunch: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f211369fb06..f85c5f35949 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,7 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' -import type { AppRuntimeFlags, DaemonTransitionNotice } from './api-types' +import type { AppRuntimeFlags } from './api-types' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { @@ -176,8 +176,6 @@ document.addEventListener( const api = { app: { getRuntimeFlags: (): Promise => ipcRenderer.invoke('app:getRuntimeFlags'), - consumeDaemonTransitionNotice: (): Promise => - ipcRenderer.invoke('app:consumeDaemonTransitionNotice'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a1b3c4d600d..0f21ee4d252 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -204,10 +204,8 @@ function App(): React.JSX.Element { const persistedUI = await window.api.ui.get() const session = await window.api.session.get() // Why: settings must be loaded before hydrateWorkspaceSession so that - // it can read experimentalTerminalDaemon to decide whether to stage - // pendingReconnectPtyIdByTabId. Without this, opted-in daemon users - // would silently lose session reattach on every launch because - // s.settings would still be null at hydration time. + // hydration has access to user preferences. Without this, settings + // would still be null at hydration time. await actions.fetchSettings() if (!cancelled) { actions.hydratePersistedUI(persistedUI) @@ -497,65 +495,6 @@ function App(): React.JSX.Element { return () => document.removeEventListener('visibilitychange', handler) }, [actions]) - // Why: v1.3.0 shipped the persistent-terminal daemon ON by default. v1.3.1+ - // defaults it OFF and gates it behind an Experimental toggle. On the first - // launch after that upgrade, main detects a still-running daemon, shuts it - // down (killing any surviving `sleep 9999`-style sessions), and stashes a - // one-shot notice. We consume that notice here and inform the user so their - // vanished sessions don't look like a bug. The renderer-side - // `experimentalTerminalDaemonNoticeShown` flag guarantees the toast fires at - // most once per install, even if main stashes a notice again on a later - // launch. - const transitionNoticeHandledRef = useRef(false) - useEffect(() => { - if (!settings || transitionNoticeHandledRef.current) { - return - } - if (settings.experimentalTerminalDaemonNoticeShown) { - transitionNoticeHandledRef.current = true - return - } - transitionNoticeHandledRef.current = true - void (async () => { - let notice: { killedCount: number } | null = null - try { - notice = await window.api.app.consumeDaemonTransitionNotice() - } catch { - // Informational only — if the IPC fails, don't fire the toast and - // don't flip the "shown" flag so we can retry on next launch. - return - } - if (!notice) { - return - } - const killedCount = notice.killedCount - const killedClause = - killedCount > 0 - ? ` Cleaned up ${killedCount} background session${killedCount === 1 ? '' : 's'} from the previous version.` - : '' - toast.info('Persistent terminal sessions are now opt-in.', { - description: `${killedClause} You can re-enable them in Settings → Experimental.`.trim(), - duration: 15000, - action: { - label: 'Open settings', - onClick: () => { - useAppStore.getState().openSettingsTarget({ - pane: 'experimental', - repoId: null - }) - useAppStore.getState().openSettingsPage() - } - } - }) - try { - await actions.updateSettings({ experimentalTerminalDaemonNoticeShown: true }) - } catch { - // If persistence fails, the toast may re-fire on a later launch — - // acceptable tradeoff vs. silently dropping the notification. - } - })() - }, [actions, settings]) - const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : [] const hasTabBar = tabs.length >= 2 const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index c0545eb7eb6..724a36d4e63 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -58,7 +58,6 @@ export function ExperimentalPane({ // directly from main once on mount. Each banner compares the user's current // setting against this snapshot to tell them a restart is still required. // null = not yet fetched (banner stays hidden to avoid a flash). - const [daemonEnabledAtStartup, setDaemonEnabledAtStartup] = useState(null) const [agentDashboardEnabledAtStartup, setAgentDashboardEnabledAtStartup] = useState< boolean | null >(null) @@ -70,7 +69,6 @@ export function ExperimentalPane({ .getRuntimeFlags() .then((flags) => { if (!cancelled) { - setDaemonEnabledAtStartup(flags.daemonEnabledAtStartup) setAgentDashboardEnabledAtStartup(flags.agentDashboardEnabledAtStartup) } }) @@ -82,12 +80,11 @@ export function ExperimentalPane({ } }, []) - const showDaemon = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]]) const showAgentDashboard = matchesSettingsSearch(searchQuery, [ - EXPERIMENTAL_PANE_SEARCH_ENTRIES[1] + EXPERIMENTAL_PANE_SEARCH_ENTRIES[0] ]) const showOrchestration = matchesSettingsSearch(searchQuery, [ - EXPERIMENTAL_PANE_SEARCH_ENTRIES[2] + EXPERIMENTAL_PANE_SEARCH_ENTRIES[1] ]) const [orchestrationEnabled, setOrchestrationEnabled] = useState(() => { @@ -117,9 +114,6 @@ export function ExperimentalPane({ } } - const pendingDaemonRestart = - daemonEnabledAtStartup !== null && - settings.experimentalTerminalDaemon !== daemonEnabledAtStartup const pendingAgentDashboardRestart = agentDashboardEnabledAtStartup !== null && settings.experimentalAgentDashboard !== agentDashboardEnabledAtStartup @@ -138,78 +132,6 @@ export function ExperimentalPane({ return (
- {showDaemon ? ( - -
-
- -

- Keeps terminals alive in a background daemon so they survive app restarts, with full - scrollback. Experimental — some sessions may become unresponsive after internal - state drift. Requires an app restart to take effect. -

-
- -
- - {pendingDaemonRestart ? ( -
-
-

- Restart required -

-

- {settings.experimentalTerminalDaemon - ? 'Restart Orca to start the background session daemon.' - : 'Restart Orca to stop the background session daemon. Any running background sessions will be closed.'} -

-
- -
- ) : null} -
- ) : null} - {showAgentDashboard ? ( Agent dashboard

Adds a cross-worktree dashboard and hover cards showing each agent's live - status. Requires an app restart - {settings.experimentalTerminalDaemon - ? ', and tracks agents started in new terminals opened after the restart' - : ''} - . + status. Requires an app restart, and tracks agents started in new terminals opened + after the restart.

@@ -294,7 +213,7 @@ export function ExperimentalPane({
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 293993ffe15..de5da1c32a6 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -390,8 +390,7 @@ function Settings(): React.JSX.Element { title: 'SSH', description: 'Remote SSH connections.', icon: Server, - searchEntries: SSH_PANE_SEARCH_ENTRIES, - badge: 'Beta' + searchEntries: SSH_PANE_SEARCH_ENTRIES }, { id: 'experimental', @@ -716,7 +715,6 @@ function Settings(): React.JSX.Element { diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index dd5f6f67153..b034ac4f4d0 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -1,22 +1,6 @@ import type { SettingsSearchEntry } from './settings-search' export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Persistent terminal sessions', - description: - 'Keeps terminal sessions alive across app restarts via a background daemon. Experimental — some sessions may become unresponsive.', - keywords: [ - 'experimental', - 'terminal', - 'daemon', - 'persistent', - 'background', - 'sessions', - 'restart', - 'scrollback', - 'reattach' - ] - }, { title: 'Agent dashboard', description: 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 be93fc0b21d..1eacee1a982 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -14,7 +14,7 @@ type StoreState = { repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map cacheTimerByKey: Record - settings: { promptCacheTimerEnabled?: boolean; experimentalTerminalDaemon?: boolean } | null + settings: { promptCacheTimerEnabled?: boolean } | null codexRestartNoticeByPtyId: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -529,8 +529,7 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, settings: { - ...mockStoreState.settings, - experimentalTerminalDaemon: true + ...mockStoreState.settings } } as StoreState const pane = createPane(2) @@ -570,8 +569,7 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, settings: { - ...mockStoreState.settings, - experimentalTerminalDaemon: true + ...mockStoreState.settings } } as StoreState const pane = createPane(2) @@ -615,8 +613,7 @@ describe('connectPanePty', () => { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] }, settings: { - ...mockStoreState.settings, - experimentalTerminalDaemon: true + ...mockStoreState.settings } } as StoreState @@ -645,7 +642,7 @@ describe('connectPanePty', () => { ) }) - it('reuses the existing local PTY on split remount when the daemon is disabled', async () => { + it('reattaches via daemon sessionId when an in-session PTY is live', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -656,47 +653,7 @@ describe('connectPanePty', () => { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-local-detached' }] }, settings: { - ...mockStoreState.settings, - // Why: with the daemon off, split/remount should still keep the - // in-process PTY alive within the same app session. This regression - // came from treating every remount like a daemon session reattach. - experimentalTerminalDaemon: false - } - } as StoreState - - const pane = createPane(2) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: 'pane:2', - restoredPtyIdByLeafId: { 'pane:2': 'pty-local-detached' } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'pty-local-detached' }) - ) - expect(transport.connect).not.toHaveBeenCalled() - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') - expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-local-detached') - }) - - it('reattaches via daemon sessionId when the daemon is enabled and an in-session PTY is live', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'pty-local-detached' }] - }, - settings: { - ...mockStoreState.settings, - // Why: complement of the daemon-off case — with the daemon on, the - // in-session remount path must go through connect({sessionId}) so - // the daemon's createOrAttach runs at the pane's real dimensions. - experimentalTerminalDaemon: true + ...mockStoreState.settings } } as StoreState @@ -745,8 +702,7 @@ describe('connectPanePty', () => { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-restarted' }] }, settings: { - ...mockStoreState.settings, - experimentalTerminalDaemon: true + ...mockStoreState.settings } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8bdc66c73ac..71fdaadc712 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -713,17 +713,6 @@ export function connectPanePty( (t) => t.id === deps.tabId )?.ptyId - const daemonEnabled = storeSnapshot.settings?.experimentalTerminalDaemon === true - // Why: restored leaf PTYs usually come from a previous app session, so - // they normally go back through the daemon's createOrAttach RPC to - // recover snapshot or cold-restore data at the pane's real dimensions. - // But split remounts in the current app session also carry a leaf binding - // in the saved layout. When the daemon is off, treating that live local - // PTY like a daemon session ID incorrectly spawns a fresh shell because - // LocalPtyProvider ignores sessionId. The reliable distinction is whether - // the tab still owns that PTY right now: same-session remounts keep the - // tab-level ptyId populated, while daemon-off cold starts clear it during - // session hydration. const restoredSessionId = restoredPtyId ?? null const detachedLivePtyId = existingPtyId && !hasExistingPaneTransport @@ -736,9 +725,7 @@ export function connectPanePty( const candidateReattachSessionId = restoredSessionId && restoredSessionId !== detachedLivePtyId ? restoredSessionId - : daemonEnabled - ? detachedLivePtyId - : null + : detachedLivePtyId // Why: daemon session IDs encode `${worktreeId}@@${uuid}`. After a daemon // crash + cold restore, corrupted or stale session-to-tab mappings can // cause a tab in workspace A to hold a ptyId from workspace B. Restoring diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 46db35c64c7..530b3d38af4 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -908,14 +908,7 @@ describe('reconnectPersistedTerminals', () => { // otherwise hydration clears pendingReconnectPtyIdByTabId and tab.ptyId // never gets rehydrated. function createDaemonEnabledStore(): ReturnType { - const store = createTestStore() - store.setState((prev) => ({ - settings: { - ...(prev.settings ?? ({} as AppState['settings'])), - experimentalTerminalDaemon: true - } as AppState['settings'] - })) - return store + return createTestStore() } beforeEach(() => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 89165238aa7..803c7ebfaee 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1419,35 +1419,26 @@ export const createTerminalSlice: StateCreator // Why: preserve the previous session's ptyId for each tab so that // reconnectPersistedTerminals can pass it as sessionId to the daemon's // createOrAttach RPC, triggering reattach instead of a fresh spawn. - // When the experimental daemon is disabled, the LocalPtyProvider will - // ignore any sessionId we pass anyway — populating this map just - // persists stale daemon-era session IDs into the next session save, - // which confuses debugging and bloats the session file. Skip it. - const daemonEnabled = s.settings?.experimentalTerminalDaemon === true const pendingReconnectPtyIdByTabId: Record = {} - if (daemonEnabled) { - for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = Object.values(s.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null - if (repo?.connectionId) { - continue - } - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - for (const tab of rawTabs) { - if (tab.ptyId && validTabIds.has(tab.id)) { - pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId - } + for (const worktreeId of pendingReconnectWorktreeIds) { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null + if (repo?.connectionId) { + continue + } + const rawTabs = session.tabsByWorktree[worktreeId] ?? [] + for (const tab of rawTabs) { + if (tab.ptyId && validTabIds.has(tab.id)) { + pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId } } } - // Why: this runs outside the daemonEnabled guard because remote PTY - // reattach uses the relay's pty.attach RPC, not the local terminal - // daemon. SSH-backed tabs need their session IDs regardless of the - // experimentalTerminalDaemon setting. The existing loop above correctly - // skips SSH repos (connectionId check), so there is no overlap. + // Why: remote PTY reattach uses the relay's pty.attach RPC, not the + // local terminal daemon. The loop above correctly skips SSH repos + // (connectionId check), so there is no overlap. console.warn( `[terminals-hydration] remoteSessionIdsByTabId:`, JSON.stringify(remoteSessionIds) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a0ead7d4a5e..5ab06f31a58 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -184,8 +184,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // the box (issue #903) while US users keep Option-as-Alt readline chords. terminalMacOptionAsAlt: 'auto', terminalMacOptionAsAltMigrated: false, - experimentalTerminalDaemon: false, - experimentalTerminalDaemonNoticeShown: false, // Why: opt-in preview — default off so managed-hook installation // (Claude/Codex/Gemini) stays dormant for existing users and upgraders // (persistence.ts merges defaults first, so upgraders inherit this). diff --git a/src/shared/types.ts b/src/shared/types.ts index 96bef5728a6..9b22a1f3a95 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1014,16 +1014,6 @@ export type GlobalSettings = { * detection, so no visible behavior change. Then we flip this flag to true * and never migrate again. */ terminalMacOptionAsAltMigrated: boolean - /** Experimental: persist terminal sessions across app restarts via an - * out-of-process daemon (src/main/daemon/**). Opt-in because the daemon - * protocol is still stabilizing — some sessions have been observed to go - * unresponsive after internal state drift. Disabled sessions fall back to - * the in-process LocalPtyProvider. Requires an app restart to apply. */ - experimentalTerminalDaemon: boolean - /** One-shot flag for the "persistent sessions are now opt-in" transition - * toast shown to users upgrading from v1.3.0 (where the daemon was on by - * default). Set to true the first time the toast fires so it never repeats. */ - experimentalTerminalDaemonNoticeShown: boolean /** Experimental: live Agent Dashboard — a bottom-docked right-sidebar panel * that aggregates working/blocked/done agents across all worktrees, plus * the sidebar AgentStatusHover surface, retention of "done" rows, and the From 89f41bfda2468136a5b48c1c2efbef217e63f4fb Mon Sep 17 00:00:00 2001 From: mcd77 <126714281+mcd77@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:29:53 -0500 Subject: [PATCH 042/259] feat(hooks): gate orca.yaml execution on trust Audited and rebased PR 1138. Squashed to remove the original untrusted commit stack; hook execution now requires explicit UI trust or CLI --run-hooks opt-in. --- src/cli/handlers/worktree.ts | 6 +- src/cli/help.ts | 4 +- src/cli/specs/core.ts | 14 +- src/main/ipc/worktrees.test.ts | 49 +++++ src/main/ipc/worktrees.ts | 4 +- src/main/runtime/orca-runtime.test.ts | 97 ++++++++- src/main/runtime/orca-runtime.ts | 17 +- src/main/runtime/rpc/methods/worktree.ts | 15 +- src/preload/api-types.ts | 2 +- src/preload/index.ts | 2 +- .../src/components/sidebar/AddRepoSteps.tsx | 3 + .../sidebar/OrcaYamlTrustDialog.tsx | 107 ++++++++++ src/renderer/src/components/sidebar/index.tsx | 2 + src/renderer/src/hooks/useComposerState.ts | 47 +++-- .../src/lib/ensure-hooks-confirmed.test.ts | 186 ++++++++++++++++++ .../src/lib/ensure-hooks-confirmed.ts | 69 +++++++ .../src/lib/launch-work-item-direct.ts | 23 ++- src/renderer/src/lib/orca-hook-trust.ts | 13 ++ src/renderer/src/store/slices/repos.ts | 8 + src/renderer/src/store/slices/ui.ts | 61 +++++- .../src/store/slices/worktrees.test.ts | 6 + src/renderer/src/store/slices/worktrees.ts | 7 +- src/shared/constants.ts | 3 +- src/shared/types.ts | 14 ++ 24 files changed, 702 insertions(+), 57 deletions(-) create mode 100644 src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx create mode 100644 src/renderer/src/lib/ensure-hooks-confirmed.test.ts create mode 100644 src/renderer/src/lib/ensure-hooks-confirmed.ts create mode 100644 src/renderer/src/lib/orca-hook-trust.ts diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 4775353df16..3f24cfdd30e 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -46,7 +46,8 @@ export const WORKTREE_HANDLERS: Record = { name: getRequiredStringFlag(flags, 'name'), baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment') + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true }) printResult(result, json, formatWorktreeShow) }, @@ -62,7 +63,8 @@ export const WORKTREE_HANDLERS: Record = { 'worktree rm': async ({ flags, client, cwd, json }) => { const result = await client.call<{ removed: boolean }>('worktree.rm', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), - force: flags.get('force') === true + force: flags.get('force') === true, + runHooks: flags.get('run-hooks') === true }) printResult(result, json, (value) => `removed: ${value.removed}`) } diff --git a/src/cli/help.ts b/src/cli/help.ts index 864d5e5267e..2e8f6ddb9f5 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -115,11 +115,11 @@ Common Commands: orca open [--json] orca status [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] - orca worktree rm --worktree [--force] [--json] + orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] orca terminal show [--terminal ] [--json] diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 95f8f66f061..ca40b6bd846 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -72,9 +72,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment'], - notes: ['By default this matches the Orca UI flow and activates the new worktree in the app.'] + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'], + notes: [ + 'By default this matches the Orca UI flow and activates the new worktree in the app.', + 'Repo-defined orca.yaml hooks are skipped unless --run-hooks is passed.' + ] }, { path: ['worktree', 'set'], @@ -86,8 +89,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ { path: ['worktree', 'rm'], summary: 'Remove a worktree from Orca and git', - usage: 'orca worktree rm --worktree [--force] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force'] + usage: 'orca worktree rm --worktree [--force] [--run-hooks] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'], + notes: ['Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.'] }, { path: ['worktree', 'ps'], diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f02e8c75624..0bbba1a6a48 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -524,6 +524,55 @@ describe('registerWorktreeHandlers', () => { }) }) + it('runs the archive hook on remove when skipArchive is not set', async () => { + listWorktreesMock.mockResolvedValue([]) + removeWorktreeMock.mockResolvedValue(undefined) + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + runHookMock.mockResolvedValue({ success: true, output: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(runHookMock).toHaveBeenCalledWith( + 'archive', + '/workspace/feature-wt', + expect.objectContaining({ id: 'repo-1' }) + ) + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false + ) + }) + + it('skips the archive hook on remove when skipArchive is true', async () => { + listWorktreesMock.mockResolvedValue([]) + removeWorktreeMock.mockResolvedValue(undefined) + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + runHookMock.mockResolvedValue({ success: true, output: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + skipArchive: true + }) + + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false + ) + }) + it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { getEffectiveHooksMock.mockReturnValue({ scripts: { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index fc2fba57383..141b4c155ad 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -249,7 +249,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store ipcMain.handle( 'worktrees:remove', - async (_event, args: { worktreeId: string; force?: boolean }) => { + async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => { const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) const repo = store.getRepo(repoId) if (!repo) { @@ -273,7 +273,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store // Run archive hook before removal const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.archive) { + if (hooks?.scripts.archive && !args.skipArchive) { const result = await runHook('archive', worktreePath, repo) if (!result.success) { console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 310a20c76c1..3a50b194e84 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/types' -import { addWorktree, listWorktrees } from '../git/worktree' +import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' @@ -9,6 +9,7 @@ import { OrcaRuntimeService } from './orca-runtime' const { MOCK_GIT_WORKTREES, addWorktreeMock, + removeWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, invalidateAuthorizedRootsCacheMock @@ -23,6 +24,7 @@ const { } ], addWorktreeMock: vi.fn(), + removeWorktreeMock: vi.fn(), computeWorktreePathMock: vi.fn(), ensurePathWithinWorkspaceMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn() @@ -30,7 +32,8 @@ const { vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), - addWorktree: addWorktreeMock + addWorktree: addWorktreeMock, + removeWorktree: removeWorktreeMock })) vi.mock('../hooks', () => ({ @@ -70,6 +73,7 @@ vi.mock('../git/repo', async (importOriginal) => { afterEach(() => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() + vi.mocked(removeWorktree).mockReset() vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(runHook).mockReset() @@ -899,7 +903,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) - it('returns a setup launch payload for CLI-created worktrees when orca.yaml defines setup', async () => { + it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() runtime.setNotifier({ @@ -940,7 +944,8 @@ describe('OrcaRuntimeService', () => { const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', - name: 'runtime-hook-test' + name: 'runtime-hook-test', + runHooks: true }) expect(createSetupRunnerScript).toHaveBeenCalledWith( @@ -973,6 +978,90 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) + it('skips setup hooks for CLI-created worktrees by default', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-hook-skip', + head: 'def', + branch: 'runtime-hook-skip', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-skip' + }) + + expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: expect.objectContaining({ + repoId: 'repo-1', + path: '/tmp/workspaces/runtime-hook-skip', + branch: 'runtime-hook-skip' + }) + }) + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) + }) + + it('skips archive hooks for CLI worktree removal by default', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + }) + + it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { + const runtime = new OrcaRuntimeService(store) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + archive: 'pnpm worktree:archive' + } + }) + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(removeWorktree).mockResolvedValue(undefined) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true) + + expect(runHook).toHaveBeenCalledWith( + 'archive', + TEST_WORKTREE_PATH, + expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH }) + ) + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + }) + it('invalidates the filesystem-auth cache after CLI worktree creation', async () => { // Reproduces: CLI-created worktrees fail with "Access denied: unknown // repository or worktree path" because the filesystem-auth cache was diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b221afcff76..216f21e17f4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -949,6 +949,7 @@ export class OrcaRuntimeService { baseBranch?: string linkedIssue?: number | null comment?: string + runHooks?: boolean }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -1035,7 +1036,7 @@ export class OrcaRuntimeService { let setup: CreateWorktreeResult['setup'] const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.setup) { + if (hooks?.scripts.setup && args.runHooks === true) { if (this.authoritativeWindowId !== null) { try { // Why: CLI-created worktrees must use the same runner-script path as the @@ -1056,6 +1057,9 @@ export class OrcaRuntimeService { } }) } + } else if (hooks?.scripts.setup) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + console.info(`[hooks] setup hook skipped for ${worktreePath}; pass --run-hooks to run it`) } this.notifier?.worktreesChanged(repo.id) @@ -1101,7 +1105,11 @@ export class OrcaRuntimeService { return mergeWorktree(worktree.repoId, worktree.git, meta) } - async removeManagedWorktree(worktreeSelector: string, force = false): Promise { + async removeManagedWorktree( + worktreeSelector: string, + force = false, + runHooks = false + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -1115,11 +1123,14 @@ export class OrcaRuntimeService { } const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.archive) { + if (hooks?.scripts.archive && runHooks) { const result = await runHook('archive', worktree.path, repo) if (!result.success) { console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output) } + } else if (hooks?.scripts.archive) { + // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. + console.info(`[hooks] archive hook skipped for ${worktree.path}; pass --run-hooks to run it`) } try { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index acc588d049c..fafd86585e7 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -34,7 +34,8 @@ const WorktreeCreate = z.object({ .pipe(z.string().min(1, 'Missing worktree name')), baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, - comment: OptionalString + comment: OptionalString, + runHooks: OptionalBoolean }) const WorktreeSet = WorktreeSelector.extend({ @@ -44,7 +45,8 @@ const WorktreeSet = WorktreeSelector.extend({ }) const WorktreeRemove = WorktreeSelector.extend({ - force: OptionalBoolean + force: OptionalBoolean, + runHooks: OptionalBoolean }) export const WORKTREE_METHODS: RpcMethod[] = [ @@ -74,7 +76,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: params.name, baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, - comment: params.comment + comment: params.comment, + runHooks: params.runHooks === true }) }), defineMethod({ @@ -92,7 +95,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - await runtime.removeManagedWorktree(params.worktree, params.force === true) + await runtime.removeManagedWorktree( + params.worktree, + params.force === true, + params.runHooks === true + ) return { removed: true } } }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f20d74a1ca3..82850026503 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -342,7 +342,7 @@ export type PreloadApi = { headRefName?: string isCrossRepository?: boolean }) => Promise<{ baseBranch: string } | { error: string }> - remove: (args: { worktreeId: string; force?: boolean }) => Promise + remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index f85c5f35949..f621d516432 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -265,7 +265,7 @@ const api = { }): Promise<{ baseBranch: string } | { error: string }> => ipcRenderer.invoke('worktrees:resolvePrBase', args), - remove: (args: { worktreeId: string; force?: boolean }): Promise => + remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise => ipcRenderer.invoke('worktrees:remove', args), updateMeta: (args: { diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 7e21f4e6900..438ce858505 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -113,6 +113,9 @@ export function useRemoteRepo( const state = useAppStore.getState() const existingIdx = state.repos.findIndex((r) => r.id === repo.id) + if (existingIdx !== -1) { + state.clearOrcaHookTrustForRepo(repo.id) + } if (existingIdx === -1) { useAppStore.setState({ repos: [...state.repos, repo] }) } else { diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx new file mode 100644 index 00000000000..1cbd7fefcb9 --- /dev/null +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx @@ -0,0 +1,107 @@ +import React, { useCallback } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import type { OrcaHookScriptKind } from '@/lib/orca-hook-trust' + +type ScriptKind = OrcaHookScriptKind + +const SCRIPT_KIND_LABEL: Record = { + setup: 'setup script', + archive: 'archive script', + issueCommand: 'issue command' +} + +const SCRIPT_KIND_TRIGGER: Record = { + setup: 'when this workspace is created', + archive: 'when this workspace is removed', + issueCommand: 'when this workspace launches with a linked issue' +} + +const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { + const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) + const closeModal = useAppStore((s) => s.closeModal) + const markOrcaHookScriptConfirmed = useAppStore((s) => s.markOrcaHookScriptConfirmed) + + const isOpen = activeModal === 'confirm-orca-yaml-hooks' + + const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' + const repoName = typeof modalData.repoName === 'string' ? modalData.repoName : 'this repository' + const scriptKind: ScriptKind = + modalData.scriptKind === 'archive' + ? 'archive' + : modalData.scriptKind === 'issueCommand' + ? 'issueCommand' + : 'setup' + const scriptContent = typeof modalData.scriptContent === 'string' ? modalData.scriptContent : '' + const contentHash = typeof modalData.contentHash === 'string' ? modalData.contentHash : '' + const onResolve = + typeof modalData.onResolve === 'function' + ? (modalData.onResolve as (decision: 'run' | 'skip') => void) + : null + + const resolveAndClose = useCallback( + (decision: 'run' | 'skip') => { + if (decision === 'run' && repoId && contentHash) { + markOrcaHookScriptConfirmed(repoId, scriptKind, contentHash) + } + onResolve?.(decision) + closeModal() + }, + [closeModal, contentHash, markOrcaHookScriptConfirmed, onResolve, repoId, scriptKind] + ) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + resolveAndClose('skip') + } + }, + [resolveAndClose] + ) + + return ( + + + + + Run {SCRIPT_KIND_LABEL[scriptKind]} from {repoName}? + + + This repository's orca.yaml defines a {SCRIPT_KIND_LABEL[scriptKind]}{' '} + that will execute on your machine {SCRIPT_KIND_TRIGGER[scriptKind]}. Only run it if you + trust the contents of this repository. + + + + {scriptContent && ( +
+
+ {scriptKind} script +
+
+              {scriptContent}
+            
+
+ )} + + + + + +
+
+ ) +}) + +export default OrcaYamlTrustDialog diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index e2ea1c5e5b0..d29ec8282b5 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -12,6 +12,7 @@ import DeleteWorktreeDialog from './DeleteWorktreeDialog' import NonGitFolderDialog from './NonGitFolderDialog' import RemoveFolderDialog from './RemoveFolderDialog' import AddRepoDialog from './AddRepoDialog' +import OrcaYamlTrustDialog from './OrcaYamlTrustDialog' const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -70,6 +71,7 @@ function Sidebar(): React.JSX.Element { + ) } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 35c6054a952..de8ebd2fb8b 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -34,6 +34,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' export type UseComposerStateOptions = { initialRepoId?: string @@ -979,12 +980,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCreateError(null) setCreating(true) try { - const result = await createWorktree( - repoId, - workspaceName, - baseBranch, - (resolvedSetupDecision ?? 'inherit') as SetupDecision - ) + const setupTrustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') + const effectiveSetupDecision: SetupDecision = + setupTrustDecision === 'skip' + ? 'skip' + : ((resolvedSetupDecision ?? 'inherit') as SetupDecision) + + let issueCommandTrustDecision: 'run' | 'skip' = 'run' + if (shouldRunIssueAutomation) { + issueCommandTrustDecision = + setupTrustDecision === 'skip' + ? 'skip' + : await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand') + } + + const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision) const worktree = result.worktree await applyWorktreeMeta(worktree.id, { @@ -993,14 +1003,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ...(note.trim() ? { comment: note.trim() } : {}) }) - const issueCommand = shouldRunIssueAutomation - ? { - command: renderIssueCommandTemplate(issueCommandTemplate, { - issueNumber: parsedLinkedIssueNumber, - artifactUrl: linkedWorkItem?.url ?? null - }) - } - : undefined + const issueCommand = + shouldRunIssueAutomation && issueCommandTrustDecision === 'run' + ? { + command: renderIssueCommandTemplate(issueCommandTemplate, { + issueNumber: parsedLinkedIssueNumber, + artifactUrl: linkedWorkItem?.url ?? null + }) + } + : undefined const startupPlan = buildAgentStartupPlan({ agent: tuiAgent, prompt: startupPrompt, @@ -1087,11 +1098,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCreateError(null) setCreating(true) try { + const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') + const effectiveSetupDecision: SetupDecision = + trustDecision === 'skip' + ? 'skip' + : ((resolvedSetupDecision ?? 'inherit') as SetupDecision) + const result = await createWorktree( repoId, workspaceName, baseBranch, - (resolvedSetupDecision ?? 'inherit') as SetupDecision + effectiveSetupDecision ) const worktree = result.worktree diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts new file mode 100644 index 00000000000..3a185897eee --- /dev/null +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { PersistedTrustedOrcaHooks } from '../../../shared/types' +import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed' +import { hashOrcaHookScript } from './orca-hook-trust' + +const hooksCheckMock = vi.fn() +const readIssueCommandMock = vi.fn() + +;(globalThis as { window: unknown }).window = { + api: { + hooks: { + check: hooksCheckMock, + readIssueCommand: readIssueCommandMock + } + } +} + +type PendingPrompt = { + modal: string + data: Record + resolve: (decision: 'run' | 'skip') => void +} + +function createTestState(overrides?: Partial): { + state: AppState + pending: PendingPrompt[] +} { + const pending: PendingPrompt[] = [] + const trust: PersistedTrustedOrcaHooks = {} + const state = { + trustedOrcaHooks: trust, + repos: [{ id: 'repo-1', displayName: 'Repo One' }], + openModal: (modal: string, data: Record) => { + pending.push({ modal, data, resolve: data.onResolve as (d: 'run' | 'skip') => void }) + }, + ...overrides + } as unknown as AppState + return { state, pending } +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('ensureHooksConfirmed', () => { + beforeEach(() => { + hooksCheckMock.mockReset() + readIssueCommandMock.mockReset() + __resetTrustPromptChainForTests() + }) + + it('short-circuits to run when the persisted content hash matches the current script', async () => { + const { state, pending } = createTestState() + const script = 'pnpm install' + const hash = await hashOrcaHookScript(script) + state.trustedOrcaHooks['repo-1'] = { + setup: { contentHash: hash, approvedAt: 1 } + } + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: script } }, + mayNeedUpdate: false + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup') + + expect(decision).toBe('run') + expect(pending).toHaveLength(0) + }) + + it('re-prompts when the script content differs from the persisted hash', async () => { + const { state, pending } = createTestState() + const staleHash = await hashOrcaHookScript('old script') + state.trustedOrcaHooks['repo-1'] = { + setup: { contentHash: staleHash, approvedAt: 1 } + } + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'new script' } }, + mayNeedUpdate: false + }) + + const promise = ensureHooksConfirmed(state, 'repo-1', 'setup') + await flush() + + expect(pending).toHaveLength(1) + expect(pending[0].data.scriptContent).toBe('new script') + + pending[0].resolve('run') + await expect(promise).resolves.toBe('run') + }) + + it('returns run without prompting when no script of that kind is configured', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') + + expect(decision).toBe('run') + expect(pending).toHaveLength(0) + }) + + it('returns run without prompting when issueCommand source is local (user-owned)', async () => { + const { state, pending } = createTestState() + readIssueCommandMock.mockResolvedValue({ + source: 'local', + sharedContent: null, + localContent: 'user content', + effectiveContent: 'user content', + localFilePath: '' + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'issueCommand') + + expect(decision).toBe('run') + expect(pending).toHaveLength(0) + }) + + it('opens a modal with the computed content hash and resolves with the user decision', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install' } }, + mayNeedUpdate: false + }) + + const promise = ensureHooksConfirmed(state, 'repo-1', 'setup') + await flush() + + expect(pending).toHaveLength(1) + expect(pending[0].data).toMatchObject({ + repoId: 'repo-1', + repoName: 'Repo One', + scriptKind: 'setup', + scriptContent: 'pnpm install', + contentHash: await hashOrcaHookScript('pnpm install') + }) + + pending[0].resolve('run') + await expect(promise).resolves.toBe('run') + }) + + it('serializes overlapping prompts so a second call waits for the first to resolve', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install', archive: 'echo bye' } }, + mayNeedUpdate: false + }) + + const first = ensureHooksConfirmed(state, 'repo-1', 'setup') + const second = ensureHooksConfirmed(state, 'repo-1', 'archive') + + await flush() + + expect(pending).toHaveLength(1) + expect(pending[0].data.scriptKind).toBe('setup') + + pending[0].resolve('skip') + await expect(first).resolves.toBe('skip') + + await flush() + + expect(pending).toHaveLength(2) + expect(pending[1].data.scriptKind).toBe('archive') + + pending[1].resolve('run') + await expect(second).resolves.toBe('run') + }) + + it('fails closed when window.api.hooks.check throws', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockRejectedValue(new Error('boom')) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup') + + expect(decision).toBe('skip') + expect(pending).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts new file mode 100644 index 00000000000..276c905df79 --- /dev/null +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -0,0 +1,69 @@ +import type { AppState } from '@/store/types' +import type { OrcaHooks } from '../../../shared/types' +import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' + +export type HookScriptKind = OrcaHookScriptKind + +// Serialize the singleton modal callback so overlapping worktree actions cannot replace it. +let trustPromptChain: Promise = Promise.resolve() + +function enqueueTrustPrompt(task: () => Promise): Promise { + const next = trustPromptChain.then(task, task) + trustPromptChain = next.catch(() => undefined) + return next +} + +export function __resetTrustPromptChainForTests(): void { + trustPromptChain = Promise.resolve() +} + +export async function ensureHooksConfirmed( + state: AppState, + repoId: string, + scriptKind: HookScriptKind +): Promise<'run' | 'skip'> { + return enqueueTrustPrompt(async () => { + let scriptContent = '' + try { + if (scriptKind === 'issueCommand') { + // Local overrides are user-owned; only shared orca.yaml commands need repo trust. + const result = await window.api.hooks.readIssueCommand({ repoId }) + if (result.source !== 'shared') { + return 'run' + } + scriptContent = (result.sharedContent ?? '').trim() + } else { + const result = await window.api.hooks.check({ repoId }) + const yamlHooks = (result.hooks as OrcaHooks | null) ?? null + scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim() + } + } catch { + // Fail closed: if we cannot inspect the script, we cannot trust it. + return 'skip' + } + + if (!scriptContent) { + return 'run' + } + + const contentHash = await hashOrcaHookScript(scriptContent) + const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash + if (existingHash === contentHash) { + return 'run' + } + + const repo = state.repos.find((r) => r.id === repoId) + const repoName = repo?.displayName ?? 'this repository' + + return new Promise<'run' | 'skip'>((resolve) => { + state.openModal('confirm-orca-yaml-hooks', { + repoId, + repoName, + scriptKind, + scriptContent, + contentHash, + onResolve: (decision: 'run' | 'skip') => resolve(decision) + }) + }) + }) +} diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index eb645d53b52..decb9219d13 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -11,6 +11,7 @@ import { getWorkspaceSeedName } from '@/lib/new-workspace' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import type { OrcaHooks, RepoHookSettings, SetupDecision, TuiAgent } from '../../../shared/types' export type LaunchableWorkItem = { @@ -164,6 +165,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return } + const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') + const finalSetupDecision: SetupDecision = + trustDecision === 'skip' ? 'skip' : setupResolution.decision + const workspaceName = getWorkspaceSeedName({ explicitName: getLinkedWorkItemSuggestedName(item), prompt: '', @@ -175,12 +180,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let primaryTabId: string | null let startupPlan: ReturnType = null try { - const result = await store.createWorktree( - repoId, - workspaceName, - baseBranch, - setupResolution.decision - ) + const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision) worktreeId = result.worktree.id const detectedIds = new Set(await detectedAgentsPromise) @@ -289,6 +289,10 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise { + const normalized = content.trim() + const bytes = new TextEncoder().encode(normalized) + const digest = await crypto.subtle.digest('SHA-256', bytes) + const hex: string[] = [] + const view = new Uint8Array(digest) + for (let i = 0; i < view.length; i += 1) { + hex.push(view[i].toString(16).padStart(2, '0')) + } + return hex.join('') +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 62bba77aa84..8ab45b17b6b 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -69,6 +69,9 @@ export const createRepoSlice: StateCreator = (set, return null } const alreadyAdded = get().repos.some((r) => r.id === repo.id) + if (alreadyAdded) { + get().clearOrcaHookTrustForRepo(repo.id) + } set((s) => { if (s.repos.some((r) => r.id === repo.id)) { return s @@ -103,6 +106,9 @@ export const createRepoSlice: StateCreator = (set, } const repo = result.repo const alreadyAdded = get().repos.some((r) => r.id === repo.id) + if (alreadyAdded) { + get().clearOrcaHookTrustForRepo(repo.id) + } set((s) => { if (s.repos.some((r) => r.id === repo.id)) { return s @@ -139,6 +145,8 @@ export const createRepoSlice: StateCreator = (set, try { await window.api.repos.remove({ repoId }) + get().clearOrcaHookTrustForRepo(repoId) + // Kill PTYs for all worktrees belonging to this repo const worktreeIds = (get().worktreesByRepo[repoId] ?? []).map((w) => w.id) const killedTabIds = new Set() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 6e030613909..6adc7d3ec21 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -4,6 +4,7 @@ import type { AppState } from '../types' import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history' import type { ChangelogData, + PersistedTrustedOrcaHooks, PersistedUIState, StatusBarItem, TaskViewPresetId, @@ -13,10 +14,9 @@ import type { } from '../../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' -// Why: mirrors the preset→query mapping in getTaskPresetQuery (new-workspace.ts). +// Why: mirrors the preset→query mapping used by TaskPage's preset buttons. // Keeping a local copy here avoids a store ↔ lib circular import while letting // openTaskPage warm exactly the cache key the page will read on mount. -// Must stay in sync with getTaskPresetQuery — see DESIGN-gh-issues-improve.md. function presetToQuery(presetId: TaskViewPresetId | null): string { switch (presetId) { case 'issues': @@ -25,10 +25,10 @@ function presetToQuery(presetId: TaskViewPresetId | null): string { return 'assignee:@me is:issue is:open' case 'prs': return 'is:pr is:open' - case 'my-prs': - return 'author:@me is:pr is:open' case 'review': return 'review-requested:@me is:pr is:open' + case 'my-prs': + return 'author:@me is:pr is:open' default: return 'is:open' } @@ -37,6 +37,7 @@ import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' +import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' const MIN_SIDEBAR_WIDTH = 220 const MAX_LEFT_SIDEBAR_WIDTH = 500 @@ -46,6 +47,19 @@ const MAX_LEFT_SIDEBAR_WIDTH = 500 // corrupted/manually-edited values rather than as a product limit. const MAX_RIGHT_SIDEBAR_WIDTH = 4000 +function filterTrustedOrcaHooksToValidRepos( + trust: PersistedTrustedOrcaHooks, + validRepoIds: Set +): PersistedTrustedOrcaHooks { + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(trust)) { + if (validRepoIds.has(repoId)) { + next[repoId] = entry + } + } + return next +} + function sanitizePersistedSidebarWidth(width: unknown, fallback: number, maxWidth: number): number { if (typeof width !== 'number' || !Number.isFinite(width)) { return fallback @@ -131,6 +145,7 @@ export type UISlice = { | 'quick-open' | 'worktree-palette' | 'new-workspace-composer' + | 'confirm-orca-yaml-hooks' modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void @@ -145,6 +160,13 @@ export type UISlice = { * tab every time. */ createFromSubTab: 'prs' | 'issues' | 'branches' | 'linear' setCreateFromSubTab: (tab: 'prs' | 'issues' | 'branches' | 'linear') => void + trustedOrcaHooks: PersistedTrustedOrcaHooks + markOrcaHookScriptConfirmed: ( + repoId: string, + kind: OrcaHookScriptKind, + contentHash: string + ) => void + clearOrcaHookTrustForRepo: (repoId: string) => void searchQuery: string setSearchQuery: (q: string) => void groupBy: 'none' | 'repo' | 'pr-status' @@ -333,6 +355,33 @@ export const createUISlice: StateCreator = (set, get) createFromSubTab: 'prs', setCreateFromSubTab: (tab) => set({ createFromSubTab: tab }), + trustedOrcaHooks: {}, + markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => + set((s) => { + const existing = s.trustedOrcaHooks[repoId] + const currentEntry = existing?.[kind] + if (currentEntry?.contentHash === contentHash) { + return s + } + const nextRepo = { + ...existing, + [kind]: { contentHash, approvedAt: Date.now() } + } + const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo } + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + clearOrcaHookTrustForRepo: (repoId) => + set((s) => { + if (!(repoId in s.trustedOrcaHooks)) { + return s + } + const next = { ...s.trustedOrcaHooks } + delete next[repoId] + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + searchQuery: '', setSearchQuery: (q) => set({ searchQuery: q }), @@ -449,6 +498,10 @@ export const createUISlice: StateCreator = (set, get) updateReassuranceSeen: ui.updateReassuranceSeen ?? false, browserDefaultUrl: ui.browserDefaultUrl ?? null, browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, + trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos( + ui.trustedOrcaHooks ?? {}, + validRepoIds + ), persistedUIReady: true } }), diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 46672086d88..eeec95cd21e 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -15,6 +15,9 @@ const mockApi = { }, pty: { kill: vi.fn().mockResolvedValue(undefined) + }, + hooks: { + check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) } } @@ -30,6 +33,9 @@ function createTestStore() { // Why: this test isolates the worktree slice, so it only provides the // state surface that `createWorktreeSlice` reads and writes. ...createWorktreeSlice(...a), + trustedOrcaHooks: {}, + repos: [], + openModal: vi.fn(), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), tabsByWorktree: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index f3279d6cfcd..55d9cae08e1 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -8,6 +8,7 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean { @@ -141,12 +142,16 @@ export const createWorktreeSlice: StateCreator })) try { + const repoIdForTrust = getRepoIdFromWorktreeId(worktreeId) + const trustDecision = await ensureHooksConfirmed(get(), repoIdForTrust, 'archive') + const skipArchive = trustDecision === 'skip' + // Why: setup-enabled worktrees now commonly have a live shell open as soon as // they are created. We must tear those PTYs down before asking Git to remove // the working tree or Windows and some shells can keep the directory in use // and make delete look broken even though the git state itself is fine. await get().shutdownWorktreeTerminals(worktreeId) - await window.api.worktrees.remove({ worktreeId, force }) + await window.api.worktrees.remove({ worktreeId, force, skipArchive }) const tabs = get().tabsByWorktree[worktreeId] ?? [] const tabIds = new Set(tabs.map((t) => t.id)) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5ab06f31a58..69d4332c66f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -232,7 +232,8 @@ export function getDefaultUIState(): PersistedUIState { statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, dismissedUpdateVersion: null, - lastUpdateCheckAt: null + lastUpdateCheckAt: null, + trustedOrcaHooks: {} } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 9b22a1f3a95..d9c5d30ee5c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1117,8 +1117,22 @@ export type PersistedUIState = { /** Once the user has starred Orca (from any entry point) we permanently * suppress the nag — no further thresholds, no notifications. */ starNagCompleted?: boolean + trustedOrcaHooks?: PersistedTrustedOrcaHooks } +export type PersistedTrustedOrcaHookEntry = { + contentHash: string + approvedAt: number +} + +export type PersistedTrustedOrcaHookRepo = { + setup?: PersistedTrustedOrcaHookEntry + archive?: PersistedTrustedOrcaHookEntry + issueCommand?: PersistedTrustedOrcaHookEntry +} + +export type PersistedTrustedOrcaHooks = Record + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number From c1620496cf13cb9bb9a56aa32f32e3b44d096ef9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:51:24 -0700 Subject: [PATCH 043/259] feat(agent-cockpit): replace bottom-panel dashboard with inline per-card agents list (#1251) Co-authored-by: Orca --- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/index.ts | 18 +- src/main/ipc/app.ts | 2 +- .../rate-limits/gemini-usage-fetcher.test.ts | 4 +- src/renderer/src/App.tsx | 44 ++- .../components/dashboard/AgentDashboard.tsx | 334 ----------------- .../dashboard/DashboardAgentRow.tsx | 30 +- .../dashboard/DashboardFilterBar.tsx | 41 --- .../dashboard/DashboardWorktreeCard.tsx | 198 ---------- .../dashboard/RetainedAgentsSyncGate.tsx | 9 +- .../components/dashboard/useDashboardData.ts | 98 ++--- .../dashboard/useDashboardFilter.ts | 185 ---------- .../dashboard/useDashboardKeyboard.ts | 223 ----------- .../src/components/dashboard/useNow.ts | 4 +- .../components/dashboard/useRetainedAgents.ts | 137 +------ .../right-sidebar/DashboardBottomPanel.tsx | 340 ----------------- .../src/components/right-sidebar/index.tsx | 21 +- .../src/components/settings/AgentsPane.tsx | 42 --- .../components/settings/ExperimentalPane.tsx | 29 +- .../settings/experimental-search.ts | 12 +- .../components/sidebar/AgentStatusHover.tsx | 345 ------------------ .../src/components/sidebar/SearchBar.tsx | 2 +- .../src/components/sidebar/SidebarHeader.tsx | 20 +- .../src/components/sidebar/WorktreeCard.tsx | 35 +- .../components/sidebar/WorktreeCardAgents.tsx | 166 +++++++++ .../src/components/sidebar/WorktreeList.tsx | 2 +- src/renderer/src/components/sidebar/index.tsx | 1 - .../sidebar/useWorktreeAgentRows.ts | 152 ++++++++ .../components/sidebar/visible-worktrees.ts | 6 +- .../src/hooks/useAutoAckViewedAgent.ts | 27 +- .../slices/agent-status-ack-cleanup.test.ts | 2 +- src/renderer/src/store/slices/agent-status.ts | 4 +- src/renderer/src/store/slices/terminals.ts | 2 +- src/renderer/src/store/slices/ui.ts | 18 + src/shared/constants.ts | 8 +- src/shared/types.ts | 40 +- 37 files changed, 548 insertions(+), 2055 deletions(-) delete mode 100644 src/renderer/src/components/dashboard/AgentDashboard.tsx delete mode 100644 src/renderer/src/components/dashboard/DashboardFilterBar.tsx delete mode 100644 src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx delete mode 100644 src/renderer/src/components/dashboard/useDashboardFilter.ts delete mode 100644 src/renderer/src/components/dashboard/useDashboardKeyboard.ts delete mode 100644 src/renderer/src/components/right-sidebar/DashboardBottomPanel.tsx delete mode 100644 src/renderer/src/components/sidebar/AgentStatusHover.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAgents.tsx create mode 100644 src/renderer/src/components/sidebar/useWorktreeAgentRows.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 3f4a4a883b8..d070b084ca4 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -64,7 +64,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, showTaskProviderIcons: true, diffDefaultView: 'inline', notifications: { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 92c69a275da..3c6020ef928 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -58,7 +58,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, showTaskProviderIcons: true, diffDefaultView: 'inline', notifications: { diff --git a/src/main/index.ts b/src/main/index.ts index e73131da3c2..89c3a47d521 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -341,15 +341,15 @@ app.whenReady().then(async () => { nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why: managed hook installation mutates user-global agent config. // Startup must fail open so a malformed local config never bricks Orca. - // Claude/Codex/Gemini installs are gated behind the experimental - // Agent Dashboard setting because the surface they feed (the in-progress - // agent dashboard) isn't shippable yet. Cursor installs unconditionally - // because cursor-agent emits no title-based working/idle signal at all - // (its terminal title stays literally "Cursor Agent" across a turn), so - // the hook channel is the only way to drive the sidebar spinner + unread - // path for it — there is no "pre-dashboard" fallback to degrade to the - // way Claude/Codex have. Toggling the setting takes effect on next launch - // because the hook scripts are installed once per boot. + // Claude/Codex/Gemini installs are gated behind the experimentalAgentDashboard + // setting because the feature they feed (the inline agent-activity list) is + // still in preview. Cursor installs unconditionally because cursor-agent + // emits no title-based working/idle signal at all (its terminal title stays + // literally "Cursor Agent" across a turn), so the hook channel is the only + // way to drive the sidebar spinner + unread path for it — there is no + // title-based fallback the way Claude/Codex have. Toggling the setting + // takes effect on next launch because the hook scripts are installed once + // per boot. const agentDashboardEnabled = store.getSettings().experimentalAgentDashboard === true if (agentDashboardEnabled) { for (const installManagedHooks of [ diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 10c51f19490..8173a170ae8 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -9,7 +9,7 @@ export type AppRuntimeFlags = { /** Whether the experimental agent dashboard setting was enabled when this * session booted. When true, Claude/Codex/Gemini managed hook installation * was attempted at startup (individual install failures are logged but do - * not flip this flag — the dashboard UI itself treats missing hooks as + * not flip this flag — the inline agents list treats missing hooks as * no-ops). Toggling the setting only affects hook installation on the next * launch, so the renderer compares this against the current setting to * decide whether a "restart required" banner needs to be shown. */ diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index 17ff0553b5d..5f4e7388a55 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -202,9 +202,7 @@ describe('fetchGeminiRateLimits', () => { return Promise.resolve(makeResponse(quotaResponse)) } if (url.includes('token')) { - return Promise.resolve( - makeResponse({ access_token: 'retried-token', expires_in: 3600 }) - ) + return Promise.resolve(makeResponse({ access_token: 'retried-token', expires_in: 3600 })) } if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0f21ee4d252..35f6ac28cb1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -139,6 +139,13 @@ function App(): React.JSX.Element { const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) + // Why: render-level gate for the experimental agent dashboard retention + // sync. Reading the flag here (rather than only inside useDashboardData / + // useRetainedAgentsSync) lets us skip mounting RetainedAgentsSyncGate + // entirely for non-toggled users, which drops all feature-tied + // subscriptions (agentStatusByPaneKey, agentStatusEpoch, etc.) instead of + // keeping them alive behind an early-return inside the hook bodies. + const agentDashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) const titlebarLeftControlsRef = useRef(null) @@ -147,10 +154,10 @@ function App(): React.JSX.Element { // Subscribe to IPC push events useIpcEvents() - // Why: retention must run at App level (not inside AgentDashboard) because - // the sidebar hovercard also reads retained entries. If retention only ran - // when the dashboard is mounted, "done" agents would vanish from the hover - // any time the user collapses the dashboard panel. + // Why: retention must run at App level so the inline per-card agents list + // always sees retained entries. If retention ran inside the sidebar-card + // subtree, "done" agents would vanish any time the user collapsed a card's + // inline agents section. // // The retention hooks are hosted inside (a leaf // component that renders null) rather than being called inline here. @@ -159,10 +166,17 @@ function App(): React.JSX.Element { // event frequency), re-rendering the entire app tree on every agent status // update. Hosting the subscriptions in a leaf isolates that churn. // - // The experimentalAgentDashboard gate still lives inside the hooks - // themselves (useDashboardData early-returns [] from its memo; - // useRetainedAgentsSync early-returns from its effect), so the gate - // component is cheap when the setting is off. + // The render-level gate on (see + // agentDashboardEnabled above) keeps the experimental feature fully dark + // for non-toggled users: without the gate mounted, none of its feature-tied + // zustand selectors (agentStatusByPaneKey / agentStatusEpoch / etc.) are + // ever subscribed, so PTY agent-status events cause zero work for them. + // + // The inner hook guards (useDashboardData early-returns [] from its memo; + // useRetainedAgentsSync early-returns from its effect) remain as + // defense-in-depth: they keep both hooks safe to call from any future + // callsite, and they handle the in-session off→on toggle transition + // cleanly without relying on a remount race when the setting flips. // Why: git conflict-operation state also drives the worktree cards. Polling // cannot live under RightSidebar because App unmounts that subtree when the // sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind @@ -926,10 +940,16 @@ function App(): React.JSX.Element { } > - {/* Why: leaf-mounted retention sync. Hosts useDashboardData() + - useRetainedAgentsSync() so their high-churn store subscriptions - re-render a null component rather than the entire App tree. */} - + {/* Why: leaf-mounted retention sync, gated at the render level by + agentDashboardEnabled. Hosting useDashboardData() + + useRetainedAgentsSync() inside a null-rendering leaf keeps their + high-churn store subscriptions from re-rendering the App tree; + the outer conditional drops those subscriptions entirely for + users who have not toggled the experimental agent dashboard on, + so PTY agent-status events do no feature-tied work for them. + The hooks' internal early-returns remain as defense-in-depth + (see the comment above useIpcEvents()). */} + {agentDashboardEnabled ? : null} {/* Why: in workspace view (split groups always enabled), the full-width titlebar is removed so tab groups + terminal extend to the top of the window. Left titlebar controls move to a header above the sidebar. diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx deleted file mode 100644 index 32d9f548412..00000000000 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import React, { useState, useCallback, useMemo, useRef } from 'react' -import { Search, X, ChevronDown, ChevronRight, FolderGit2 } from 'lucide-react' -import { cn } from '@/lib/utils' -import { useAppStore } from '@/store' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { useDashboardData } from './useDashboardData' -import { useDashboardFilter } from './useDashboardFilter' -import { useDashboardKeyboard } from './useDashboardKeyboard' -import { useRetainedAgents } from './useRetainedAgents' -import DashboardFilterBar from './DashboardFilterBar' -import DashboardWorktreeCard from './DashboardWorktreeCard' -import { useNow } from './useNow' - -const AgentDashboard = React.memo(function AgentDashboard() { - const liveGroups = useDashboardData() - // Why: useRetainedAgents keeps a "done" row visible after the terminal/pane - // is closed and the explicit status entry is evicted from the store. Without - // this, a completed agent vanishes entirely — and the user loses the signal - // that the agent finished. Retained rows are dismissed when the user clicks - // through to the worktree. - const { enrichedGroups: groups, dismissAgent } = useRetainedAgents(liveGroups) - const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const setActiveTab = useAppStore((s) => s.setActiveTab) - const setActiveView = useAppStore((s) => s.setActiveView) - // Why: the persistent "selected" tint on a worktree card tracks the active - // worktree, not the last-clicked focus state. Keeping this in sync with the - // app-level activeWorktreeId makes the dashboard highlight what the user is - // currently viewing rather than where the keyboard/mouse last landed. - const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - // Why: own the 30s relative-time tick once for the whole dashboard and - // thread it down to every agent row. Previously each DashboardAgentRow - // instantiated its own setInterval, which scaled linearly with the number - // of rows on screen (N timers → N staggered re-renders per cycle). - const now = useNow(30_000) - - // Why: the store's explicit status entry persists after an agent reports - // `done` until the pane actually exits — which may be much later, since the - // user often leaves the Claude/Codex session alive to review output. The - // per-row dismiss removes both the live store entry and any retained entry - // so done agents don't pile up indefinitely in the dashboard. - const handleDismissAgent = useCallback( - (paneKey: string) => { - dropAgentStatus(paneKey) - dismissAgent(paneKey) - }, - [dropAgentStatus, dismissAgent] - ) - - const [searchQuery, setSearchQuery] = useState('') - const { filter, setFilter, filteredGroups, hasResults } = useDashboardFilter(groups, searchQuery) - const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) - // Why: repo groups are collapsible so users can hide repos they aren't - // actively watching. State is per-session (intentionally not persisted) — - // a long-lived collapsed state across restarts would hide new activity - // under a closed header and silently erase the "needs attention" signal. - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const toggleCollapse = useCallback((repoId: string) => { - setCollapsedRepos((prev) => { - const next = new Set(prev) - if (next.has(repoId)) { - next.delete(repoId) - } else { - next.add(repoId) - } - return next - }) - }, []) - // Why: arrow-key nav should only step over worktrees whose repo header is - // expanded. Building this list here keeps the source of truth in one place - // for both the DOM render order and the keyboard iteration order. - const visibleWorktrees = useMemo( - () => filteredGroups.flatMap((g) => (collapsedRepos.has(g.repo.id) ? [] : g.worktrees)), - [filteredGroups, collapsedRepos] - ) - - // Why: the keyboard hook scopes its listener to this container (not window) - // so dashboard shortcuts (1-5, arrows, Enter, Escape) don't hijack the - // terminal or other focused inputs when the dashboard pane is merely open. - // We keep a local ref so the callback ref below can detect the null → element - // transition (for focus-on-first-mount), and separately feed the element into - // `useDashboardKeyboard` via its callback ref. The container is rendered - // unconditionally (including on the no-repos empty state) so keyboard - // shortcuts remain wired even before any repo is added — otherwise the - // filter keys (1-5) would silently no-op until the first repo arrived. - const containerRef = useRef(null) - - // Why: clicking an agent row takes the user to the specific tab the agent - // ran in, not just the worktree's last-active tab. Retained rows can outlive - // their pane — fall back to worktree-only activation when the tab is no - // longer present so the click still lands somewhere useful. - const handleActivateAgentTab = useCallback( - (worktreeId: string, tabId: string) => { - setActiveWorktree(worktreeId) - setActiveView('terminal') - const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] - if (tabs.some((t) => t.id === tabId)) { - setActiveTab(tabId) - } - }, - [setActiveWorktree, setActiveTab, setActiveView] - ) - - // Why: a stable reference shared across every DashboardWorktreeCard so - // React.memo can actually short-circuit re-renders. An inline - // `() => setFocusedWorktreeId(card.worktree.id)` per card per render would - // mint a fresh function every render and invalidate memo on all N cards - // even when nothing else changed — the 30s `now` tick alone would cascade. - const handleCardFocus = useCallback((worktreeId: string) => setFocusedWorktreeId(worktreeId), []) - - const setKeyboardContainer = useDashboardKeyboard({ - filteredWorktrees: visibleWorktrees, - focusedWorktreeId, - setFocusedWorktreeId, - filter, - setFilter - }) - - // Why: combine the local RefObject with the keyboard hook's callback ref so - // both see the same element on attach/detach, and use the callback to drive - // focus-on-first-mount (below). A callback ref is the canonical React - // pattern for "run code when an element mounts/unmounts" and is required - // here because the container div is conditionally rendered — a RefObject - // alone wouldn't notify the keyboard hook (or the focus logic) when the - // container appears after the empty-state branch is replaced. - const setContainerRef = useCallback( - (el: HTMLDivElement | null) => { - const hadEl = containerRef.current !== null - containerRef.current = el - setKeyboardContainer(el) - // Why: focus the container the first time it becomes non-null so keyboard - // shortcuts work immediately — including on the path where the empty-state - // branch renders first (no container) and repos arrive later. A []-deps - // useEffect would fire once against a null ref on that path and never - // re-run. activeElement guard prevents stealing focus from inputs the - // user is currently typing in (e.g. sidebar search, terminal). - if (el && !hadEl) { - if (document.activeElement === null || document.activeElement === document.body) { - el.focus() - } - } - }, - [setKeyboardContainer] - ) - - const handleClearSearch = useCallback(() => setSearchQuery(''), []) - - const searchActive = searchQuery.trim().length > 0 - const showNoResults = searchActive && !hasResults - // Why: render the container unconditionally so the keyboard hook's - // callback ref fires on mount even when no repos exist yet — otherwise - // filter/arrow shortcuts don't bind until the first repo arrives. - const hasRepos = groups.length > 0 - - return ( -
- {!hasRepos ? ( -
-
- No repos added. Add a repo to see agent activity. -
-
- ) : ( - <> -
-
- - setSearchQuery(e.target.value)} - placeholder="Search..." - className="h-7 pl-7 pr-7 text-[11px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30" - /> - {searchActive && ( - - )} -
-
- -
-
- -
- {hasResults ? ( -
- {filteredGroups.map((group, groupIdx) => { - const isCollapsed = collapsedRepos.has(group.repo.id) - // Why: per-repo stats replace the global stats strip that used - // to sit above the whole dashboard. Counts live at the scope - // of the grouping so the user sees per-repo agent load instead - // of a rollup that hides which repo is busy. Counts come from - // useDashboardFilter's memo so this doesn't re-walk every agent - // in every worktree on each `now` tick or search change. - const { running: groupRunning, blocked: groupBlocked, done: groupDone } = group - const Icon = isCollapsed ? ChevronRight : ChevronDown - return ( -
- {/* Why: the repo header is a lightweight row, not a card — - no background fill, no border box. It stays an - expand/collapse control so users can hide repos they - aren't watching, but it doesn't wrap the children in - chrome that duplicates the worktree row's own borders. */} - - {!isCollapsed && - group.worktrees.map((card, i) => ( - - ))} -
- ) - })} -
- ) : ( -
-
- {showNoResults - ? 'No matches.' - : filter === 'active' - ? 'No active agents.' - : filter === 'blocked' - ? 'No agents are blocked.' - : filter === 'done' - ? 'No completed agents to show.' - : 'No agent activity yet.'} -
- {showNoResults ? ( - - ) : ( - filter !== 'all' && ( - - ) - )} -
- )} -
- - )} -
- ) -}) - -export default AgentDashboard diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 748d40214d2..4321c9b9ddf 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -71,21 +71,29 @@ type Props = { * Why: the relative-time labels ("Xm ago") need a periodic re-render to stay * honest. We accept `now` from a parent container so a single 30s tick owned * by the container drives every visible row, rather than each row running - * its own setInterval. See useNow.ts for the shared hook — callers own the - * tick (AgentDashboard for the dashboard, AgentStatusHover for hovercards). + * its own setInterval. See useNow.ts for the shared hook — WorktreeCardAgents + * owns the tick for the inline-in-card list. */ now: number /** - * Why: bold weight for the prompt rides on the enclosing worktree's - * isUnread (unvisited) signal, not on the per-agent state. Passed in from - * DashboardWorktreeCard so the workspace name and its agent rows share + * Why: bold weight for the prompt rides on the enclosing workspace card's + * unvisited signal, not on the per-agent state. Passed in from + * WorktreeCardAgents so the workspace name and its agent rows share * the same "you haven't looked at this yet" rule — visiting the worktree - * clears isUnread, and the next render mutes both in lockstep. + * clears the signal, and the next render mutes both in lockstep. * * Optional so other callers can opt out and default to muted when their * surface carries the unread signal elsewhere. */ isUnvisited?: boolean + /** + * Why: the inline-in-card variant sits in a tighter layout next to the + * agent identity icon, so 'md' reads as a second ~12px glyph that users + * can confuse with the agent icon. 'sm' keeps them visually distinct. + * The full dashboard has more breathing room and prefers 'md' for leading- + * slot presence, so default stays 'md'. + */ + stateDotSize?: 'sm' | 'md' } const DashboardAgentRow = React.memo(function DashboardAgentRow({ @@ -93,7 +101,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ onDismiss, onActivate, now, - isUnvisited = false + isUnvisited = false, + stateDotSize = 'md' }: Props) { const [expanded, setExpanded] = useState(false) // Why: stop propagation so clicking the X doesn't also fire the worktree @@ -146,7 +155,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // slot would collapse the text column and leave the row with no human- // readable label — just a state dot and icon. Fall back to the state label // ("Working", "Done", "Waiting", …) so every row is identifiable at a - // glance, matching the old AgentStatusHover.tsx behavior. + // glance. const displayLabel = prompt || agentStateLabel(asDotState(agent.state)) // Why: the tool row describes what the agent is *currently* doing; once it // leaves working, that line goes stale and misleads (a done row showing @@ -179,8 +188,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // role=button violates ARIA's "no interactive content inside interactive // content" rule and breaks keyboard/AT navigation. Keyboard users reach // the agent via the child buttons and the tab switcher; the outer
- // stays a plain clickable surface for pointer activation, mirroring the - // pattern in DashboardBottomPanel.tsx's collapse header. + // stays a plain clickable surface for pointer activation.
- + diff --git a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx b/src/renderer/src/components/dashboard/DashboardFilterBar.tsx deleted file mode 100644 index 329d6201c0f..00000000000 --- a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import type { DashboardFilter } from './useDashboardFilter' - -const FILTERS: { value: DashboardFilter; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'active', label: 'Active' }, - { value: 'blocked', label: 'Blocked' }, - { value: 'done', label: 'Done' } -] - -type Props = { - value: DashboardFilter - onChange: (value: DashboardFilter) => void -} - -const DashboardFilterBar = React.memo(function DashboardFilterBar({ value, onChange }: Props) { - return ( - { - // ToggleGroup fires empty string when deselecting — keep current filter - if (v) { - onChange(v as DashboardFilter) - } - }} - variant="outline" - size="sm" - className="gap-0" - > - {FILTERS.map((f) => ( - - {f.label} - - ))} - - ) -}) - -export default DashboardFilterBar diff --git a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx deleted file mode 100644 index 3a99343f62c..00000000000 --- a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useCallback, useMemo } from 'react' -import { cn } from '@/lib/utils' -import { useAppStore } from '@/store' -import DashboardAgentRow from './DashboardAgentRow' -import type { DashboardWorktreeCard as DashboardWorktreeCardData } from './useDashboardData' - -type Props = { - card: DashboardWorktreeCardData - /** True when this worktree is the one the user is currently viewing. */ - isActive: boolean - /** - * Why: accepts the worktree id (not a SyntheticEvent) so the parent can pass - * a single stable callback shared across all cards instead of minting a - * fresh `() => setFocusedWorktreeId(id)` closure per card per render — that - * inline lambda would invalidate React.memo on every AgentDashboard render. - */ - onFocus: (worktreeId: string) => void - onDismissAgent: (paneKey: string) => void - /** Navigate to a specific tab inside this card's worktree. */ - onActivateAgentTab: (worktreeId: string, tabId: string) => void - isLast: boolean - /** - * Why: `now` is owned by the dashboard container and threaded through every - * card to its rows. One shared 30s tick re-renders all visible "Xm ago" - * labels instead of each row owning its own setInterval (which would fire N - * times per cycle, staggered by mount time). - */ - now: number -} - -const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({ - card, - isActive, - onFocus, - onDismissAgent, - onActivateAgentTab, - isLast, - now -}: Props) { - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const setActiveView = useAppStore((s) => s.setActiveView) - const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents) - - const paneKeys = useMemo(() => card.agents.map((a) => a.paneKey), [card.agents]) - // Why: subscribe to the ack map's single reference (cheap Object.is check - // via Zustand's default equality) and derive the per-card slice locally. - // A useShallow selector here would allocate a fresh object on every - // store change — including unrelated ones like terminal output — - // multiplied across every card on screen. Reading the reference and - // memoizing the per-card slice collapses that to one allocation per - // card per genuine ack change. acknowledgeAgents in ui.ts preserves the - // map reference when no ack is actually moving forward, so unrelated - // clicks do not invalidate this memo either. - const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) - const ackByPaneKey = useMemo(() => { - const out: Record = {} - for (const paneKey of paneKeys) { - out[paneKey] = acknowledgedAgentsByPaneKey[paneKey] ?? 0 - } - return out - }, [paneKeys, acknowledgedAgentsByPaneKey]) - - // Why: an agent counts as "unvisited" when it has no ack OR the agent's - // current state began after the last ack (a new turn/state transition is - // a fresh signal the user hasn't seen). Using stateStartedAt (not - // updatedAt) means within-state tool/prompt pings don't re-trigger the - // unread highlight; only genuine state changes do. - const isAgentUnvisited = useCallback( - (paneKey: string, stateStartedAt: number) => { - const ackAt = ackByPaneKey[paneKey] ?? 0 - return ackAt < stateStartedAt - }, - [ackByPaneKey] - ) - - // Why: clicking a worktree row navigates AND acknowledges every agent - // currently shown under it. The user looking at the card counts as - // "seeing" all its rows, even ones they don't click individually — - // otherwise a workspace with five done agents would stay bold forever - // after the user scrolled past it. Dismissal of done rows still requires - // the explicit X; ack only changes the visual weight. - const handleClick = useCallback(() => { - setActiveWorktree(card.worktree.id) - setActiveView('terminal') - acknowledgeAgents(paneKeys) - }, [card.worktree.id, paneKeys, setActiveWorktree, setActiveView, acknowledgeAgents]) - - // Why: clicking an agent row navigates to that agent's tab AND acks the - // row so it fades to the visited weight. Scoped to a single paneKey so - // sibling rows (other agents on the same workspace) remain bold until the - // user looks at them. Dismissal still requires the explicit X. - const handleActivateAgent = useCallback( - (tabId: string, paneKey: string) => { - onActivateAgentTab(card.worktree.id, tabId) - acknowledgeAgents([paneKey]) - }, - [card.worktree.id, onActivateAgentTab, acknowledgeAgents] - ) - - // Why: React's onFocus handler receives a SyntheticEvent, but the parent - // needs the worktree id. Wrap here so the parent can pass a single stable - // callback that does not get invalidated per-card per-render. - const handleFocus = useCallback(() => { - onFocus(card.worktree.id) - }, [onFocus, card.worktree.id]) - - const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '') ?? '' - - // Why: workspace-level bold/muted weight tracks whether ANY of this card's - // agents are unvisited — so the workspace header stays bold while even one - // row inside it needs the user's attention, and fades once the user has - // clicked through every agent. Per-agent granularity lives on the rows - // themselves (DashboardAgentRow isUnvisited prop). If a workspace has no - // agents (edge case during spin-up), default to muted so the row doesn't - // read louder than it has value to. - const anyAgentUnvisited = card.agents.some((a) => - isAgentUnvisited(a.paneKey, a.entry.stateStartedAt) - ) - - // Why: the card is a clickable *surface* but NOT a `role="button"` — its - // children (DashboardAgentRow) render real - - {/* Body: full AgentDashboard */} - {!collapsed && ( -
- -
- )} -
- ) -} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index d6f4b64c725..fcc14df6638 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -21,7 +21,6 @@ import FileExplorer from './FileExplorer' import SourceControl from './SourceControl' import SearchPanel from './Search' import ChecksPanel from './ChecksPanel' -import DashboardBottomPanel from './DashboardBottomPanel' import PortsPanel from './PortsPanel' const MIN_WIDTH = 220 @@ -124,16 +123,6 @@ function RightSidebarInner(): React.JSX.Element { const checksStatus = useAppStore(getActiveChecksStatus) const activityBarPosition = useAppStore((s) => s.activityBarPosition) const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) - // Why: the bottom-docked agent dashboard is opt-out via Settings → Agents, - // AND gated behind the experimental opt-in setting. Users who prefer a - // quieter sidebar can hide the panel without losing any in-terminal agent - // status — the per-tab status indicators remain. While settings are still - // loading, render the panel so it doesn't flash in once settings arrive. - const showAgentDashboard = useAppStore((s) => s.settings?.showAgentDashboard !== false) - const dashboardExperimentEnabled = useAppStore( - (s) => s.settings?.experimentalAgentDashboard === true - ) - // Why: source control and checks are meaningless for non-git folders. // Hide those tabs so the activity bar only shows relevant actions. const activeRepo = useRepoById(activeWorktree?.repoId ?? null) @@ -245,10 +234,11 @@ function RightSidebarInner(): React.JSX.Element { that froze the app for seconds on Windows. Each panel now reacts to activeWorktreeId changes via store subscriptions and reset effects, keeping the component instance alive across switches. */} - {/* Why: the active tab content takes the top of the sidebar. The agent - dashboard docks at the bottom regardless of which tab is selected, - so users keep a glanceable view of agent status while they browse - files, search, etc. */} + {/* Why: live agent activity now renders inline inside each workspace + card (WorktreeCardAgents, toggled by the 'inline-agents' card + property) rather than in a bottom-docked dashboard panel that + competed with file Explorer/Search for vertical space. The right + sidebar is back to tab-only content. */}
{effectiveTab === 'explorer' && } {effectiveTab === 'search' && } @@ -256,7 +246,6 @@ function RightSidebarInner(): React.JSX.Element { {effectiveTab === 'checks' && } {effectiveTab === 'ports' && }
- {dashboardExperimentEnabled && showAgentDashboard && }
) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 843c586e39d..980f713a520 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -246,50 +246,8 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React defaultAgent === null || (defaultAgent !== 'blank' && !detectedIds?.has(defaultAgent)) const isBlankDefault = defaultAgent === 'blank' - // Why: match right-sidebar's `s.settings?.showAgentDashboard !== false` read. - // Persisted settings from older versions may be missing this field — treating - // `undefined` as "on" here keeps the toggle's displayed state in sync with - // the sidebar's actual rendering, so the UI never contradicts itself. - const showDashboard = settings.showAgentDashboard !== false - return (
- {/* Dashboard visibility */} - {settings.experimentalAgentDashboard === true && ( -
-
-
- - Show Agent Dashboard - -

- Show the live agent activity panel docked at the bottom of the right sidebar — - surfaces working, blocked, and done agents across all open worktrees. -

-
- -
-
- )} - {/* Default agent picker */}
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 724a36d4e63..4151e068929 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -13,8 +13,9 @@ import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' // Why: agents with a per-agent hook-service module under src/main that posts // status to the shared agent-hooks server. Keep this list in sync with the -// hook-service.ts files — any agent without one will not light up the -// dashboard even when the experimental setting is on. +// hook-service.ts files — any agent without one will not appear in the inline +// per-workspace-card agent activity list even when the experimental setting +// is on. const AGENT_DASHBOARD_SUPPORTED_AGENTS: readonly TuiAgent[] = [ 'claude', 'codex', @@ -134,15 +135,17 @@ export function ExperimentalPane({
{showAgentDashboard ? (
- +

- Adds a cross-worktree dashboard and hover cards showing each agent's live - status. Requires an app restart, and tracks agents started in new terminals opened - after the restart. + Shows each agent's live status, current prompt, and last message inline inside + its workspace card. Requires an app restart, and tracks agents started in new + terminals opened after the restart.

@@ -190,8 +193,8 @@ export function ExperimentalPane({

{settings.experimentalAgentDashboard - ? 'Restart Orca to finish enabling the agent dashboard.' - : 'Restart Orca to finish disabling the agent dashboard.'} + ? 'Restart Orca to finish enabling detailed agent activity.' + : 'Restart Orca to finish disabling detailed agent activity.'}

+ {!isCollapsed && ( +
+ {agents.map((agent) => ( +
+ +
+ ))} +
+ )} +
+ ) +}) + +export default WorktreeCardAgents diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 27a32359112..06857e45648 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -566,7 +566,7 @@ const WorktreeList = React.memo(function WorktreeList() { // O(N × E × T) per sortEpoch bump. Only smart mode uses the score map; // other modes ignore it. // Why: smart-sort only weighs live agent status when the experimental - // Agent Dashboard is opted in — that's the surface that populates + // agent-activity feature is opted in — that's what populates // agentStatusByPaneKey via hooks. With the setting off, pass undefined // so the comparator falls back to the persisted-sortOrder + title // heuristics instead of scoring against an empty map. diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index d29ec8282b5..4bb1b0d44c9 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -52,7 +52,6 @@ function Sidebar(): React.JSX.Element { - {/* Virtualized scrollable list */} {/* Fixed bottom toolbar */} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts new file mode 100644 index 00000000000..35a905ba5f1 --- /dev/null +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -0,0 +1,152 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import type { TerminalTab } from '../../../../shared/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' + +// Why: stable empty-array references so narrow selectors return the same +// reference when there's nothing for this worktree. Without stable empties, +// zustand's shallow equality would see a new `[]` every render and trigger +// unnecessary re-renders — defeating the purpose of the narrow selector. +const EMPTY_TABS: TerminalTab[] = [] +const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] +const EMPTY_RETAINED: RetainedAgentEntry[] = [] + +// Why: stable empty-array reference returned when the experimental +// feature is off, so reference equality across ticks prevents +// downstream re-renders on flag-disabled runs. +const EMPTY_ROWS: DashboardAgentRow[] = [] + +/** + * Narrow per-worktree agent row hook used by the WorktreeCard inline agents + * list. Produces live hook-reported agents plus retained "done" snapshots, + * stale-decayed to 'idle' when the hook stream has gone quiet. + * + * Uses per-worktree selectors rather than reusing useDashboardData's + * cross-worktree aggregate — that pipeline is O(repos × worktrees × agents) + * and would recompute once per sidebar card on every agent-status event. + * Scoped selectors keep the cost O(this-worktree-entries) per card. + */ +export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { + const dashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) + const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId]) + // Why: narrow the subscriptions to only THIS worktree's entries via + // useShallow. Subscribing to the whole agentStatusByPaneKey map would make + // every on-screen card re-render on any agent-status update anywhere — + // O(worktrees²) render amplification. Pre-filtering here means the card + // only re-renders when something relevant to THIS worktree changes. + const entries = useAppStore( + useShallow((s) => { + const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS + if (wtTabs.length === 0) { + return EMPTY_LIVE_ENTRIES + } + const tabIds = new Set(wtTabs.map((t) => t.id)) + const out: AgentStatusEntry[] = [] + for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { + const sepIdx = paneKey.indexOf(':') + if (sepIdx <= 0) { + continue + } + const tabId = paneKey.slice(0, sepIdx) + if (!tabIds.has(tabId)) { + continue + } + out.push(entry) + } + return out.length > 0 ? out : EMPTY_LIVE_ENTRIES + }) + ) + const retained = useAppStore( + useShallow((s) => { + const out: RetainedAgentEntry[] = [] + for (const ra of Object.values(s.retainedAgentsByPaneKey)) { + if (ra.worktreeId === worktreeId) { + out.push(ra) + } + } + return out.length > 0 ? out : EMPTY_RETAINED + }) + ) + // Why: agentStatusEpoch is included in the dependency array (but not in the + // computation itself) so the memo recomputes when freshness boundaries + // expire, even if no new PTY data arrives — same rationale as + // useDashboardData. + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + + return useMemo(() => { + // Why: belt-and-suspenders gate. The only current caller + // (WorktreeCardAgents inside WorktreeCard) already gates on the + // experimental flag, but keeping the check here prevents a future + // caller from silently leaking per-worktree agent-status + // subscriptions to users who have the feature off. + if (!dashboardEnabled) { + return EMPTY_ROWS + } + const rows: DashboardAgentRow[] = [] + const seenPaneKeys = new Set() + // Why: Date.now() is read inside the memo (not as a dep) so stale-decay + // recalculates whenever agentStatusEpoch ticks — same pattern as + // useDashboardData. + const now = Date.now() + + const entriesByTabId = new Map() + for (const entry of entries) { + const colonIndex = entry.paneKey.indexOf(':') + if (colonIndex === -1) { + continue + } + const tabId = entry.paneKey.slice(0, colonIndex) + const bucket = entriesByTabId.get(tabId) + if (bucket) { + bucket.push(entry) + } else { + entriesByTabId.set(tabId, [entry]) + } + } + + const worktreeTabs = tabs ?? [] + for (const tab of worktreeTabs) { + const explicitEntries = entriesByTabId.get(tab.id) ?? [] + for (const entry of explicitEntries) { + const isFresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + const shouldDecay = + !isFresh && + (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') + rows.push({ + paneKey: entry.paneKey, + entry, + tab, + agentType: entry.agentType ?? 'unknown', + state: shouldDecay ? 'idle' : entry.state, + startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + }) + seenPaneKeys.add(entry.paneKey) + } + } + + for (const ra of retained) { + if (seenPaneKeys.has(ra.entry.paneKey)) { + continue + } + rows.push({ + paneKey: ra.entry.paneKey, + entry: ra.entry, + tab: ra.tab, + agentType: ra.agentType, + state: 'done', + startedAt: ra.startedAt + }) + } + + rows.sort((a, b) => a.startedAt - b.startedAt) + return rows + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardEnabled, tabs, entries, retained, worktreeId, agentStatusEpoch]) +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 5350f145298..1d6926f9245 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -119,9 +119,9 @@ export function getVisibleWorktreeIds(): string[] { let sortedIds: string[] - // Why: matches WorktreeList's gate — when the experimental Agent Dashboard - // is off, the agent-status map is not populated, so fall back to the - // non-status sort heuristics instead of scoring against an empty map. + // Why: matches WorktreeList's gate — when the experimental agent-activity + // feature is off, the agent-status map is not populated, so fall back to + // the non-status sort heuristics instead of scoring against an empty map. const agentStatusForSort = state.settings?.experimentalAgentDashboard === true ? state.agentStatusByPaneKey : undefined if (state.sortBy === 'smart') { diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 646f0db0700..3fe4d68edcf 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -65,12 +65,13 @@ export function useAutoAckViewedAgent(): void { return } - // Why: mirror the dashboard's visibility gate — if the experimental - // agent dashboard is off, nothing in the UI reads the ack map, so - // accumulating entries for unseen agents is wasted memory and the - // Object.entries scan below is pure overhead. The subscribe callback - // fires on any store change, so flipping the setting naturally - // re-evaluates this guard without a separate subscription. + // Why: mirror the inline agents' visibility gate — when the + // experimental agent-activity feature is off, nothing in the UI reads + // the ack map, so accumulating entries for unseen agents is wasted + // memory and the Object.entries scan below is pure overhead. The + // subscribe callback fires on any store change, so flipping the + // setting naturally re-evaluates this guard without a separate + // subscription. if (s.settings?.experimentalAgentDashboard !== true) { return } @@ -80,10 +81,10 @@ export function useAutoAckViewedAgent(): void { } // Why: the auto-ack represents "the user saw this row" — but tab-active is // only a proxy. If the OS window is hidden, minimized, or another app has - // focus, the user is demonstrably not looking at the dashboard even with the - // terminal tab set. Without this gate, an agent finishing while the user is - // away silently clears the bold-until-viewed signal and the user returns to - // a dashboard with no indication anything transitioned. + // focus, the user is demonstrably not looking at the inline agents list + // even with the terminal tab set. Without this gate, an agent finishing + // while the user is away silently clears the bold-until-viewed signal and + // the user returns to a card with no indication anything transitioned. if (typeof document !== 'undefined') { if (document.visibilityState !== 'visible') { return @@ -100,8 +101,8 @@ export function useAutoAckViewedAgent(): void { // or feature gate caused an early return, we must leave the refs stale so // the next call (e.g. triggered by the focus listener on return) sees a // diff and actually runs the scan. Updating refs before the gates would - // consume the diff silently and leave the user returning to a dashboard - // whose bold-until-viewed rows stay bold until some unrelated store change + // consume the diff silently and leave the user returning to cards whose + // bold-until-viewed rows stay bold until some unrelated store change // happens to bump the refs again. lastActiveView = s.activeView lastActiveTabId = s.activeTabId @@ -119,7 +120,7 @@ export function useAutoAckViewedAgent(): void { // within the same state don't re-trigger ack work on every event — // acknowledgeAgents short-circuits anyway when the value is // unchanged, but keeping the comparison in sync with the - // "is-unvisited" rule in DashboardWorktreeCard avoids a stutter + // "is-unvisited" rule in WorktreeCardAgents avoids a stutter // where we ack on an updatedAt-bump that didn't cross a state // transition. if (ackAt < entry.stateStartedAt) { diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index 8759d34e9ab..1cdaa7a6295 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -107,7 +107,7 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { const newEntry = store.getState().agentStatusByPaneKey['tab-1:0'] const ackAt = store.getState().acknowledgedAgentsByPaneKey['tab-1:0'] ?? 0 - // Why: the unvisited rule (DashboardWorktreeCard's isAgentUnvisited) is + // Why: the unvisited rule (WorktreeCardAgents' unvisitedByPaneKey) is // `ackAt < stateStartedAt`. A leaked session-1 ack would still be // greater than the second paneKey's fresh stateStartedAt only by // accident of wall-clock ordering, but more robustly: after cleanup, diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index db80001473a..41203950b65 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -405,7 +405,7 @@ export const createAgentStatusSlice: StateCreator { // Why: skip the allocation + set(...) entirely when every input entry // is already present by reference. Consumers of retainedAgentsByPaneKey - // select on its identity (dashboard + hovercard), so a spurious map + // select on its identity (the inline agents list), so a spurious map // reallocation forces re-renders even when nothing changed. Mirrors // the identity-preservation pattern used by pruneRetainedAgents and // clearRetentionSuppressedPaneKeys. diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 803c7ebfaee..49c24bdbc6e 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -528,7 +528,7 @@ export const createTerminalSlice: StateCreator }) // Why: sweep live AND retained agent-status entries for this tab — closing // the tab is the user telling us "I'm done with this session", so any - // completion snapshots it left behind (in the sidebar/hovercard) must go + // completion snapshots it left behind (in the inline agents list) must go // too. Use dropAgentStatusByTabPrefix (not removeAgentStatusByTabPrefix) // so retention suppressors are planted: a live→gone transition inside the // same frame as the tab close cannot re-snapshot a row we just dropped. diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 6adc7d3ec21..879e6ee6c53 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -82,6 +82,12 @@ export type UISlice = { * first visit after launch is a legitimate "need to see" moment. */ acknowledgedAgentsByPaneKey: Record acknowledgeAgents: (paneKeys: string[]) => void + /** Per-worktree collapsed state for the inline agents section shown inside + * each workspace card. Session-only — a restart defaults back to expanded, + * which matches the expected default (people rarely want agents hidden + * across launches). */ + collapsedInlineAgentsByWorktreeId: Record + toggleInlineAgentsCollapsed: (worktreeId: string) => void activeView: 'terminal' | 'settings' | 'tasks' previousViewBeforeTasks: 'terminal' | 'settings' previousViewBeforeSettings: 'terminal' | 'tasks' @@ -251,6 +257,18 @@ export const createUISlice: StateCreator = (set, get) } return next ? { acknowledgedAgentsByPaneKey: next } : s }), + collapsedInlineAgentsByWorktreeId: {}, + toggleInlineAgentsCollapsed: (worktreeId) => + set((s) => { + const current = s.collapsedInlineAgentsByWorktreeId[worktreeId] === true + const next = { ...s.collapsedInlineAgentsByWorktreeId } + if (current) { + delete next[worktreeId] + } else { + next[worktreeId] = true + } + return { collapsedInlineAgentsByWorktreeId: next } + }), activeView: 'terminal', previousViewBeforeTasks: 'terminal', diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 69d4332c66f..57af1c5343b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -67,7 +67,12 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'ci', 'issue', 'pr', - 'comment' + 'comment', + // Why: agent activity is the primary reason users opt into the feature, so + // show it inline on each card by default. Unchecking this from the + // Workspaces view options hides the inline list entirely — there is no + // alternative agent-activity surface in the sidebar. + 'inline-agents' ] export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ @@ -155,7 +160,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInApp: true, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, showTaskProviderIcons: true, notifications: getDefaultNotificationSettings(), diffDefaultView: 'inline', diff --git a/src/shared/types.ts b/src/shared/types.ts index d9c5d30ee5c..ab144fa5ba6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -919,16 +919,6 @@ export type GlobalSettings = { rightSidebarOpenByDefault: boolean /** Whether to show the live agent activity count badge in the titlebar. */ showTitlebarAgentActivity: boolean - /** Whether to show the Agent Dashboard panel at the bottom of the right sidebar. - * Why: optional because readers use the `settings?.showAgentDashboard !== false` - * idiom (right-sidebar/index.tsx, AgentsPane.tsx) which presumes the field may - * be undefined — e.g. on first hydrate before main-process defaults apply, or - * when migrating settings persisted before this field existed. A required - * `boolean` here would make those readers' fallback branches dead-on-paper - * while still being reached at runtime, which is exactly the kind of type - * hack that drifts silently. Aligning the declaration with reader intent - * keeps the contract honest. */ - showAgentDashboard?: boolean /** Why: the Tasks sidebar label can be kept cleaner for users who do not * actively use the GitHub/Linear integrations behind it. */ showTaskProviderIcons: boolean @@ -1014,14 +1004,13 @@ export type GlobalSettings = { * detection, so no visible behavior change. Then we flip this flag to true * and never migrate again. */ terminalMacOptionAsAltMigrated: boolean - /** Experimental: live Agent Dashboard — a bottom-docked right-sidebar panel - * that aggregates working/blocked/done agents across all worktrees, plus - * the sidebar AgentStatusHover surface, retention of "done" rows, and the - * hook-driven status slice that feeds them. Opt-in because the surface is - * still in preview: managed hook installation (Claude/Codex/Gemini) only - * runs when this is true, so toggling it on takes effect on the next app - * launch. The in-pane status indicators and the cursor-agent hook path are - * unaffected by this toggle. */ + /** Experimental: live agent activity — inline per-workspace-card agent + * rows showing state, prompt, and last message, plus retention of "done" + * rows and the hook-driven status slice that feeds them. Opt-in because + * the surface is still in preview: managed hook installation + * (Claude/Codex/Gemini) only runs when this is true, so toggling it on + * takes effect on the next app launch. The in-pane status indicators and + * the cursor-agent hook path are unaffected by this toggle. */ experimentalAgentDashboard: boolean } @@ -1050,7 +1039,20 @@ export type NotificationDispatchResult = { reason?: 'disabled' | 'source-disabled' | 'suppressed-focus' | 'cooldown' | 'not-supported' } -export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment' +export type WorktreeCardProperty = + | 'status' + | 'unread' + | 'ci' + | 'issue' + | 'pr' + | 'comment' + // Why: inline list of agent activity rendered directly inside each + // workspace card when the experimental agent-activity feature is on. On by + // default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts) — + // live agent activity is the primary reason users opt into the feature. + // Users who prefer a compact sidebar can uncheck it from the Workspaces + // view options. + | 'inline-agents' export type StatusBarItem = | 'claude' From b5d8eb6d31dfa904b9939b1085e75fb5a88ae076 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:03:52 -0700 Subject: [PATCH 044/259] fix(sidebar): restore green dot for 'active' worktree status (#1266) Co-authored-by: Orca --- .../components/sidebar/StatusIndicator.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index b9de553010d..e4875bb0f40 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -19,11 +19,12 @@ const StatusIndicator = React.memo(function StatusIndicator({ ...rest }: StatusIndicatorProps) { // Why: surface the status label as a native tooltip so hovering the dot - // reveals the state — matters especially for 'active' vs 'inactive', which - // share the same grey dot (see color-branch comment below). Callers pass - // aria-hidden="true" alongside an sr-only label, so the `title` attribute - // is ignored by AT and only serves sighted users on hover. Callers can - // override by passing their own `title`. + // reveals the state — matters especially for 'active' vs 'done', which + // share the same emerald dot until TODO(#1265) lands (see color-branch + // comment below). Callers pass aria-hidden="true" alongside an sr-only + // label, so the `title` attribute is ignored by AT and only serves + // sighted users on hover. Callers can override by passing their own + // `title`. const resolvedTitle = title ?? getWorktreeStatusLabel(status) if (status === 'working') { @@ -49,12 +50,18 @@ const StatusIndicator = React.memo(function StatusIndicator({ 'block size-2 rounded-full', status === 'permission' ? 'bg-red-500' - : status === 'done' - ? // Green dot for done; working uses a yellow spinner so the - // two states differ by both hue and motion. 'active' (terminal - // open, quiet) collapses to the same grey as 'inactive' — the - // tooltip carries the distinction for sighted users and the - // sr-only sibling in callers carries it for AT. + : status === 'done' || status === 'active' + ? // Green dot for both hook-reported 'done' and the heuristic + // 'active' (terminal open, quiet). Without the 'active' + // branch users without the experimental agent-tracking + // setting never reach 'done' and lose the green dot + // entirely. Working uses a yellow spinner so working vs + // done differ by both hue and motion; 'inactive' stays grey. + // TODO(#1265): differentiate 'active' (quiet terminal) + // from 'done' (agent-reported completion) visually — today + // both share emerald, so with the experimental tracking + // toggle on a newly-opened quiet terminal reads the same + // as a completed agent. 'bg-emerald-500' : 'bg-neutral-500/40' )} From 977d56b97508b767dc4a134088c637fcabb3f469 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:04:17 +0000 Subject: [PATCH 045/259] release: v1.3.25-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1c42699174e..2833a17475e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.25-rc.2", + "version": "1.3.25-rc.3", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 7993fb5aaad477bf05f5c905c7b58037fa957677 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:29:11 -0700 Subject: [PATCH 046/259] feat(status-bar): show inline usage for inactive accounts in switcher (#1259) Co-authored-by: Orca --- src/main/claude-accounts/service.ts | 13 +- src/main/codex-accounts/service.test.ts | 6 +- src/main/codex-accounts/service.ts | 19 +- src/main/index.ts | 12 ++ src/main/ipc/rate-limits.ts | 6 + src/main/rate-limits/claude-fetcher.ts | 86 +++++++-- src/main/rate-limits/service.ts | 181 ++++++++++++++++-- src/preload/api-types.ts | 2 + src/preload/index.ts | 4 + .../src/components/status-bar/StatusBar.tsx | 166 +++++++++++++--- .../src/components/status-bar/tooltip.tsx | 2 +- src/renderer/src/store/slices/rate-limits.ts | 20 +- src/shared/rate-limit-types.ts | 9 + 13 files changed, 458 insertions(+), 68 deletions(-) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 0063e5a1a3c..b1245c2344e 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -110,12 +110,13 @@ export class ClaudeAccountService { lastAuthenticatedAt: now } + const outgoingAccountId = previousSettings.activeClaudeManagedAccountId this.store.updateSettings({ claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], activeClaudeManagedAccountId: account.id }) await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) @@ -178,7 +179,12 @@ export class ClaudeAccountService { try { await this.syncRuntimeAuthWithLivePtyGate() await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) - await this.rateLimits.refreshForClaudeAccountChange() + this.rateLimits.evictInactiveClaudeCache(accountId) + await this.rateLimits.refreshForClaudeAccountChange( + settings.activeClaudeManagedAccountId === accountId + ? settings.activeClaudeManagedAccountId + : undefined + ) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -192,10 +198,11 @@ export class ClaudeAccountService { this.requireAccount(accountId) } const previousSettings = this.store.getSettings() + const outgoingAccountId = previousSettings.activeClaudeManagedAccountId this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) try { await this.syncRuntimeAuthWithLivePtyGate() - await this.rateLimits.refreshForClaudeAccountChange() + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(previousSettings) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 3c6020ef928..47ca0385162 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -111,7 +111,8 @@ function createStore(settings: GlobalSettings) { function createRateLimits() { return { - refreshForCodexAccountChange: vi.fn().mockResolvedValue(undefined) + refreshForCodexAccountChange: vi.fn().mockResolvedValue(undefined), + evictInactiveCodexCache: vi.fn() } } @@ -554,7 +555,8 @@ describe('CodexAccountService config sync', () => { const rateLimits = { refreshForCodexAccountChange: vi.fn(async () => { callOrder.push('refresh') - }) + }), + evictInactiveCodexCache: vi.fn() } const runtimeHome = createRuntimeHome() diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 7f37b470092..f2344954b28 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -108,7 +108,10 @@ export class CodexAccountService { this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection() - await this.rateLimits.refreshForCodexAccountChange() + // Why: the new account becomes active, so the previous active account is + // now inactive and its last-known usage should be cached for the switcher. + const outgoingAccountId = settings.activeCodexManagedAccountId + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) return this.getSnapshot() } catch (error) { this.safeRemoveManagedHome(managedHomePath) @@ -171,7 +174,14 @@ export class CodexAccountService { this.runtimeHome.syncForCurrentSelection() this.safeRemoveManagedHome(account.managedHomePath) - await this.rateLimits.refreshForCodexAccountChange() + // Why: a removed account can no longer appear in the switcher dropdown, + // so purge its cached usage to avoid stale entries. + this.rateLimits.evictInactiveCodexCache(accountId) + await this.rateLimits.refreshForCodexAccountChange( + settings.activeCodexManagedAccountId === accountId + ? settings.activeCodexManagedAccountId + : undefined + ) return this.getSnapshot() } @@ -180,13 +190,16 @@ export class CodexAccountService { this.requireAccount(accountId) } + const previousSettings = this.store.getSettings() + const outgoingAccountId = previousSettings.activeCodexManagedAccountId + this.store.updateSettings({ activeCodexManagedAccountId: accountId }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection() - await this.rateLimits.refreshForCodexAccountChange() + await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId) return this.getSnapshot() } diff --git a/src/main/index.ts b/src/main/index.ts index 89c3a47d521..e98f2532552 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -333,6 +333,18 @@ app.whenReady().then(async () => { rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) rateLimits.setSettingsResolver(() => store!.getSettings()) + rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store!.getSettings() + return settings.claudeManagedAccounts + .filter((account) => account.id !== settings.activeClaudeManagedAccountId) + .map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath })) + }) + rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store!.getSettings() + return settings.codexManagedAccounts + .filter((account) => account.id !== settings.activeCodexManagedAccountId) + .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) + }) runtime = new OrcaRuntimeService(store, stats) starNag = new StarNagService(store, stats) starNag.start() diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 1a4634e8feb..703e9761721 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -7,4 +7,10 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:setPollingInterval', (_event, ms: number) => rateLimits.setPollingInterval(ms) ) + ipcMain.handle('rateLimits:fetchInactiveClaudeAccounts', () => + rateLimits.fetchInactiveClaudeAccountsOnOpen() + ) + ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => + rateLimits.fetchInactiveCodexAccountsOnOpen() + ) } diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index f85518f4c40..312c65a8cdc 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -6,6 +6,7 @@ import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { readManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -75,6 +76,25 @@ type KeychainCredentials = { } } +// Why: factored out so both the active-account Keychain reader and the +// managed-account reader share the same JSON parsing + expiry check. +function parseOAuthTokenFromCredentialsJson(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as KeychainCredentials + const token = parsed?.claudeAiOauth?.accessToken + if (!token || typeof token !== 'string') { + return null + } + const expiresAt = parsed.claudeAiOauth?.expiresAt + if (typeof expiresAt === 'number' && expiresAt < Date.now()) { + return null + } + return token + } catch { + return null + } +} + /** * Read OAuth token from macOS Keychain. * Why: Claude Code v2.x+ stores OAuth credentials in the macOS Keychain @@ -103,24 +123,7 @@ async function readFromKeychain(): Promise { resolve(null) return } - try { - const parsed = JSON.parse(stdout.trim()) as KeychainCredentials - const token = parsed?.claudeAiOauth?.accessToken - if (!token || typeof token !== 'string') { - resolve(null) - return - } - - const expiresAt = parsed.claudeAiOauth?.expiresAt - if (typeof expiresAt === 'number' && expiresAt < Date.now()) { - resolve(null) - return - } - - resolve(token) - } catch { - resolve(null) - } + resolve(parseOAuthTokenFromCredentialsJson(stdout.trim())) } ) }) @@ -314,3 +317,50 @@ export async function fetchClaudeRateLimits(options?: { status: 'unavailable' } } + +// --------------------------------------------------------------------------- +// Managed account usage (inactive accounts — fetch-on-open) +// --------------------------------------------------------------------------- + +export type InactiveClaudeAccountInfo = { + id: string + managedAuthPath: string +} + +// Why: reads an inactive account's OAuth token directly from its managed +// storage without materializing credentials into the shared runtime location. +// Using ClaudeRuntimeAuthService would overwrite the active account's auth. +async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { + try { + if (process.platform === 'darwin') { + const raw = await readManagedClaudeKeychainCredentials(account.id) + if (raw) { + return parseOAuthTokenFromCredentialsJson(raw) + } + return null + } + return await readFromCredentialsFile(account.managedAuthPath) + } catch { + return null + } +} + +export async function fetchManagedAccountUsage( + account: InactiveClaudeAccountInfo +): Promise { + const token = await readManagedOAuthToken(account) + if (!token) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'No credentials', + status: 'error' + } + } + // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY + // path materializes credentials via ClaudeRuntimeAuthService, which would + // interfere with the active account's auth state. + return fetchViaOAuth(token) +} diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 2b7a53dfb0c..fc63a49e319 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -2,22 +2,42 @@ handling, account-switch fetch semantics, and renderer push coordination so the fetch ordering rules stay in one place. */ import type { BrowserWindow } from 'electron' -import type { RateLimitState, ProviderRateLimits } from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits } from './claude-fetcher' +import type { + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage +} from '../../shared/rate-limit-types' +import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' +import type { InactiveClaudeAccountInfo } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +export type InactiveCodexAccountInfo = { + id: string + managedHomePath: string +} + // Why: quota state does not need near-real-time polling, and a less aggressive // default reduces avoidable Claude /usage pressure. We intentionally use a // slower cadence here rather than polling every 2 minutes. const DEFAULT_POLL_MS = 5 * 60 * 1000 // 5 minutes const MIN_REFETCH_MS = 30 * 1000 // 30 seconds — debounce rapid refresh requests const STALE_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes — after this, stale data is dropped +const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open + +// Why: the internal state only tracks claude and codex. The inactiveClaudeAccounts +// array is derived from the cache on demand in getState() and pushToRenderer(). +type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null +} export class RateLimitService { - private state: RateLimitState = { claude: null, codex: null, gemini: null, opencodeGo: null } + private state: InternalRateLimitState = { claude: null, codex: null, gemini: null, opencodeGo: null } private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private lastFetchAt = 0 @@ -41,6 +61,14 @@ export class RateLimitService { geminiCliOAuthEnabled?: boolean }) | null = null + private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + private inactiveClaudeCache = new Map() + private inactiveCodexCache = new Map() + private inactiveClaudeFetching = new Set() + private inactiveCodexFetching = new Set() + private lastInactiveClaudeFetchAt = 0 + private lastInactiveCodexFetchAt = 0 constructor() {} @@ -61,6 +89,15 @@ export class RateLimitService { ): void { this.settingsResolver = resolver } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + } + attach(mainWindow: BrowserWindow): void { this.detachWindowListeners?.() this.mainWindow = mainWindow @@ -98,7 +135,17 @@ export class RateLimitService { } getState(): RateLimitState { - return this.state + return { + ...this.state, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } } async refresh(): Promise { @@ -107,11 +154,15 @@ export class RateLimitService { // broken after wake/focus transitions because the click can no-op even // though the user is asking for a fresh read right now. await this.fetchAll({ force: true }) - return this.state + return this.getState() } - async refreshForCodexAccountChange(): Promise { + async refreshForCodexAccountChange(outgoingAccountId?: string | null): Promise { + if (outgoingAccountId && this.state.codex?.session) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } this.codexFetchGeneration += 1 + this.lastInactiveCodexFetchAt = 0 // Why: switching the selected Codex account must immediately clear the old // Codex quota view. Keeping stale values visible would show the previous // account's limits under the newly selected identity until the next poll. @@ -120,17 +171,97 @@ export class RateLimitService { codex: this.withFetchingStatus(null, 'codex') }) await this.fetchCodexOnly({ force: true }) - return this.state + return this.getState() } - async refreshForClaudeAccountChange(): Promise { + async refreshForClaudeAccountChange(outgoingAccountId?: string | null): Promise { + // Why: snapshot the outgoing account's usage before clearing it so the + // inline usage bars in the switcher can show last-known data immediately. + if (outgoingAccountId && this.state.claude?.session) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } this.claudeFetchGeneration += 1 + this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, claude: this.withFetchingStatus(null, 'claude') }) await this.fetchClaudeOnly({ force: true }) - return this.state + return this.getState() + } + + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + for (const account of accounts) { + try { + const fresh = await fetchManagedAccountUsage(account) + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one Keychain rejection or + // network error from aborting the remaining accounts in the batch. + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + this.lastInactiveClaudeFetchAt = Date.now() + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + + for (const account of accounts) { + this.inactiveCodexFetching.add(account.id) + } + this.pushToRenderer() + + for (const account of accounts) { + try { + // Why: fetchCodexRateLimits already accepts codexHomePath, so we can + // point it at the managed account's home directory directly without + // materializing credentials into the shared runtime location. + const fresh = await fetchCodexRateLimits({ codexHomePath: account.managedHomePath }) + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + this.lastInactiveCodexFetchAt = Date.now() + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + evictInactiveCodexCache(accountId: string): void { + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() } setPollingInterval(ms: number): void { @@ -587,7 +718,35 @@ export class RateLimitService { } } - private updateState(next: RateLimitState): void { + private buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + claude: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include accounts that are fetching but have no cache yet so the + // renderer can show a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + claude: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + private updateState(next: InternalRateLimitState): void { this.state = next this.pushToRenderer() } @@ -596,6 +755,6 @@ export class RateLimitService { if (!this.mainWindow || this.mainWindow.isDestroyed()) { return } - this.mainWindow.webContents.send('rateLimits:update', this.state) + this.mainWindow.webContents.send('rateLimits:update', this.getState()) } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 82850026503..32b7769efe0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -829,6 +829,8 @@ export type PreloadApi = { get: () => Promise refresh: () => Promise setPollingInterval: (ms: number) => Promise + fetchInactiveClaudeAccounts: () => Promise + fetchInactiveCodexAccounts: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } ssh: { diff --git a/src/preload/index.ts b/src/preload/index.ts index f621d516432..be8adb9a88e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1579,6 +1579,10 @@ const api = { refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), setPollingInterval: (ms: number): Promise => ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + fetchInactiveClaudeAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + fetchInactiveCodexAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b847d1c7bc6..061897cc376 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -27,7 +27,7 @@ import type { CodexRateLimitAccountsState } from '../../../../shared/types' import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' -import { ProviderIcon, ProviderPanel } from './tooltip' +import { ProviderIcon, ProviderPanel, barColor } from './tooltip' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' @@ -65,6 +65,8 @@ function ClaudeSwitcherMenu({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) + const fetchInactiveClaudeAccountUsage = useAppStore((s) => s.fetchInactiveClaudeAccountUsage) + const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts) const claudeAccountSyncKey = useAppStore((s) => { const settings = s.settings if (!settings) { @@ -90,6 +92,12 @@ function ClaudeSwitcherMenu({ } }, [open]) + useEffect(() => { + if (accountsExpanded) { + void fetchInactiveClaudeAccountUsage() + } + }, [accountsExpanded, fetchInactiveClaudeAccountUsage]) + const handleSelectAccount = async (accountId: string | null): Promise => { if (isSwitching) { return @@ -155,18 +163,34 @@ function ClaudeSwitcherMenu({ {availableSwitchTargets.length === 0 ? (
No other accounts
) : null} - {availableSwitchTargets.map((target) => ( - { - event.preventDefault() - void handleSelectAccount(target.id) - }} - > - {target.label} - - ))} + {availableSwitchTargets.map((target) => { + const inactiveUsage = target.id + ? inactiveClaudeAccounts.find((a) => a.accountId === target.id) + : null + + return ( + { + event.preventDefault() + void handleSelectAccount(target.id) + }} + > +
+ {target.label} + {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( + + ) : inactiveUsage?.claude ? ( + + ) : null} +
+
+ ) + })}
Restart live Claude terminals before continuing old conversations after switching. @@ -205,6 +229,66 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element { ) } +// --------------------------------------------------------------------------- +// Inline usage bars (compact bars for inactive accounts in the switcher) +// --------------------------------------------------------------------------- + +function InlineUsageBars({ + limits, + isFetching +}: { + limits: ProviderRateLimits + isFetching: boolean +}): React.JSX.Element { + const sessionLeft = limits.session + ? Math.max(0, Math.round(100 - limits.session.usedPercent)) + : null + const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null + + return ( +
+ {sessionLeft !== null && ( +
+
+
+
+ + {sessionLeft}% 5h + +
+ )} + {weeklyLeft !== null && ( +
+
+
+
+ + {weeklyLeft}% wk + +
+ )} + {limits.status === 'error' && !limits.session && !limits.weekly && ( + Sign in to see usage + )} +
+ ) +} + +function InlineUsageSkeleton(): React.JSX.Element { + return ( +
+
+
+
+ ) +} + // --------------------------------------------------------------------------- // Window label (shows percent remaining) // --------------------------------------------------------------------------- @@ -336,6 +420,8 @@ function CodexSwitcherMenu({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) + const fetchInactiveCodexAccountUsage = useAppStore((s) => s.fetchInactiveCodexAccountUsage) + const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId) @@ -397,6 +483,12 @@ function CodexSwitcherMenu({ } }, [open]) + useEffect(() => { + if (accountsExpanded) { + void fetchInactiveCodexAccountUsage() + } + }, [accountsExpanded, fetchInactiveCodexAccountUsage]) + const activeAccountLabel = accounts.activeAccountId === null ? 'System default' @@ -461,22 +553,38 @@ function CodexSwitcherMenu({ {availableSwitchTargets.length === 0 ? (
No other accounts
) : null} - {availableSwitchTargets.map((target) => ( - { - // Why: account switching may need an immediate follow-up - // restart action for live Codex tabs. Prevent the menu from - // auto-closing so that prompt can stay within the same - // account-switcher interaction instead of jumping elsewhere. - event.preventDefault() - void handleSelectAccount(target.id) - }} - disabled={isSwitching} - > - {target.label} - - ))} + {availableSwitchTargets.map((target) => { + const inactiveUsage = target.id + ? inactiveCodexAccounts.find((a) => a.accountId === target.id) + : null + + return ( + { + // Why: account switching may need an immediate follow-up + // restart action for live Codex tabs. Prevent the menu from + // auto-closing so that prompt can stay within the same + // account-switcher interaction instead of jumping elsewhere. + event.preventDefault() + void handleSelectAccount(target.id) + }} + disabled={isSwitching} + > +
+ {target.label} + {inactiveUsage?.isFetching && !inactiveUsage.claude ? ( + + ) : inactiveUsage?.claude ? ( + + ) : null} +
+
+ ) + })}
) : null} diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index b7cf6f5e242..acf6bdc4b0a 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -108,7 +108,7 @@ export function getWindowSections( // Why: color-coded by remaining capacity so users can quickly gauge urgency. // Green = comfortable (>40% left), yellow = caution (20-40%), red = critical (<20%). -function barColor(leftPct: number): string { +export function barColor(leftPct: number): string { if (leftPct > 40) { return 'bg-green-500' } diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index d3be60d9085..0f888007dd4 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -6,11 +6,13 @@ export type RateLimitSlice = { rateLimits: RateLimitState fetchRateLimits: () => Promise refreshRateLimits: () => Promise + fetchInactiveClaudeAccountUsage: () => Promise + fetchInactiveCodexAccountUsage: () => Promise setRateLimitsFromPush: (state: RateLimitState) => void } export const createRateLimitSlice: StateCreator = (set) => ({ - rateLimits: { claude: null, codex: null, gemini: null, opencodeGo: null }, + rateLimits: { claude: null, codex: null, gemini: null, opencodeGo: null, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] }, fetchRateLimits: async () => { try { @@ -30,6 +32,22 @@ export const createRateLimitSlice: StateCreator { + try { + await window.api.rateLimits.fetchInactiveClaudeAccounts() + } catch (error) { + console.error('Failed to fetch inactive Claude account usage:', error) + } + }, + + fetchInactiveCodexAccountUsage: async () => { + try { + await window.api.rateLimits.fetchInactiveCodexAccounts() + } catch (error) { + console.error('Failed to fetch inactive Codex account usage:', error) + } + }, + setRateLimitsFromPush: (state) => { set({ rateLimits: state }) } diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7931cea4b21..a796cc663ab 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -32,9 +32,18 @@ export type ProviderRateLimits = { status: ProviderRateLimitStatus } +export type InactiveAccountUsage = { + accountId: string + claude: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] } From 964202c8973b053b21a0c67f0e77f66590d19ddf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:41:26 +0000 Subject: [PATCH 047/259] release: v1.3.25-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2833a17475e..6a1bfd5fce3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.25-rc.3", + "version": "1.3.25-rc.4", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 6f9c97ef99c614cf003ae906b20294ad3f12d78c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:30:28 -0700 Subject: [PATCH 048/259] fix(browser-pane): keep focus in Orca window when destroying focused webview (#1270) Before removing a that currently owns focus, blur the active element and refocus the window. Without this, macOS hands activation to the previously-active app (e.g. Slack) because the focused webContents is gone with no replacement, pulling the user out of Orca on worktree delete. Co-authored-by: Orca --- src/renderer/src/components/browser-pane/BrowserPane.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index c7c0b31c654..ae35fd16455 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -148,6 +148,14 @@ export function destroyPersistentWebview(browserTabId: string): void { return } void window.api.browser.unregisterGuest({ browserPageId: browserTabId }) + // Why: if this webview currently owns focus, removing it lets macOS hand + // activation back to the previously-active app (Slack, etc.) because the + // focused webContents is gone with no replacement. Move focus back into the + // main renderer first so Electron keeps focus inside the Orca window. + if (webview === document.activeElement || webview.contains(document.activeElement)) { + ;(document.activeElement as HTMLElement | null)?.blur?.() + window.focus() + } webview.remove() webviewRegistry.delete(browserTabId) registeredWebContentsIds.delete(browserTabId) From db956ed6db1ef86481fb78455de0b31e622d152d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:37:19 -0700 Subject: [PATCH 049/259] fix(window): prevent teardown resize events from clobbering saved window bounds (#1269) On close/quit (especially auto-updater relaunch via quitAndInstall), Electron/OS emit resize/move/unmaximize while the window is torn down. The debounced persistence was capturing those near-minimum bounds, so the next launch came up at minWidth x minHeight. - Freeze bounds persistence on window 'close' and app 'before-quit' (updater strips the window 'close' listener before quitting, so the app-level latch is needed as a second line of defense). - Release the freeze on will-prevent-unload so aborted Cmd+Q keeps saving bounds. - On restore, discard saved bounds at/below the min dimensions to recover users who already persisted corrupt bounds. Co-authored-by: Orca --- src/main/window/createMainWindow.test.ts | 1 + src/main/window/createMainWindow.ts | 64 ++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 34863909242..56fcf5f9701 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,7 @@ const { browserWindowMock, openExternalMock, attachGuestPoliciesMock, isMock } = })) vi.mock('electron', () => ({ + app: { on: vi.fn(), removeListener: vi.fn() }, BrowserWindow: browserWindowMock, ipcMain: { on: vi.fn(), removeListener: vi.fn() }, nativeTheme: { shouldUseDarkColors: false }, diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 09a29e7d3f0..161e6156305 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, screen, shell } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import icon from '../../../resources/icon.png?asset' @@ -38,6 +38,8 @@ function forceRepaint(window: BrowserWindow): void { const TITLEBAR_CSS_CENTER = 20 const TRAFFIC_LIGHT_RADIUS = 6 const TRAFFIC_LIGHT_X = 16 +const MIN_WIDTH = 600 +const MIN_HEIGHT = 400 function syncTrafficLightPosition(win: BrowserWindow, zoomFactor: number): void { if (process.platform !== 'darwin' || win.isDestroyed()) { @@ -63,7 +65,15 @@ export function createMainWindow( store: Store | null, opts?: CreateMainWindowOptions ): BrowserWindow { - const savedBounds = store?.getUI().windowBounds + const rawSavedBounds = store?.getUI().windowBounds + // Why: defense in depth — if a previous quit/update path persisted + // shrink-to-min bounds (see freezeBoundsOnQuit), discard them on restore + // rather than resurrecting a tiny window. Anything at or below the min + // dimensions is treated as corrupt and falls back to defaultBounds. + const savedBounds = + rawSavedBounds && rawSavedBounds.width > MIN_WIDTH && rawSavedBounds.height > MIN_HEIGHT + ? rawSavedBounds + : undefined const savedMaximized = store?.getUI().windowMaximized ?? false // Why: on first launch (no saved bounds), fill the primary display work area // so the window feels spacious without calling maximize(). Saved bounds still @@ -95,8 +105,8 @@ export function createMainWindow( width: savedBounds?.width ?? defaultBounds.width, height: savedBounds?.height ?? defaultBounds.height, ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), - minWidth: 600, - minHeight: 400, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, show: false, // Why: on macOS the menu lives in the system menu bar, so the in-window // menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar @@ -180,13 +190,21 @@ export function createMainWindow( // position/size instead of maximizing on every launch. Debounce to avoid // hammering the persistence layer during continuous resize drags. let boundsTimer: ReturnType | null = null + // Why: once close has been initiated (user Cmd+Q, auto-updater relaunch, + // app.quit during quitAndInstall), Electron can still emit resize/move/ + // unmaximize events while the OS tears the window down — persisting those + // intermediate, often near-minimum bounds would clobber the user's real + // last-used size and cause the next launch (especially post-update + // relaunch) to come up at minWidth × minHeight. Freeze persistence as soon + // as 'close' is observed. + let windowClosing = false const saveBounds = (): void => { if (boundsTimer) { clearTimeout(boundsTimer) } boundsTimer = setTimeout(() => { boundsTimer = null - if (mainWindow.isDestroyed() || mainWindow.isFullScreen()) { + if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) { return } const isMaximized = mainWindow.isMaximized() @@ -199,10 +217,31 @@ export function createMainWindow( mainWindow.on('resize', saveBounds) mainWindow.on('move', saveBounds) + // Why: the auto-updater install path calls + // `win.removeAllListeners('close')` before quitting, so the per-window + // 'close' handler below never runs for update-triggered relaunches. + // Listen to app-level 'before-quit' as a second latch so resize/move + // events emitted during window teardown don't persist shrink-to-min + // bounds that would be restored on next launch. + const freezeBoundsOnQuit = (): void => { + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } + } + app.on('before-quit', freezeBoundsOnQuit) + mainWindow.on('maximize', () => { + if (windowClosing) { + return + } store?.updateUI({ windowMaximized: true }) }) mainWindow.on('unmaximize', () => { + if (windowClosing) { + return + } store?.updateUI({ windowMaximized: false, windowBounds: mainWindow.getBounds() }) }) @@ -448,6 +487,16 @@ export function createMainWindow( mainWindow.on('close', (e) => { if (windowCloseConfirmed) { windowCloseConfirmed = false + // Why: past this point Electron/OS may emit resize/move/unmaximize as + // the window is destroyed. Freeze bounds persistence so those + // teardown events can't clobber the user's saved window size — which + // would otherwise make the post-update relaunch come up at minWidth × + // minHeight (issue surfaced in v1.3.26-rc2). + windowClosing = true + if (boundsTimer) { + clearTimeout(boundsTimer) + boundsTimer = null + } return } e.preventDefault() @@ -456,6 +505,10 @@ export function createMainWindow( }) }) mainWindow.webContents.on('will-prevent-unload', () => { + // Why: a prevented beforeunload cancels the in-flight quit. Release the + // bounds-persistence freeze so a user who keeps using the window after + // aborting Cmd+Q still gets their size saved. + windowClosing = false opts?.onQuitAborted?.() }) @@ -480,6 +533,7 @@ export function createMainWindow( ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) ipcMain.removeListener(confirmCloseChannel, onConfirmClose) ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) + app.removeListener('before-quit', freezeBoundsOnQuit) }) if (is.dev && process.env.ELECTRON_RENDERER_URL) { From 020780c4dd7882362cef20429806716ec7e2ebe3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:47:04 -0700 Subject: [PATCH 050/259] feat(hooks): restore always-trust repo option Restores repo-level always-trust for orca.yaml hooks and surfaces CLI warnings when --run-hooks is omitted and a hook is skipped. --- src/cli/handlers/worktree.ts | 33 +++++++++++---- src/main/runtime/orca-runtime.test.ts | 9 +++- src/main/runtime/orca-runtime.ts | 20 ++++++--- src/main/runtime/rpc/methods/worktree.ts | 4 +- .../sidebar/OrcaYamlTrustDialog.tsx | 41 +++++++++++++++++-- .../src/lib/ensure-hooks-confirmed.test.ts | 14 +++++++ .../src/lib/ensure-hooks-confirmed.ts | 4 ++ src/renderer/src/store/slices/ui.ts | 17 ++++++++ src/shared/types.ts | 4 ++ 9 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 3f24cfdd30e..7377f817c25 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -14,6 +14,16 @@ import { } from '../flags' import { getRequiredWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' +type HookWarningResult = { + warning?: string +} + +function printHookWarning(result: HookWarningResult, json: boolean): void { + if (!json && result.warning) { + console.error(`warning: ${result.warning}`) + } +} + export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { const result = await client.call('worktree.ps', { @@ -41,14 +51,18 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree create': async ({ flags, client, json }) => { - const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.create', { - repo: getRequiredStringFlag(flags, 'repo'), - name: getRequiredStringFlag(flags, 'name'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - linkedIssue: getOptionalNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true - }) + const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>( + 'worktree.create', + { + repo: getRequiredStringFlag(flags, 'repo'), + name: getRequiredStringFlag(flags, 'name'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + linkedIssue: getOptionalNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true + } + ) + printHookWarning(result.result, json) printResult(result, json, formatWorktreeShow) }, 'worktree set': async ({ flags, client, cwd, json }) => { @@ -61,11 +75,12 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree rm': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ removed: boolean }>('worktree.rm', { + const result = await client.call<{ removed: boolean; warning?: string }>('worktree.rm', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), force: flags.get('force') === true, runHooks: flags.get('run-hooks') === true }) + printHookWarning(result.result, json) printResult(result, json, (value) => `removed: ${value.removed}`) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3a50b194e84..e25338cac62 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1022,7 +1022,9 @@ describe('OrcaRuntimeService', () => { repoId: 'repo-1', path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' - }) + }), + warning: + 'orca.yaml setup hook skipped for /tmp/workspaces/runtime-hook-skip; pass --run-hooks to run it.' }) expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) }) @@ -1036,10 +1038,13 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(removeWorktree).mockResolvedValue(undefined) - await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID) expect(runHook).not.toHaveBeenCalled() expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) + expect(result.warning).toBe( + `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.` + ) }) it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 216f21e17f4..4776a555510 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1035,6 +1035,7 @@ export class OrcaRuntimeService { const worktree = mergeWorktree(repo.id, created, meta) let setup: CreateWorktreeResult['setup'] + let warning: string | undefined const hooks = getEffectiveHooks(repo) if (hooks?.scripts.setup && args.runHooks === true) { if (this.authoritativeWindowId !== null) { @@ -1059,7 +1060,8 @@ export class OrcaRuntimeService { } } else if (hooks?.scripts.setup) { // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - console.info(`[hooks] setup hook skipped for ${worktreePath}; pass --run-hooks to run it`) + warning = `orca.yaml setup hook skipped for ${worktreePath}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) } this.notifier?.worktreesChanged(repo.id) @@ -1077,7 +1079,8 @@ export class OrcaRuntimeService { invalidateAuthorizedRootsCache() return { worktree, - ...(setup ? { setup } : {}) + ...(setup ? { setup } : {}), + ...(warning ? { warning } : {}) } } @@ -1109,7 +1112,7 @@ export class OrcaRuntimeService { worktreeSelector: string, force = false, runHooks = false - ): Promise { + ): Promise<{ warning?: string }> { if (!this.store) { throw new Error('runtime_unavailable') } @@ -1123,6 +1126,7 @@ export class OrcaRuntimeService { } const hooks = getEffectiveHooks(repo) + let warning: string | undefined if (hooks?.scripts.archive && runHooks) { const result = await runHook('archive', worktree.path, repo) if (!result.success) { @@ -1130,7 +1134,8 @@ export class OrcaRuntimeService { } } else if (hooks?.scripts.archive) { // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - console.info(`[hooks] archive hook skipped for ${worktree.path}; pass --run-hooks to run it`) + warning = `orca.yaml archive hook skipped for ${worktree.path}; pass --run-hooks to run it.` + console.warn(`[hooks] ${warning}`) } try { @@ -1147,7 +1152,9 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) - return + return { + ...(warning ? { warning } : {}) + } } throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) } @@ -1156,6 +1163,9 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) + return { + ...(warning ? { warning } : {}) + } } async renameTerminal(handle: string, title: string | null): Promise { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index fafd86585e7..8f5bd832a5e 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -95,12 +95,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - await runtime.removeManagedWorktree( + const result = await runtime.removeManagedWorktree( params.worktree, params.force === true, params.runHooks === true ) - return { removed: true } + return { removed: true, ...result } } }) ] diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx index 1cbd7fefcb9..07480ee779c 100644 --- a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { Dialog, DialogContent, @@ -30,6 +30,8 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const markOrcaHookScriptConfirmed = useAppStore((s) => s.markOrcaHookScriptConfirmed) + const markOrcaHookRepoAlwaysTrusted = useAppStore((s) => s.markOrcaHookRepoAlwaysTrusted) + const [alwaysTrust, setAlwaysTrust] = useState(false) const isOpen = activeModal === 'confirm-orca-yaml-hooks' @@ -48,15 +50,34 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { ? (modalData.onResolve as (decision: 'run' | 'skip') => void) : null + useEffect(() => { + if (isOpen) { + setAlwaysTrust(false) + } + }, [isOpen]) + const resolveAndClose = useCallback( (decision: 'run' | 'skip') => { - if (decision === 'run' && repoId && contentHash) { - markOrcaHookScriptConfirmed(repoId, scriptKind, contentHash) + if (decision === 'run' && repoId) { + if (alwaysTrust) { + markOrcaHookRepoAlwaysTrusted(repoId) + } else if (contentHash) { + markOrcaHookScriptConfirmed(repoId, scriptKind, contentHash) + } } onResolve?.(decision) closeModal() }, - [closeModal, contentHash, markOrcaHookScriptConfirmed, onResolve, repoId, scriptKind] + [ + alwaysTrust, + closeModal, + contentHash, + markOrcaHookRepoAlwaysTrusted, + markOrcaHookScriptConfirmed, + onResolve, + repoId, + scriptKind + ] ) const handleOpenChange = useCallback( @@ -93,6 +114,18 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
)} + + diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 85aa69e514d..5d1c9478a6e 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,26 +1,20 @@ import React from 'react' -import { Github, List } from 'lucide-react' +import { List } from 'lucide-react' import { useAppStore } from '@/store' import { useRepoMap } from '@/store/selectors' import { cn } from '@/lib/utils' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace' -function LinearIcon({ className }: { className?: string }): React.JSX.Element { - return ( - - - - ) -} - const SidebarNav = React.memo(function SidebarNav() { const openTaskPage = useAppStore((s) => s.openTaskPage) const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo)) - const showTaskProviderIcons = useAppStore((s) => s.settings?.showTaskProviderIcons !== false) + // Why: the setting is opt-out (default true). `!== false` keeps the button + // visible for users whose persisted settings predate this field. + const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) // Why: warm the GitHub work-item cache on hover/focus so by the time the // user's click finishes the round-trip has either completed or is already @@ -51,6 +45,10 @@ const SidebarNav = React.memo(function SidebarNav() { const tasksActive = activeView === 'tasks' + if (!showTasksButton) { + return null + } + return (
) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index eb8e760c1e1..14a23508543 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -178,6 +178,9 @@ describe('useIpcEvents updater integration', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: (listener: (status: unknown) => void) => { @@ -365,6 +368,9 @@ describe('useIpcEvents updater integration', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -550,6 +556,9 @@ describe('useIpcEvents updater integration', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -747,6 +756,9 @@ describe('useIpcEvents browser tab close routing', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -929,6 +941,9 @@ describe('useIpcEvents browser tab close routing', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -1106,6 +1121,9 @@ describe('useIpcEvents browser tab close routing', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -1292,6 +1310,9 @@ describe('useIpcEvents shortcut hint clearing', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, @@ -1485,6 +1506,9 @@ describe('useIpcEvents CLI-created worktree activation', () => { getZoomLevel: () => 0, set: vi.fn() }, + settings: { + onChanged: () => () => {} + }, updater: { getStatus: () => Promise.resolve({ state: 'idle' }), onStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 74cce8e0dfb..29496a806c6 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -45,6 +45,30 @@ export function useIpcEvents(): void { }) ) + // Why: the View > Appearance menu toggles settings directly in main (so + // checkbox state reflects the persisted value without a round-trip) and + // broadcasts the change. Merge it into the store so the sidebar and + // titlebar re-render immediately instead of waiting for the next + // fetchSettings() call. + unsubs.push( + window.api.settings.onChanged((updates) => { + const store = useAppStore.getState() + if (!store.settings) { + return + } + useAppStore.setState({ + settings: { + ...store.settings, + ...updates, + notifications: { + ...store.settings.notifications, + ...updates.notifications + } + } + }) + }) + ) + unsubs.push( window.api.ui.onToggleLeftSidebar(() => { dispatchClearModifierHints() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 57af1c5343b..15c648fb8e0 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -160,7 +160,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInApp: true, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showTaskProviderIcons: true, + showTasksButton: true, notifications: getDefaultNotificationSettings(), diffDefaultView: 'inline', promptCacheTimerEnabled: false, diff --git a/src/shared/types.ts b/src/shared/types.ts index 716edd879cd..592e494e1de 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -920,9 +920,10 @@ export type GlobalSettings = { rightSidebarOpenByDefault: boolean /** Whether to show the live agent activity count badge in the titlebar. */ showTitlebarAgentActivity: boolean - /** Why: the Tasks sidebar label can be kept cleaner for users who do not - * actively use the GitHub/Linear integrations behind it. */ - showTaskProviderIcons: boolean + /** Why: some users do not use the Tasks feature and prefer to keep the + * left sidebar free of its button entirely. Hiding the button here also + * removes it from keyboard navigation. */ + showTasksButton: boolean diffDefaultView: 'inline' | 'side-by-side' notifications: NotificationSettings /** When true, a countdown timer is shown after a Claude agent becomes idle, From 066c245d14d78451a61decf4490e27632a8a9089 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:33:36 -0700 Subject: [PATCH 052/259] fix(terminal): force WebGL repaint after split when dimensions match (#1272) Co-authored-by: Orca --- .../use-terminal-pane-lifecycle.ts | 3 +- .../pane-fit-resize-observer.test.ts | 1 + .../lib/pane-manager/pane-lifecycle.test.ts | 3 +- .../src/lib/pane-manager/pane-lifecycle.ts | 3 +- .../lib/pane-manager/pane-manager-types.ts | 5 + .../lib/pane-manager/pane-tree-ops.test.ts | 3 +- .../src/lib/pane-manager/pane-tree-ops.ts | 23 ++- .../src/runtime/sync-runtime-graph.ts | 12 +- tests/e2e/dead-terminal-repro.spec.ts | 168 +++++++++++++++++ tests/e2e/dead-terminal-stress.spec.ts | 151 +++++++++++++++ tests/e2e/global-setup.ts | 9 +- tests/e2e/helpers/dead-terminal.ts | 175 ++++++++++++++++++ tests/e2e/helpers/runtime-types.ts | 2 + 13 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/dead-terminal-repro.spec.ts create mode 100644 tests/e2e/dead-terminal-stress.spec.ts create mode 100644 tests/e2e/helpers/dead-terminal.ts 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 5136ef26248..72fb203f041 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 @@ -663,7 +663,8 @@ export function useTerminalPaneLifecycle({ // Why: TerminalPane instances stay mounted for hidden visited worktrees // so PTYs survive navigation. Creating WebGL for those offscreen panes // still consumes Chromium's context budget and can blank visible panes. - initialRenderingSuspended: !isVisibleRef.current + initialRenderingSuspended: !isVisibleRef.current, + debugLabel: `tab:${tabId}/wt:${worktreeId}` }) managerRef.current = manager diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index fda07eeea5b..e40d81f435c 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -58,6 +58,7 @@ function createPane(): ManagedPaneInternal { webglAddon: null, ligaturesAddon: null, compositionHandler: null, + debugLabel: null, pendingSplitScrollState: { wasAtBottom: true, firstVisibleLineContent: '', diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 41e3f02cec2..74623962154 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -45,7 +45,8 @@ function createPane(): ManagedPaneInternal { webLinksAddon: {} as never, webglAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: null } } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 33281244df4..297fd5e6f27 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -121,7 +121,8 @@ export function createPaneDOM( webglAddon: null, ligaturesAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: options.debugLabel ?? null } // Focus handler: clicking a pane makes it active and explicitly focuses diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 84ce99705ff..0fd7dfb18eb 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -28,6 +28,10 @@ export type PaneManagerOptions = { terminalOptions?: (paneId: number) => Partial onLinkClick?: (event: MouseEvent | undefined, url: string) => void initialRenderingSuspended?: boolean + // Why: diagnostic label for log correlation. safeFit and other internal + // helpers log warnings that are hard to correlate without knowing which + // tab/worktree the PaneManager belongs to. + debugLabel?: string } export type PaneStyleOptions = { @@ -91,6 +95,7 @@ export type ManagedPaneInternal = { // intermediate fit paths skip their own scroll restoration, deferring to // the splitPane's final authoritative restore. pendingSplitScrollState: ScrollState | null + debugLabel: string | null } & ManagedPane export type DropZone = 'top' | 'bottom' | 'left' | 'right' diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index adaf155ae20..e52585fb05b 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -52,7 +52,8 @@ function createPane({ webglAddon: null, ligaturesAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: null } } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3187fecd43b..31b37b8c3cc 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -41,18 +41,27 @@ export function safeFit(pane: ManagedPaneInternal): void { // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() // churn, which was causing visible terminal blinking while resizing. // - // Why: diagnostic for intermittent dead-terminal-after-split. If a - // just-reparented pane's proposed dimensions match its current - // dimensions (the default 80×24 at certain screen widths), this - // early-return skips fitAddon.fit() and no terminal.resize() fires - // — leaving the WebGL canvas at stale dimensions. + // Why: wrapInSplit() reparents the pane's container, which can leave + // the WebGL canvas stale even when proposed dimensions match current + // (the browser detaches and reattaches the canvas during the DOM move). + // When pendingSplitScrollState is set we must force a fit + refresh so + // the WebGL renderer repaints. Without this, the pane appears blank + // until something forces a dimension change. if (pane.pendingSplitScrollState) { console.warn( - '[terminal] safeFit early-return during pending split for pane', + '[terminal] safeFit forcing fit+refresh during pending split for pane', pane.id, `— dims ${dims.cols}×${dims.rows} match current, webgl:`, - !!pane.webglAddon + !!pane.webglAddon, + pane.debugLabel ? `(${pane.debugLabel})` : '' ) + pane.fitAddon.fit() + try { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } catch { + /* ignore — terminal may not be fully initialised */ + } + return } return } diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index f7502bdeb74..53dee0b6f6b 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -13,6 +13,13 @@ type RegisteredTerminalTab = { } const registeredTabs = new Map() +// Why: track when each tab was registered so we can suppress the "no live +// transport" warning during the initial PTY connection window. The warning +// is noise when it fires on mount (PTY spawn/attach is async and hasn't +// finished yet), but valuable if the transport is still missing after the +// grace period — that indicates a real stuck state. +const tabRegisteredAt = new Map() +const NO_TRANSPORT_GRACE_MS = 10_000 let syncScheduled = false let syncEnabled = false let getStoreState: (() => AppState) | null = null @@ -23,9 +30,11 @@ export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null) export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void { registeredTabs.set(tab.tabId, tab) + tabRegisteredAt.set(tab.tabId, Date.now()) scheduleRuntimeGraphSync() return () => { registeredTabs.delete(tab.tabId) + tabRegisteredAt.delete(tab.tabId) scheduleRuntimeGraphSync() } } @@ -89,7 +98,8 @@ async function syncRuntimeGraph(): Promise { const leafId = paneLeafId(pane.id) const ptyId = registeredTab.getPtyIdForPane(pane.id) const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null - if (!ptyId && savedPtyId) { + const registeredTime = tabRegisteredAt.get(tabId) ?? 0 + if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { tabId, worktreeId: registeredTab.worktreeId, diff --git a/tests/e2e/dead-terminal-repro.spec.ts b/tests/e2e/dead-terminal-repro.spec.ts new file mode 100644 index 00000000000..dce799ef75a --- /dev/null +++ b/tests/e2e/dead-terminal-repro.spec.ts @@ -0,0 +1,168 @@ +/** + * Stress test for dead-terminal reproduction (setup-split flow). + * + * Why @headful: the dead-terminal bug is a WebGL canvas staleness issue — after + * wrapInSplit() reparents the existing pane's container, the WebGL canvas can + * fail to repaint. In headless mode WebGL is NEVER active, so the DOM fallback + * renderer is used and the bug cannot manifest. Running headful ensures real + * WebGL contexts matching production. + * + * See helpers/dead-terminal.ts for the shared worktree-creation helper that + * replicates the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal + * production flow. + */ + +import { test, expect } from './helpers/orca-app' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + switchToWorktree, + ensureTerminalVisible +} from './helpers/store' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + createAndActivateWorktreeWithSetup, + removeWorktreeViaStore, + waitForAllPanesToHaveContent, + checkWebglState +} from './helpers/dead-terminal' + +const STRESS_ITERATIONS = 5 + +test.describe('Dead Terminal Reproduction @headful', () => { + const createdWorktreeIds: string[] = [] + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + if (!state) { + return + } + state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + + // Why: write orca.yaml into the repo so createWorktree IPC returns a + // WorktreeSetupLaunch with a runner script, triggering the setup split. + // This is scoped to the dead-terminal tests to avoid breaking other + // specs that don't expect setup scripts to fire on worktree creation. + const wt = Object.values(state.worktreesByRepo) + .flat() + .find((w) => w.id === state.activeWorktreeId) + if (wt) { + const sep = wt.path.includes('\\') ? '\\' : '/' + await window.api.fs.writeFile({ + filePath: `${wt.path}${sep}orca.yaml`, + content: 'scripts:\n setup: echo SETUP_COMPLETE\n' + }) + } + }) + }) + + test.afterEach(async ({ orcaPage }) => { + for (const id of createdWorktreeIds) { + await removeWorktreeViaStore(orcaPage, id) + } + createdWorktreeIds.length = 0 + }) + + test('@headful setup-split flow does not produce dead terminals', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await checkWebglState(orcaPage, 'home-initial') + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const direction = i % 2 === 0 ? 'vertical' : 'horizontal' + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `setup-${i}`, direction) + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await checkWebglState(orcaPage, `setup-${i}`) + await waitForAllPanesToHaveContent(orcaPage, `setup-${i} both panes`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + test('@headful setup-split then switch-back does not leave panes dead', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const newId = await createAndActivateWorktreeWithSetup( + orcaPage, + `switchback-${i}`, + 'vertical' + ) + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} initial`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + + await switchToWorktree(orcaPage, newId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} after return`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + test('@headful rapid switching between many setup-split worktrees', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const worktreeIds = [homeWorktreeId] + for (let i = 0; i < 4; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `multi-${i}`, 'vertical') + createdWorktreeIds.push(newId) + worktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `multi-create-${i}`) + } + + for (let round = 0; round < 3; round++) { + for (const wId of worktreeIds) { + await switchToWorktree(orcaPage, wId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(wId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `multi-r${round}-${wId.slice(0, 8)}`) + } + } + }) +}) diff --git a/tests/e2e/dead-terminal-stress.spec.ts b/tests/e2e/dead-terminal-stress.spec.ts new file mode 100644 index 00000000000..8ffb1a614d4 --- /dev/null +++ b/tests/e2e/dead-terminal-stress.spec.ts @@ -0,0 +1,151 @@ +/** + * Aggressive stress tests for dead-terminal reproduction. + * + * These tests target specific failure vectors beyond the basic setup-split flow: + * - Forced WebGL context loss (simulating Chromium memory pressure) + * - Rapid switching during the ~200ms scheduleSplitScrollRestore window + * + * All tests require @headful mode for WebGL to be active. + */ + +import { test, expect } from './helpers/orca-app' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + switchToWorktree, + ensureTerminalVisible +} from './helpers/store' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + createAndActivateWorktreeWithSetup, + removeWorktreeViaStore, + waitForAllPanesToHaveContent +} from './helpers/dead-terminal' + +const STRESS_ITERATIONS = 5 + +test.describe('Dead Terminal Stress @headful', () => { + const createdWorktreeIds: string[] = [] + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + if (!state) { + return + } + state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + + const wt = Object.values(state.worktreesByRepo) + .flat() + .find((w) => w.id === state.activeWorktreeId) + if (wt) { + const sep = wt.path.includes('\\') ? '\\' : '/' + await window.api.fs.writeFile({ + filePath: `${wt.path}${sep}orca.yaml`, + content: 'scripts:\n setup: echo SETUP_COMPLETE\n' + }) + } + }) + }) + + test.afterEach(async ({ orcaPage }) => { + for (const id of createdWorktreeIds) { + await removeWorktreeViaStore(orcaPage, id) + } + createdWorktreeIds.length = 0 + }) + + /** + * Force WebGL context loss on visible canvases immediately after a setup + * split. In production, Chromium reclaims WebGL contexts under memory + * pressure — especially with many worktrees open. The recovery path is: + * onContextLoss → dispose WebGL → DOM fallback → rAF → fit + refresh. + */ + test('@headful setup-split with forced WebGL context loss recovers', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `ctxloss-${i}`, 'vertical') + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + + const lostCount = await orcaPage.evaluate(() => { + const canvases = document.querySelectorAll('.pane canvas:not(.xterm-link-layer)') + let lost = 0 + for (const canvas of canvases) { + const gl = + (canvas as HTMLCanvasElement).getContext('webgl2') ?? + (canvas as HTMLCanvasElement).getContext('webgl') + if (gl) { + const ext = gl.getExtension('WEBGL_lose_context') + if (ext) { + ext.loseContext() + lost++ + } + } + } + return lost + }) + if (lostCount > 0) { + console.log(`[ctxloss-${i}] Forced context loss on ${lostCount} canvases`) + } + + await orcaPage.waitForTimeout(500) + await waitForAllPanesToHaveContent(orcaPage, `ctxloss-${i} after context loss`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + /** + * Switch worktrees WITHOUT waiting for the split to settle. This hits the + * race between wrapInSplit() reparenting, WebGL context creation during + * resumeRendering(), and the scheduleSplitScrollRestore 200ms timer. + */ + test('@headful rapid worktree switching during setup-split lifecycle', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < 3; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `rapid-${i}`, 'vertical') + createdWorktreeIds.push(newId) + + // Switch away during the ~200ms scheduleSplitScrollRestore window + await orcaPage.waitForTimeout(50) + await switchToWorktree(orcaPage, homeWorktreeId) + await orcaPage.waitForTimeout(50) + + // Switch back — triggers resumeRendering on partially-initialized panes + await switchToWorktree(orcaPage, newId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `rapid-${i} after return`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) +}) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index ef4eaa03704..cfb96a1b2a6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -33,7 +33,7 @@ export default function globalSetup(): void { execSync('npx electron-vite build --mode e2e', { cwd: root, stdio: 'inherit', - timeout: 120_000, + timeout: 120_000 }) console.log('[e2e] Build complete.') } @@ -53,10 +53,7 @@ export default function globalSetup(): void { path.join(testRepoDir, 'README.md'), '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' ) - writeFileSync( - path.join(testRepoDir, 'CLAUDE.md'), - '# CLAUDE.md\n\nTest instructions for E2E.\n' - ) + writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') writeFileSync( path.join(testRepoDir, 'package.json'), `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` @@ -74,7 +71,7 @@ export default function globalSetup(): void { const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${Date.now()}`) execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { cwd: testRepoDir, - stdio: 'pipe', + stdio: 'pipe' }) console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts new file mode 100644 index 00000000000..b26a2a178a0 --- /dev/null +++ b/tests/e2e/helpers/dead-terminal.ts @@ -0,0 +1,175 @@ +/** + * Shared helpers for dead-terminal reproduction tests. + * + * These helpers exercise the real production worktree-creation-with-setup-split + * flow and verify that terminal panes render content after the split. + */ + +import { expect } from '@playwright/test' +import type { getActiveWorktreeId } from './store' + +type TestPage = Parameters[0] + +/** + * Create a worktree with setup via the real IPC flow, then activate it + * replicating the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal + * path. The setup split is queued on the tab BEFORE the TerminalPane mounts, + * matching production timing. + */ +export async function createAndActivateWorktreeWithSetup( + page: TestPage, + suffix: string, + direction: 'vertical' | 'horizontal' +): Promise { + const name = `e2e-dead-term-${suffix}-${Date.now()}` + return page.evaluate( + async ({ worktreeName, direction }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const activeWorktreeId = state.activeWorktreeId + if (!activeWorktreeId) { + throw new Error('No active worktree') + } + + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((wt) => wt.id === activeWorktreeId) + if (!activeWorktree) { + throw new Error('Active worktree not found in store') + } + + const result = await state.createWorktree( + activeWorktree.repoId, + worktreeName, + undefined, + 'run' + ) + await state.fetchWorktrees(activeWorktree.repoId) + const worktreeId = result.worktree.id + + if (activeWorktree.repoId !== state.activeRepoId) { + state.setActiveRepo(activeWorktree.repoId) + } + if (store.getState().activeView !== 'terminal') { + state.setActiveView('terminal') + } + state.setActiveWorktree(worktreeId) + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (renderableTabCount > 0) { + return worktreeId + } + + const tab = state.createTab(worktreeId, undefined, undefined, { + pendingActivationSpawn: true + }) + state.setActiveTab(tab.id) + + if (result.setup) { + const runnerPath = result.setup.runnerScriptPath + const command = `bash ${runnerPath}` + state.queueTabSetupSplit(tab.id, { + command, + env: result.setup.envVars, + direction + }) + } + + state.revealWorktreeInSidebar(worktreeId) + return worktreeId + }, + { worktreeName: name, direction } + ) +} + +export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise { + await page.evaluate(async (id) => { + try { + await window.__store?.getState().removeWorktree(id, true) + } catch { + /* best-effort */ + } + }, worktreeId) +} + +/** + * Poll until every pane in the active tab's PaneManager has non-empty buffer + * content. A dead terminal has an empty serialize() result because the WebGL + * canvas never painted the shell prompt. + */ +export async function waitForAllPanesToHaveContent( + page: TestPage, + label: string, + timeoutMs = 15_000 +): Promise { + await expect + .poll( + async () => { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return { ok: false, reason: 'no store' } + } + const state = store.getState() + const wId = state.activeWorktreeId + if (!wId) { + return { ok: false, reason: 'no active worktree' } + } + const tabs = state.tabsByWorktree[wId] ?? [] + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[wId] ?? tabs[0]?.id) + if (!tabId) { + return { ok: false, reason: 'no tab' } + } + + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return { ok: false, reason: 'no manager' } + } + const panes = manager.getPanes?.() ?? [] + if (panes.length === 0) { + return { ok: false, reason: 'no panes' } + } + + const paneStates = panes.map((pane) => { + const content = pane.serializeAddon?.serialize?.() ?? '' + // oxlint-disable-next-line no-control-regex -- stripping terminal control chars is intentional + const stripped = content.replace(/[\s\x00-\x1f]/g, '') + return { id: pane.id, hasContent: stripped.length > 0 } + }) + + return { ok: paneStates.every((p) => p.hasContent), paneStates } + }) + }, + { + timeout: timeoutMs, + message: `[${label}] Not all terminal panes have rendered content (possible dead terminal)` + } + ) + .toMatchObject({ ok: true }) +} + +/** + * Log WebGL canvas state for diagnostics. In headful mode, visible panes + * should have WebGL canvases; hidden panes (suspended rendering) should not. + */ +export async function checkWebglState(page: TestPage, label: string): Promise { + const paneStates = await page.evaluate(() => { + const containers = document.querySelectorAll('.pane[data-pane-id]') + return Array.from(containers).map((c) => ({ + paneId: (c as HTMLElement).dataset.paneId, + canvasCount: c.querySelectorAll('canvas').length + })) + }) + + const hasCanvas = paneStates.some((p) => p.canvasCount > 0) + if (!hasCanvas) { + console.warn(`[${label}] No WebGL canvases — DOM renderer only.`) + } +} diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 593cdb6d46c..a376e2416da 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -19,6 +19,8 @@ export type PaneManagerLike = { splitPane?(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null closePane?(paneId: number): void setActivePane?(paneId: number, opts?: { focus?: boolean }): void + suspendRendering?(): void + resumeRendering?(): void } export type ExplorerFileSummary = Pick From fb3d46710b9ae07b1b60f7d3ae35629460201743 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:50:41 -0700 Subject: [PATCH 053/259] fix(mac-perms): fall through to Privacy pane when in-app prompt is a no-op (#1273) Co-authored-by: Orca --- src/main/ipc/developer-permissions.ts | 25 ++++++++++++++++--- .../settings/DeveloperPermissionsPane.tsx | 12 +++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index 60b4ea887d2..060eb7a3877 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -147,13 +147,32 @@ async function requestPermission( } if (id === 'microphone' || id === 'camera') { + // Why: askForMediaAccess only surfaces the TCC prompt when status is + // 'not-determined'. If the user previously denied (or macOS set the status + // to 'denied'/'restricted'), it resolves false with no prompt — leaving + // the user stuck. Fall through to the Privacy pane so they can toggle it. const granted = await systemPreferences.askForMediaAccess(id) - return { id, status: granted ? 'granted' : getMediaStatus(id), openedSystemSettings: false } + if (granted) { + return { id, status: 'granted', openedSystemSettings: false } + } + const status = getMediaStatus(id) + if (status === 'denied' || status === 'restricted' || status === 'unknown') { + await openPrivacyPane(id) + return { id, status, openedSystemSettings: true } + } + return { id, status, openedSystemSettings: false } } if (id === 'accessibility') { - systemPreferences.isTrustedAccessibilityClient(true) - return { id, status: getAccessibilityStatus(), openedSystemSettings: false } + // Why: isTrustedAccessibilityClient(true) shows the prompt only the first + // time for a given bundle. Once the user has dismissed or denied, calling + // it again is a no-op. Fall through to the Privacy pane when not granted. + const trusted = systemPreferences.isTrustedAccessibilityClient(true) + if (trusted) { + return { id, status: 'granted', openedSystemSettings: false } + } + await openPrivacyPane(id) + return { id, status: getAccessibilityStatus(), openedSystemSettings: true } } if (id === 'automation') { diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index eead3e04e1c..1273fda68fd 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -179,6 +179,18 @@ export function DeveloperPermissionsPane(): React.JSX.Element { void refresh() }, [refresh]) + // Why: after the user flips a permission in System Settings and switches + // back to Orca, the chip should reflect the new status without a manual + // Refresh click. Tied to window focus rather than a polling interval so + // we don't keep hammering `systemPreferences` while the pane is idle. + useEffect(() => { + const onFocus = (): void => { + void refresh() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [refresh]) + const request = async (id: DeveloperPermissionId): Promise => { setPendingId(id) try { From 61909fff032f8d7a66e30c2134391dbaf3231538 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:02:46 -0700 Subject: [PATCH 054/259] fix(tab-bar): dismiss tab context menu on window blur for webview clicks (#1274) Electron elements run in a separate process, so clicks inside them don't dispatch pointerdown on the renderer document. Radix DropdownMenu relies on document pointerdown for outside-click detection and therefore misses webview clicks, leaving the menu stuck open. Listening for window blur catches the moment focus leaves the renderer (including into a webview) and closes the menu. Co-authored-by: Orca --- src/renderer/src/components/tab-bar/BrowserTab.tsx | 14 ++++++++++++++ .../src/components/tab-bar/EditorFileTab.tsx | 14 ++++++++++++++ .../src/components/tab-bar/SortableTab.tsx | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 8c2e62b752a..3d960dc4006 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -97,6 +97,20 @@ export default function BrowserTab({ return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) }, []) + // Why: Electron elements run in a separate process, so clicking + // inside one never dispatches a pointerdown on the renderer document. Radix + // DropdownMenu relies on document pointerdown for outside-click detection, + // so it misses webview clicks. Listening for window blur catches the moment + // focus leaves the renderer (including into a webview). + useEffect(() => { + if (!menuOpen) { + return + } + const dismiss = (): void => setMenuOpen(false) + window.addEventListener('blur', dismiss) + return () => window.removeEventListener('blur', dismiss) + }, [menuOpen]) + return ( <>
window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) }, []) + // Why: Electron elements run in a separate process, so clicking + // inside one never dispatches a pointerdown on the renderer document. Radix + // DropdownMenu relies on document pointerdown for outside-click detection, + // so it misses webview clicks. Listening for window blur catches the moment + // focus leaves the renderer (including into a webview). + useEffect(() => { + if (!menuOpen) { + return + } + const dismiss = (): void => setMenuOpen(false) + window.addEventListener('blur', dismiss) + return () => window.removeEventListener('blur', dismiss) + }, [menuOpen]) + return ( <>
window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) }, []) + // Why: Electron elements run in a separate process, so clicking + // inside one never dispatches a pointerdown on the renderer document. Radix + // DropdownMenu relies on document pointerdown for outside-click detection, + // so it misses webview clicks. Listening for window blur catches the moment + // focus leaves the renderer (including into a webview). + useEffect(() => { + if (!menuOpen) { + return + } + const dismiss = (): void => setMenuOpen(false) + window.addEventListener('blur', dismiss) + return () => window.removeEventListener('blur', dismiss) + }, [menuOpen]) + // Why: while editing, suppress dnd-kit drag listeners and tab-activation/double-click // handlers so typing/clicking inside the inline input doesn't start a drag, re-open the // editor, or steal focus away from the input. We still spread `attributes` unconditionally From 4318f3bfa79511d899760a349ac1f4482b07f00a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:09:46 -0700 Subject: [PATCH 055/259] chore: reduce root-directory clutter (#1275) Co-authored-by: Orca --- CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 .github/workflows/e2e.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/release-cut.yml | 2 +- .github/workflows/release-rc.yml | 2 +- .github/workflows/release.yml | 2 +- .nvmrc | 1 - .../@xterm__addon-ligatures@0.11.0-beta.198.patch | 0 {patches => config/patches}/node-pty@1.1.0.patch | 0 {scripts => config/scripts}/build-relay.mjs | 0 config/scripts/install-dev-cli.mjs | 4 ++-- {bin => config/scripts}/orca-dev | 2 +- .../scripts}/rebuild-native-deps.mjs | 0 package.json | 15 +++++++++------ pnpm-lock.yaml | 4 ++-- .../src/lib/pane-manager/pane-lifecycle.ts | 2 +- vitest.config.ts | 3 --- 17 files changed, 20 insertions(+), 21 deletions(-) rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) delete mode 100644 .nvmrc rename {patches => config/patches}/@xterm__addon-ligatures@0.11.0-beta.198.patch (100%) rename {patches => config/patches}/node-pty@1.1.0.patch (100%) rename {scripts => config/scripts}/build-relay.mjs (100%) rename {bin => config/scripts}/orca-dev (95%) rename {scripts => config/scripts}/rebuild-native-deps.mjs (100%) delete mode 100644 vitest.config.ts diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cbfabad4d16..08ce81990ad 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1472f7472b7..e85f321bca8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c023bd3fd22..2a8159c927a 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -60,7 +60,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Configure git author run: | diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 3641ba6faa6..3ebdc8c2b31 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json - name: Configure git author run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43a9170d22c..bda260be9aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,7 +122,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: package.json cache: pnpm # Why: release builds hit the same native-module postinstall path as diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index a45fd52cc58..00000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24 diff --git a/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch b/config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch similarity index 100% rename from patches/@xterm__addon-ligatures@0.11.0-beta.198.patch rename to config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch diff --git a/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch similarity index 100% rename from patches/node-pty@1.1.0.patch rename to config/patches/node-pty@1.1.0.patch diff --git a/scripts/build-relay.mjs b/config/scripts/build-relay.mjs similarity index 100% rename from scripts/build-relay.mjs rename to config/scripts/build-relay.mjs diff --git a/config/scripts/install-dev-cli.mjs b/config/scripts/install-dev-cli.mjs index 1c74e797d71..ddd5226820f 100644 --- a/config/scripts/install-dev-cli.mjs +++ b/config/scripts/install-dev-cli.mjs @@ -6,8 +6,8 @@ import { execFileSync } from 'node:child_process' import path from 'node:path' import { fileURLToPath } from 'node:url' -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') -const source = path.join(repoRoot, 'bin', 'orca-dev') +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const source = path.join(scriptDir, 'orca-dev') const commandPath = process.platform === 'darwin' || process.platform === 'linux' diff --git a/bin/orca-dev b/config/scripts/orca-dev similarity index 95% rename from bin/orca-dev rename to config/scripts/orca-dev index 97acbf9f1f7..47bdb1c5b56 100755 --- a/bin/orca-dev +++ b/config/scripts/orca-dev @@ -10,7 +10,7 @@ while [ -L "$SOURCE" ]; do [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" done SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" CLI_ENTRY="$REPO_ROOT/out/cli/index.js" if [ ! -f "$CLI_ENTRY" ]; then diff --git a/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs similarity index 100% rename from scripts/rebuild-native-deps.mjs rename to config/scripts/rebuild-native-deps.mjs diff --git a/package.json b/package.json index 6a1bfd5fce3..106e1af51ae 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "author": "stablyai", "bin": { "orca": "./out/cli/index.js", - "orca-dev": "./bin/orca-dev" + "orca-dev": "./config/scripts/orca-dev" }, "main": "./out/main/index.js", "scripts": { @@ -24,13 +24,13 @@ "typecheck": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", - "build:relay": "node scripts/build-relay.mjs", + "build:relay": "node config/scripts/build-relay.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", "build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli", - "postinstall": "pnpm rebuild electron && node scripts/rebuild-native-deps.mjs", - "rebuild:electron": "node scripts/rebuild-native-deps.mjs", + "postinstall": "pnpm rebuild electron && node config/scripts/rebuild-native-deps.mjs", + "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", @@ -149,6 +149,9 @@ "oxfmt --write" ] }, + "engines": { + "node": "24" + }, "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "pnpm": { "onlyBuiltDependencies": [ @@ -160,8 +163,8 @@ "node-pty" ], "patchedDependencies": { - "node-pty@1.1.0": "patches/node-pty@1.1.0.patch", - "@xterm/addon-ligatures@0.11.0-beta.198": "patches/@xterm__addon-ligatures@0.11.0-beta.198.patch" + "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", + "@xterm/addon-ligatures@0.11.0-beta.198": "config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95bfd66b061..c2335eb9b1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,10 @@ settings: patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.198': hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634 - path: patches/@xterm__addon-ligatures@0.11.0-beta.198.patch + path: config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch node-pty@1.1.0: hash: 02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322 - path: patches/node-pty@1.1.0.patch + path: config/patches/node-pty@1.1.0.patch importers: diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 297fd5e6f27..9e8605381e3 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -3,7 +3,7 @@ import type { ITerminalOptions } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' // Upstream packaging bug: @xterm/addon-ligatures declares `"main": // "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to -// resolve the bare import. Fixed locally via patches/@xterm__addon-ligatures*. +// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*. // Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 — drop // the patch once that lands. import { LigaturesAddon } from '@xterm/addon-ligatures' diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 614b53e4ac3..00000000000 --- a/vitest.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import config from './config/vitest.config' - -export default config From 8a4e64519eef356a7a706d1d096d5fbb6e2e3abf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:10:20 -0700 Subject: [PATCH 056/259] Delete .editorconfig --- .editorconfig | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index cf640d53fbc..00000000000 --- a/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true \ No newline at end of file From 8c83c8c9719b69e458e9a590f56dd4071d80f0d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:11:14 -0700 Subject: [PATCH 057/259] chore: move dev-app-update.yml into config/ (#1276) Co-authored-by: Orca --- dev-app-update.yml => config/dev-app-update.yml | 0 config/electron-builder.config.cjs | 2 +- src/main/updater.ts | 6 ++++++ 3 files changed, 7 insertions(+), 1 deletion(-) rename dev-app-update.yml => config/dev-app-update.yml (100%) diff --git a/dev-app-update.yml b/config/dev-app-update.yml similarity index 100% rename from dev-app-update.yml rename to config/dev-app-update.yml diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 17b3d51101b..aae41fe4025 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,7 +14,7 @@ module.exports = { '!**/.vscode/*', '!src/*', '!electron.vite.config.{js,ts,mjs,cjs}', - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}', + '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', '!tsconfig.json', '!config/*' diff --git a/src/main/updater.ts b/src/main/updater.ts index 80b7d1b548f..72048271cee 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import path from 'node:path' import { app, BrowserWindow, powerMonitor } from 'electron' import { autoUpdater } from 'electron-updater' import type { NsisUpdater } from 'electron-updater' @@ -497,6 +498,11 @@ export function setupAutoUpdater( return } if (is.dev) { + // Why: dev-app-update.yml lives at config/dev-app-update.yml (not repo root) + // so the root directory stays short. electron-updater only reads it when + // the dev-mode early-return below is temporarily disabled to exercise the + // update flow locally — point it at the new path up-front so that works. + autoUpdater.updateConfigPath = path.join(app.getAppPath(), 'config', 'dev-app-update.yml') return } From 79faafcc980682e01da40cb1511586ee72160136 Mon Sep 17 00:00:00 2001 From: Colin Chambachan Date: Thu, 30 Apr 2026 03:43:00 -0400 Subject: [PATCH 058/259] Add sparse checkout worktree creation (#1131) Co-authored-by: Orca Co-authored-by: Jinwoo-H --- config/scripts/build-relay.mjs | 3 +- src/main/git/remove-worktree.test.ts | 201 ++++++++- src/main/git/worktree.ts | 135 +++++- src/main/ipc/repos-sparse-presets.test.ts | 223 ++++++++++ src/main/ipc/repos.ts | 91 +++- src/main/ipc/sparse-checkout-directories.ts | 29 ++ src/main/ipc/worktree-logic.ts | 8 + src/main/ipc/worktree-remote.ts | 63 ++- src/main/ipc/worktrees.test.ts | 174 ++++++++ src/main/persistence.ts | 36 +- src/preload/api-types.ts | 20 +- src/preload/index.ts | 31 +- .../components/NewWorkspaceComposerCard.tsx | 42 +- .../components/NewWorkspaceComposerModal.tsx | 5 +- .../components/settings/RepositoryPane.tsx | 21 + .../settings/SparsePresetSettingsSection.tsx | 335 +++++++++++++++ .../settings/sparse-preset-date.test.ts | 13 + .../components/settings/sparse-preset-date.ts | 16 + .../src/components/sidebar/WorktreeCard.tsx | 28 ++ .../sparse/SparseCheckoutPresetSelect.tsx | 387 ++++++++++++++++++ src/renderer/src/hooks/useComposerState.ts | 134 +++++- src/renderer/src/lib/sparse-paths.ts | 41 ++ .../src/lib/sparse-preset-draft.test.ts | 49 +++ src/renderer/src/lib/sparse-preset-draft.ts | 42 ++ src/renderer/src/store/index.ts | 2 + .../src/store/slices/sparse-presets.test.ts | 233 +++++++++++ .../src/store/slices/sparse-presets.ts | 135 ++++++ .../slices/store-session-cascades.test.ts | 2 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 2 + .../src/store/slices/worktree-helpers.ts | 4 +- src/renderer/src/store/slices/worktrees.ts | 20 +- src/renderer/src/store/types.ts | 2 + src/shared/constants.ts | 1 + src/shared/types.ts | 33 ++ 35 files changed, 2486 insertions(+), 77 deletions(-) create mode 100644 src/main/ipc/repos-sparse-presets.test.ts create mode 100644 src/main/ipc/sparse-checkout-directories.ts create mode 100644 src/renderer/src/components/settings/SparsePresetSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/sparse-preset-date.test.ts create mode 100644 src/renderer/src/components/settings/sparse-preset-date.ts create mode 100644 src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx create mode 100644 src/renderer/src/lib/sparse-paths.ts create mode 100644 src/renderer/src/lib/sparse-preset-draft.test.ts create mode 100644 src/renderer/src/lib/sparse-preset-draft.ts create mode 100644 src/renderer/src/store/slices/sparse-presets.test.ts create mode 100644 src/renderer/src/store/slices/sparse-presets.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 8a794a97376..b793b64e757 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -15,7 +15,8 @@ import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT = join(__dirname, '..') +// Why: the script moved from `scripts/` to `config/scripts/` — go two levels up to reach the repo root. +const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index eecf11344dc..0832c0b8e78 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,17 +1,22 @@ +/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner + mock harness, and splitting them would duplicate setup without a clearer boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, - translateWslOutputPaths: (output: string) => output + translateWslOutputPaths: translateWslOutputPathsMock })) -import { removeWorktree } from './worktree' +import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree' type MockResult = { error?: Error @@ -54,11 +59,13 @@ describe('removeWorktree', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) }) it('removes the worktree, prunes stale refs, and deletes its local branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -68,7 +75,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -92,7 +99,7 @@ branch refs/heads/main it('skips branch deletion when another worktree still points at the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -106,7 +113,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -125,7 +132,7 @@ branch refs/heads/feature/test expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git worktree list --porcelain' + 'git worktree list --porcelain -z' ]) ) expect(calls).not.toContain('git branch -D feature/test') @@ -134,7 +141,7 @@ branch refs/heads/feature/test it('deletes the branch after prune removes stale sibling worktree entries', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -149,7 +156,7 @@ branch refs/heads/feature/test prunable gitdir file points to non-existent location ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -172,7 +179,7 @@ branch refs/heads/main it('passes --force before the worktree path when forced removal is requested', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -182,7 +189,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -197,7 +204,7 @@ branch refs/heads/main it('matches Windows worktree paths before deleting the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -207,7 +214,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -230,7 +237,7 @@ branch refs/heads/main it('keeps removal successful when branch cleanup fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -240,7 +247,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -262,3 +269,159 @@ branch refs/heads/main warnSpy.mockRestore() }) }) + +describe('listWorktrees', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + }) + + it('translates parsed path fields from NUL-delimited porcelain output', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + + 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0' + }) + translateWslOutputPathsMock.mockImplementation((output: string) => { + expect(output).not.toContain('\0') + return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + }) + + await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + expect(getGitCalls()).toEqual(['git worktree list --porcelain -z', 'git sparse-checkout list']) + expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) + }) + + it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[], options: { cwd: string }) => { + if (args.join(' ') === 'worktree list --porcelain -z') { + return { + stdout: + 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + + 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0', + stderr: '' + } + } + if (args.join(' ') === 'sparse-checkout list') { + const isFeatureWorktree = + options.cwd === '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' + return { + stdout: isFeatureWorktree ? 'packages/web\n' : '', + stderr: '' + } + } + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + translateWslOutputPathsMock.mockImplementation((output: string) => { + expect(output).not.toContain('\0') + return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + }) + + const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + + expect(worktrees).toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['sparse-checkout', 'list'], { + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' + }) + }) +}) + +describe('addSparseWorktree', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + }) + + it('separates sparse checkout directory operands from options', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src']) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', '-docs', 'src'], + { cwd: '/repo-feature' } + ) + }) + + it('removes the worktree and deletes the created branch when sparse setup fails', async () => { + mockGitCommands({ + 'git sparse-checkout set -- packages/web': { + error: new Error('sparse setup failed') + }, + 'git worktree list --porcelain -z': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain -z#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + + await expect( + addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web']) + ).rejects.toThrow('sparse setup failed') + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git worktree add --no-checkout -b feature/test /repo-feature', + 'git sparse-checkout init --cone', + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature', + 'git worktree prune', + 'git branch -D feature/test' + ]) + ) + expectGitCallOrder( + calls, + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature' + ) + expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test') + }) +}) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c109d168d69..303d709058b 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -2,6 +2,10 @@ import { posix, win32 } from 'path' import type { GitWorktreeInfo } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' +type SparseWorktreeCreateError = Error & { + cleanupFailed?: boolean +} + function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -29,21 +33,23 @@ function looksLikeWindowsPath(pathValue: string): boolean { */ export function parseWorktreeList(output: string): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - // [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings, - // which are common when running git on Windows. - const blocks = output.trim().split(/\r?\n\r?\n/) + const blocks = output.includes('\0') + ? parseNullDelimitedWorktreeBlocks(output) + : output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) - for (const block of blocks) { - if (!block.trim()) { + for (const lines of blocks) { + if (lines.length === 0) { continue } - // [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings. - const lines = block.trim().split(/\r?\n/) let path = '' let head = '' let branch = '' let isBare = false + let isSparse = false for (const line of lines) { if (line.startsWith('worktree ')) { @@ -54,12 +60,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { branch = line.slice('branch '.length) } else if (line === 'bare') { isBare = true + } else if (line === 'sparse') { + isSparse = true } } if (path) { // `git worktree list` always emits the main working tree first. - worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 }) + worktrees.push({ + path, + head, + branch, + isBare, + ...(isSparse ? { isSparse } : {}), + isMainWorktree: worktrees.length === 0 + }) } } @@ -71,14 +86,24 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { cwd: repoPath }) - // Why: when git runs inside WSL, worktree paths are Linux-native - // (e.g. /home/user/repo). Translate them back to Windows UNC paths - // so the rest of Orca can access them via Node fs APIs. - const translated = translateWslOutputPaths(stdout, repoPath) - return parseWorktreeList(translated) + // Why: WSL path translation is line-oriented, but `-z` porcelain output is + // NUL-delimited. Parse first so only complete path fields are translated. + const worktrees = parseWorktreeList(stdout).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) + return Promise.all( + worktrees.map(async (worktree) => { + if (worktree.isBare || worktree.isSparse) { + return worktree + } + const isSparse = await detectSparseCheckout(worktree.path) + return isSparse ? { ...worktree, isSparse } : worktree + }) + ) } catch { return [] } @@ -96,7 +121,8 @@ export async function addWorktree( worktreePath: string, branch: string, baseBranch?: string, - refreshLocalBaseRef = false + refreshLocalBaseRef = false, + noCheckout = false ): Promise { // Why: Some users want Orca-created worktrees to make plain commands like // `git diff main...HEAD` work out of the box, while others do not want @@ -152,13 +178,49 @@ export async function addWorktree( } } - const args = ['worktree', 'add', '-b', branch, worktreePath] + const args = ['worktree', 'add'] + if (noCheckout) { + args.push('--no-checkout') + } + args.push('-b', branch, worktreePath) if (baseBranch) { args.push(baseBranch) } await gitExecFileAsync(args, { cwd: repoPath }) } +export async function addSparseWorktree( + repoPath: string, + worktreePath: string, + branch: string, + directories: string[], + baseBranch?: string, + refreshLocalBaseRef = false +): Promise { + let created = false + try { + await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true) + created = true + await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath }) + await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath }) + await gitExecFileAsync(['checkout', branch], { cwd: worktreePath }) + } catch (error) { + const wrapped: SparseWorktreeCreateError = + error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) + if (created) { + try { + await removeWorktree(repoPath, worktreePath, true) + } catch { + wrapped.cleanupFailed = true + // Why: the user needs to know that manual cleanup may be required — + // otherwise a half-created worktree silently lingers on disk. + wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` + } + } + throw wrapped + } +} + /** * Remove a worktree. */ @@ -208,3 +270,44 @@ export async function removeWorktree( ) } } + +function parseNullDelimitedWorktreeBlocks(output: string): string[][] { + const blocks: string[][] = [] + let current: string[] = [] + + for (const token of output.split('\0')) { + if (!token) { + if (current.length > 0) { + blocks.push(current) + current = [] + } + continue + } + current.push(token) + } + + if (current.length > 0) { + blocks.push(current) + } + + return blocks +} + +function translateWorktreePath(worktreePath: string, repoPath: string): string { + const prefix = 'worktree ' + const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) + return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath +} + +async function detectSparseCheckout(worktreePath: string): Promise { + // Why: `core.sparseCheckout` is repo-level config shared across all worktrees. + // Querying it would false-positive every linked worktree when the main repo + // has sparse checkout enabled. Instead, check the worktree-local + // `sparse-checkout` config file which only exists when sparse is active. + try { + const { stdout } = await gitExecFileAsync(['sparse-checkout', 'list'], { cwd: worktreePath }) + return stdout.trim().length > 0 + } catch { + return false + } +} diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts new file mode 100644 index 00000000000..71ab88b6a4d --- /dev/null +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'crypto' +import type { SparsePreset } from '../../shared/types' + +const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ + handleMock: vi.fn(), + randomUUIDMock: vi.fn(() => 'preset-new'), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getSparsePresets: vi.fn(), + saveSparsePreset: vi.fn(), + removeSparsePreset: vi.fn() + } +})) + +vi.mock('crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: randomUUIDMock + } +}) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: vi.fn() + } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]), + BASE_REF_SEARCH_ARGS: ['for-each-ref'], + filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +import { registerRepoHandlers } from './repos' + +type HandlerMap = Map unknown> + +function makePreset( + overrides: Partial & { id: string; repoId: string } +): SparsePreset { + return { + name: overrides.id, + directories: ['packages/web'], + createdAt: 10, + updatedAt: 20, + ...overrides + } +} + +describe('sparse preset repo IPC handlers', () => { + const handlers: HandlerMap = new Map() + const mainWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + randomUUIDMock.mockReturnValue('preset-new') + mainWindow.webContents.send.mockReset() + mockStore.getRepo.mockReset().mockReturnValue({ + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }) + mockStore.getSparsePresets.mockReset().mockReturnValue([]) + mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset) + mockStore.removeSparsePreset.mockReset() + + registerRepoHandlers(mainWindow as never, mockStore as never) + }) + + it('normalizes and de-duplicates saved sparse preset directories', () => { + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' Web preset ', + directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', ''] + }) + + expect(mockStore.saveSparsePreset).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'preset-new', + repoId: 'repo-1', + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(saved).toEqual( + expect.objectContaining({ + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'repo-1' + }) + }) + + it('preserves createdAt and id when editing an existing preset', () => { + mockStore.getSparsePresets.mockReturnValue([ + makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 }) + ]) + + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + id: 'preset-1', + name: 'New', + directories: ['packages/new'] + }) + + expect(saved).toEqual( + expect.objectContaining({ + id: 'preset-1', + name: 'New', + createdAt: 123, + directories: ['packages/new'] + }) + ) + }) + + it('rejects invalid sparse preset saves before persistence', () => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['../secrets'] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' ', + directories: ['packages/web'] + }) + ).toThrow('Preset name is required.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['.', ''] + }) + ).toThrow('Preset must have at least one directory.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it.each([ + '/Users/me/repo/packages/web', + 'C:\\repo\\packages\\web', + '\\\\server\\share\\repo', + '\\repo\\packages\\web' + ])('rejects absolute sparse preset directory before normalization: %s', (directory) => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['packages/web', directory] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it('rejects sparse preset saves for unknown repos', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'missing', + name: 'Web', + directories: ['packages/web'] + }) + ).toThrow('Repo "missing" not found') + }) + + it('rejects sparse preset removals for unknown repos before persistence', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:remove')!(null, { + repoId: 'missing', + presetId: 'preset-1' + }) + ).toThrow('Repo "missing" not found') + + expect(mockStore.removeSparsePreset).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'missing' + }) + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index b59aa8fb7ca..3f1b13df5d2 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' import type { Store } from '../persistence' -import type { Repo, BaseRefDefaultResult } from '../../shared/types' +import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' import { rebuildAuthorizedRootsCache } from './filesystem-auth' @@ -28,6 +28,7 @@ import { } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' +import { normalizeSparseDirectories } from './sparse-checkout-directories' // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, @@ -50,6 +51,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') ipcMain.removeHandler('repos:addRemote') + ipcMain.removeHandler('sparsePresets:list') + ipcMain.removeHandler('sparsePresets:save') + ipcMain.removeHandler('sparsePresets:remove') ipcMain.handle('repos:list', () => { return store.getRepos() @@ -219,6 +223,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + // ── Sparse presets ───────────────────────────────────────────── + // Why: presets are repo-scoped reusable directory lists used by the + // new-workspace composer. Persisted via Store and broadcast back to the + // renderer so any open composer reflects new/edited/deleted presets + // immediately. + + ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => { + return store.getSparsePresets(args.repoId) + }) + + ipcMain.handle( + 'sparsePresets:save', + ( + _event, + args: { repoId: string; id?: string; name: string; directories: string[] } + ): SparsePreset => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + const name = normalizeSparsePresetName(args.name) + const directories = normalizeSparsePresetDirectories(args.directories) + const now = Date.now() + const existing = args.id + ? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id) + : undefined + const preset: SparsePreset = { + id: existing?.id ?? randomUUID(), + repoId: args.repoId, + name, + directories, + createdAt: existing?.createdAt ?? now, + updatedAt: now + } + const saved = store.saveSparsePreset(preset) + notifySparsePresetsChanged(mainWindow, args.repoId) + return saved + } + ) + + ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + store.removeSparsePreset(args.repoId, args.presetId) + notifySparsePresetsChanged(mainWindow, args.repoId) + }) + ipcMain.handle('repos:pickFolder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] @@ -514,3 +567,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void { mainWindow.webContents.send('repos:changed') } } + +function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('sparsePresets:changed', { repoId }) + } +} + +function normalizeSparsePresetName(name: string): string { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Preset name is required.') + } + if (trimmed.length > 80) { + throw new Error('Preset name is too long.') + } + return trimmed +} + +function normalizeSparsePresetDirectories(directories: string[]): string[] { + let normalized: string[] + try { + normalized = normalizeSparseDirectories(directories) + } catch (err) { + if ( + err instanceof Error && + err.message === 'Sparse checkout directories must be repo-relative paths.' + ) { + throw new Error('Preset directories must be repo-relative paths.') + } + throw err + } + if (normalized.length === 0) { + throw new Error('Preset must have at least one directory.') + } + return normalized +} diff --git a/src/main/ipc/sparse-checkout-directories.ts b/src/main/ipc/sparse-checkout-directories.ts new file mode 100644 index 00000000000..74f2873d062 --- /dev/null +++ b/src/main/ipc/sparse-checkout-directories.ts @@ -0,0 +1,29 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/ + +function isAbsoluteSparseDirectoryPath(entry: string): boolean { + return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry) +} + +export function normalizeSparseDirectories(directories: string[]): string[] { + const seen = new Set() + return directories + .map((entry) => entry.trim()) + .map((entry) => { + // Why: absolute paths can look repo-relative after slash normalization. + if (isAbsoluteSparseDirectoryPath(entry)) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') + }) + .filter((entry) => entry.length > 0 && entry !== '.') + .filter((entry) => { + if (entry.split('/').includes('..')) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + if (seen.has(entry)) { + return false + } + seen.add(entry) + return true + }) +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 70e60f2ef44..f4fc0e292b8 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -160,6 +160,7 @@ export function mergeWorktree( head: git.head, branch: git.branch, isBare: git.isBare, + ...(git.isSparse === true ? { isSparse: true } : {}), isMainWorktree: git.isMainWorktree, displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), comment: meta?.comment || '', @@ -171,6 +172,13 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(git.isSparse === true + ? { + sparseDirectories: meta?.sparseDirectories, + sparseBaseRef: meta?.sparseBaseRef, + sparsePresetId: meta?.sparsePresetId + } + : {}), // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in // shared/types) and forwarded verbatim so the renderer store mirrors // on-disk state. `undefined` here means the worktree has no comments yet. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 548dcc7cead..31867bbc6b6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -13,7 +13,7 @@ import type { WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree } from '../git/worktree' +import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' @@ -31,6 +31,7 @@ import { areWorktreePathsEqual } from './worktree-logic' import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { normalizeSparseDirectories } from './sparse-checkout-directories' export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { @@ -44,6 +45,10 @@ export async function createRemoteWorktree( store: Store, mainWindow: BrowserWindow ): Promise { + if (args.sparseCheckout) { + throw new Error('Sparse checkout is not supported for remote SSH repos yet.') + } + const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined if (!provider) { throw new Error(`No git provider for connection "${repo.connectionId}"`) @@ -307,6 +312,32 @@ export async function createLocalWorktree( // Resolve it before mutating git state so missing UI input cannot strand // a real worktree on disk while the renderer reports "create failed". const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + let sparsePresetId: string | undefined + if (args.sparseCheckout?.presetId) { + const preset = store + .getSparsePresets(repo.id) + .find((entry) => entry.id === args.sparseCheckout?.presetId) + if (preset?.repoId === repo.id) { + try { + const presetDirectories = normalizeSparseDirectories(preset.directories) + // Why: use Set-based comparison so directory order does not affect + // attribution — matches the renderer's sparseDirectoriesMatch logic. + const presetSet = new Set(presetDirectories) + const directoriesMatch = + presetDirectories.length === sparseDirectories.length && + sparseDirectories.every((entry) => presetSet.has(entry)) + sparsePresetId = directoriesMatch ? preset.id : undefined + } catch { + // Why: corrupt preset data should not block creation or falsely label the new worktree. + } + } + } // Why: `git fetch` previously blocked worktree creation for 1–5s on every // click, even though the fetch result isn't actually required — the @@ -319,13 +350,22 @@ export async function createLocalWorktree( // Fetch is best-effort — don't block worktree creation if offline }) - await addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + await (sparseDirectories.length > 0 + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + )) // Re-list to get the freshly created worktree info const gitWorktrees = await listWorktrees(repo.path) @@ -342,6 +382,13 @@ export async function createLocalWorktree( lastActivityAt: Date.now(), ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } + : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId + } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 0bbba1a6a48..f32730d0b33 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,6 +6,7 @@ const { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -26,6 +27,7 @@ const { removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), addWorktreeMock: vi.fn(), + addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), @@ -53,6 +55,7 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, addWorktree: addWorktreeMock, + addSparseWorktree: addSparseWorktreeMock, removeWorktree: removeWorktreeMock })) @@ -113,6 +116,7 @@ describe('registerWorktreeHandlers', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -125,6 +129,7 @@ describe('registerWorktreeHandlers', () => { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -143,6 +148,7 @@ describe('registerWorktreeHandlers', () => { mainWindow.webContents.send, store.getRepos, store.getRepo, + store.getSparsePresets, store.getSettings, store.getWorktreeMeta, store.setWorktreeMeta, @@ -168,6 +174,7 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + store.getSparsePresets.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, @@ -471,6 +478,173 @@ describe('registerWorktreeHandlers', () => { ) }) + it('creates a sparse worktree and persists its sparse metadata', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.setWorktreeMeta.mockReturnValue({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], + presetId: 'preset-1' + } + }) + + expect(addWorktreeMock).not.toHaveBeenCalled() + expect(addSparseWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + ['packages/web', 'apps/api'], + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + repoId: 'repo-1', + path: '/workspace/improve-dashboard', + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) + + it('clears sparse preset attribution when the preset id does not belong to the repo', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-2', + repoId: 'repo-1', + name: 'Other preset', + directories: ['packages/web'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('clears sparse preset attribution when normalized directories do not match', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('rejects sparse checkout directories that traverse above the repo root', async () => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', '../secrets'] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])( + 'rejects absolute sparse checkout directory before normalization: %s', + async (directory) => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', directory] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + } + ) + it('still returns the created worktree when setup runner generation fails', async () => { listWorktreesMock.mockResolvedValue(createdWorktreeList) getEffectiveHooksMock.mockReturnValue({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e2b8fd1cb61..f663bd75ca7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6,7 +6,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS import { writeFile, rename, mkdir, rm } from 'fs/promises' import { join, dirname } from 'path' import { homedir } from 'os' -import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types' +import type { + PersistedState, + Repo, + SparsePreset, + WorktreeMeta, + GlobalSettings +} from '../shared/types' import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -310,6 +316,9 @@ export class Store { removeRepo(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) + // Why: presets are repo-scoped, so removing the repo means the presets + // can never be referenced again — drop them with the parent. + delete this.state.sparsePresetsByRepo[id] // Clean up worktree meta for this repo const prefix = `${id}::` for (const key of Object.keys(this.state.worktreeMeta)) { @@ -360,6 +369,31 @@ export class Store { } } + // ── Sparse Presets ───────────────────────────────────────────────── + + getSparsePresets(repoId: string): SparsePreset[] { + return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => + left.name.localeCompare(right.name) + ) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, i) => (i === index ? preset : entry)) + this.scheduleSave() + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = this.state.sparsePresetsByRepo[repoId] ?? [] + this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) + this.scheduleSave() + } + // ── Worktree Meta ────────────────────────────────────────────────── getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9f94cd95a97..ac23be812ae 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -8,6 +8,7 @@ import type { BrowserSessionProfileSource, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, + CreateWorktreeArgs, CreateWorktreeResult, DirEntry, FsChangedPayload, @@ -44,6 +45,7 @@ import type { PRComment, PRInfo, Repo, + SparsePreset, SearchOptions, SearchResult, StatsSummary, @@ -327,15 +329,21 @@ export type PreloadApi = { searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise onChanged: (callback: () => void) => () => void } + sparsePresets: { + list: (args: { repoId: string }) => Promise + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }) => Promise + remove: (args: { repoId: string; presetId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + } worktrees: { list: (args: { repoId: string }) => Promise listAll: () => Promise - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }) => Promise + create: (args: CreateWorktreeArgs) => Promise resolvePrBase: (args: { repoId: string prNumber: number diff --git a/src/preload/index.ts b/src/preload/index.ts index 70a105b2ba6..bb5da6001cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, + CreateWorktreeArgs, FsChangedPayload, GitHubAssignableUser, GitHubCommentResult, @@ -244,18 +245,36 @@ const api = { } }, + sparsePresets: { + list: (args: { repoId: string }): Promise => + ipcRenderer.invoke('sparsePresets:list', args), + + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }): Promise => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args: { repoId: string; presetId: string }): Promise => + ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => + callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } + }, + worktrees: { list: (args: { repoId: string }): Promise => ipcRenderer.invoke('worktrees:list', args), listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'), - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }): Promise => ipcRenderer.invoke('worktrees:create', args), + create: (args: CreateWorktreeArgs): Promise => + ipcRenderer.invoke('worktrees:create', args), resolvePrBase: (args: { repoId: string diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 3b27aab1996..c72a55ebde8 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { TuiAgent } from '../../../shared/types' +import type { SparsePreset, TuiAgent } from '../../../shared/types' +import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -51,6 +52,10 @@ type NewWorkspaceComposerCardProps = { shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: string | null + canUseSparseCheckout: boolean + sparsePresets: SparsePreset[] + sparseSelectedPresetId: string | null + onSparseSelectPreset: (preset: SparsePreset | null) => void } function SetupCommandPreview({ @@ -186,7 +191,11 @@ export default function NewWorkspaceComposerCard({ onSetupDecisionChange, shouldWaitForSetupCheck, resolvedSetupDecision, - createError + createError, + canUseSparseCheckout, + sparsePresets, + sparseSelectedPresetId, + onSparseSelectPreset }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) @@ -337,8 +346,8 @@ export default function NewWorkspaceComposerCard({
@@ -347,7 +356,14 @@ export default function NewWorkspaceComposerCard({ textarea's 3px outset focus ring has horizontal breathing room inside the overflow-hidden drawer above. Without it the ring gets clipped on the right edge when the field is focused. */} -
+