mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Compare commits
13 commits
v1.3.8-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2554ab28 | ||
|
|
d66d86645d | ||
|
|
53f911ddc3 | ||
|
|
633204babd | ||
|
|
55e935ff32 | ||
|
|
a8c3780bb9 | ||
|
|
933d59f165 | ||
|
|
bf21a028b9 | ||
|
|
7a9bc4ef6d | ||
|
|
22ae50e0ae | ||
|
|
3a58a829f9 | ||
|
|
8ea1f2ee33 | ||
|
|
3bbe9ed712 |
94 changed files with 16401 additions and 160 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -1,6 +1,19 @@
|
|||
# Build artifacts
|
||||
tsconfig.*.tsbuildinfo
|
||||
|
||||
# TypeScript emit artifacts next to sources (tsc produced these accidentally;
|
||||
# real source lives in .ts/.tsx). Hand-authored declaration files are
|
||||
# re-included below.
|
||||
src/**/*.js
|
||||
src/**/*.d.ts
|
||||
/electron.vite.config.js
|
||||
/electron.vite.config.d.ts
|
||||
!src/main/types/hosted-git-info.d.ts
|
||||
!src/preload/api-types.d.ts
|
||||
!src/preload/index.d.ts
|
||||
!src/renderer/src/env.d.ts
|
||||
!src/renderer/src/mermaid.d.ts
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Code Comments: Document the "Why"
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, you **must** add a comment explaining **why** the code behaves the way it does.
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
|
||||
|
||||
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
|
|
@ -22,4 +24,4 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
|||
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
const { chmodSync, existsSync, readdirSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
|
|
@ -23,12 +26,34 @@ module.exports = {
|
|||
// Why: daemon-entry.js is forked as a separate Node.js process and must be
|
||||
// accessible on disk (not inside the asar archive) for child_process.fork().
|
||||
asarUnpack: ['out/cli/**', 'out/shared/**', 'out/main/daemon-entry.js', 'out/main/chunks/**', 'resources/**'],
|
||||
afterPack: async (context) => {
|
||||
const resourcesDir =
|
||||
context.electronPlatformName === 'darwin'
|
||||
? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
|
||||
: join(context.appOutDir, 'resources')
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
}
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
if (!filename.startsWith('agent-browser-')) {
|
||||
continue
|
||||
}
|
||||
// Why: the upstream package has inconsistent executable bits across
|
||||
// platform binaries (notably darwin-x64). child_process.execFile needs
|
||||
// the copied binary to be executable in packaged apps.
|
||||
chmodSync(join(resourcesDir, filename), 0o755)
|
||||
}
|
||||
},
|
||||
win: {
|
||||
executableName: 'Orca',
|
||||
extraResources: [
|
||||
{
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
to: 'bin/orca.cmd'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
|
||||
to: 'agent-browser-win32-x64.exe'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -60,6 +85,10 @@ module.exports = {
|
|||
{
|
||||
from: 'resources/darwin/bin/orca',
|
||||
to: 'bin/orca'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
|
||||
to: 'agent-browser-darwin-${arch}'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
|
|
@ -84,6 +113,10 @@ module.exports = {
|
|||
{
|
||||
from: 'resources/linux/bin/orca',
|
||||
to: 'bin/orca'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
|
||||
to: 'agent-browser-linux-${arch}'
|
||||
}
|
||||
],
|
||||
target: ['AppImage', 'deb'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "orca",
|
||||
"version": "1.3.8-rc.0",
|
||||
"version": "1.3.8-rc.2",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
|
@ -73,6 +73,7 @@
|
|||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"agent-browser": "~0.24.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -99,6 +100,7 @@
|
|||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
|
@ -111,6 +113,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260406.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "^41.1.0",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ importers:
|
|||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
agent-browser:
|
||||
specifier: ~0.24.1
|
||||
version: 0.24.1
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -184,6 +187,9 @@ importers:
|
|||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
|
@ -199,7 +205,7 @@ importers:
|
|||
version: 1.59.1
|
||||
'@stablyai/playwright-test':
|
||||
specifier: ^2.1.13
|
||||
version: 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
version: 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
'@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))
|
||||
|
|
@ -215,6 +221,9 @@ importers:
|
|||
'@types/ssh2':
|
||||
specifier: ^1.15.5
|
||||
version: 1.15.5
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260406.1
|
||||
version: 7.0.0-dev.20260406.1
|
||||
|
|
@ -2205,8 +2214,8 @@ packages:
|
|||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@stablyai/playwright-base@2.1.13':
|
||||
resolution: {integrity: sha512-F8lc2qSfNZQ53WeWWDLLZSpu6f2ZCuiVgGP0P0+PGdO9swCKEwV0f+ti7a4MlmgMlHoCsf5tvddXIVpikhPRlQ==}
|
||||
'@stablyai/playwright-base@2.1.14':
|
||||
resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@playwright/test': ^1.52.0
|
||||
|
|
@ -2215,13 +2224,13 @@ packages:
|
|||
zod:
|
||||
optional: true
|
||||
|
||||
'@stablyai/playwright-test@2.1.13':
|
||||
resolution: {integrity: sha512-VXy65GukMkIsHtTuYuLhSP3l3YMl21ePTXKI2xLRBCkgzhTLdzat0vHM5TEh7vh58lsxmHlruMFESjcaIeb25g==}
|
||||
'@stablyai/playwright-test@2.1.14':
|
||||
resolution: {integrity: sha512-CAyVVnRdsyJg9pbK3Yq5L9lcvEabilFLb2RWeTQybKv7sDkEEqE2t1boXqBt3X6wQO6lsyhUHB9pc10wSwuc4Q==}
|
||||
peerDependencies:
|
||||
'@playwright/test': ^1.52.0
|
||||
|
||||
'@stablyai/playwright@2.1.13':
|
||||
resolution: {integrity: sha512-PGE6hR5WTknfbEBz+KvhG9i2gukSYdie0at6SI0CnJPu13NvGBno1N0Fm/AePhtO5Kjn1mMWW5cRiknVP4bOwA==}
|
||||
'@stablyai/playwright@2.1.14':
|
||||
resolution: {integrity: sha512-+SkphioOf+o2VWiM3KPm/fFTTjwNHUV5b2ZRPrLMTsW6bwmEvjo2FbVOUobNBqbopQBnntNLd8ZCG2gvw7rwtg==}
|
||||
peerDependencies:
|
||||
'@playwright/test': ^1.52.0
|
||||
|
||||
|
|
@ -2751,6 +2760,9 @@ packages:
|
|||
'@types/verror@1.10.11':
|
||||
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
|
|
@ -2880,6 +2892,10 @@ packages:
|
|||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
agent-browser@0.24.1:
|
||||
resolution: {integrity: sha512-csWJtYEQow52b+p93zVZfNrcNBwbxGCZDXDMNWl2ij2i0MFKubIzN+icUeX2/NrkZe5iIau8px+HQlxata2oPw==}
|
||||
hasBin: true
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -6047,6 +6063,18 @@ packages:
|
|||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -7897,7 +7925,7 @@ snapshots:
|
|||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@stablyai/playwright-base@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
'@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@playwright/test': 1.59.1
|
||||
jpeg-js: 0.4.4
|
||||
|
|
@ -7906,18 +7934,18 @@ snapshots:
|
|||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@stablyai/playwright-test@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
'@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@playwright/test': 1.59.1
|
||||
'@stablyai/playwright': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
'@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
'@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
'@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@stablyai/playwright@2.1.13(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
'@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@playwright/test': 1.59.1
|
||||
'@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
'@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
|
|
@ -8482,6 +8510,10 @@ snapshots:
|
|||
'@types/verror@1.10.11':
|
||||
optional: true
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
|
@ -8608,6 +8640,8 @@ snapshots:
|
|||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
agent-browser@0.24.1: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
|
@ -12374,6 +12408,8 @@ snapshots:
|
|||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: orca-cli
|
||||
description: Use the Orca CLI to orchestrate worktrees and live terminals through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; or read, send to, wait on, or stop Orca-managed terminals. Coding agents should also keep the current worktree comment updated with the latest meaningful work-in-progress checkpoint whenever useful; this is an expected default behavior, not a special trigger-only action. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", "update Orca worktree comment", or any task where the agent should operate through Orca instead of talking to git worktrees and terminal processes directly.
|
||||
description: Use the Orca CLI to orchestrate worktrees, live terminals, and browser automation through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; read, send to, wait on, or stop Orca-managed terminals; or automate the built-in browser (navigate, snapshot, click, fill, screenshot). Coding agents should also keep the current worktree comment updated with the latest meaningful work-in-progress checkpoint whenever useful. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", "update Orca worktree comment", "click on", "fill the form", "take a screenshot", "navigate to", "interact with the page", "snapshot the page", or any task where the agent should operate through Orca.
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
|
|
@ -167,6 +167,374 @@ Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca
|
|||
- 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.
|
||||
- If a command fails, prefer retrying with the public `orca` command before concluding the CLI is broken, unless the failure already came from `orca` itself.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
The `orca` CLI also drives the built-in Orca browser. The core workflow is a **snapshot-interact-re-snapshot** loop:
|
||||
|
||||
1. **Snapshot** the page to see interactive elements and their refs.
|
||||
2. **Interact** using refs (`@e1`, `@e3`, etc.) to click, fill, or select.
|
||||
3. **Re-snapshot** after interactions to see the updated page state.
|
||||
|
||||
```bash
|
||||
orca goto --url https://example.com --json
|
||||
orca snapshot --json
|
||||
# Read the refs from the snapshot output
|
||||
orca click --element @e3 --json
|
||||
orca snapshot --json
|
||||
```
|
||||
|
||||
### Element Refs
|
||||
|
||||
Refs like `@e1`, `@e5` are short identifiers assigned to interactive page elements during a snapshot. They are:
|
||||
|
||||
- **Assigned by snapshot**: Run `orca snapshot` to get current refs.
|
||||
- **Scoped to one tab**: Refs from one tab are not valid in another.
|
||||
- **Invalidated by navigation**: If the page navigates after a snapshot, refs become stale. Re-snapshot to get fresh refs.
|
||||
- **Invalidated by tab switch**: Switching tabs with `orca tab switch` invalidates refs. Re-snapshot after switching.
|
||||
|
||||
If a ref is stale, the command returns `browser_stale_ref` — re-snapshot and retry.
|
||||
|
||||
### Worktree Scoping
|
||||
|
||||
Browser commands default to the **current worktree** — only tabs belonging to the agent's worktree are visible and targetable. Tab indices are relative to the filtered tab list.
|
||||
|
||||
```bash
|
||||
# Default: operates on tabs in the current worktree
|
||||
orca snapshot --json
|
||||
|
||||
# Explicitly target all worktrees (cross-worktree access)
|
||||
orca snapshot --worktree all --json
|
||||
|
||||
# Tab indices are relative to the worktree-filtered list
|
||||
orca tab list --json # Shows tabs [0], [1], [2] for this worktree
|
||||
orca tab switch --index 1 --json # Switches to tab [1] within this worktree
|
||||
```
|
||||
|
||||
If no tabs are open in the current worktree, commands return `browser_no_tab`.
|
||||
|
||||
### Stable Page Targeting
|
||||
|
||||
For single-agent flows, bare browser commands are fine: Orca will target the active browser tab in the current worktree.
|
||||
|
||||
For concurrent or multi-process browser automation, prefer a stable page id instead of ambient active-tab state:
|
||||
|
||||
1. Run `orca tab list --json`.
|
||||
2. Read `tabs[].browserPageId` from the result.
|
||||
3. Pass `--page <browserPageId>` to follow-up commands like `snapshot`, `click`, `goto`, `screenshot`, `tab switch`, or `tab close`.
|
||||
|
||||
Why: active-tab state and tab indices can change while another Orca CLI process is working. `browserPageId` pins the command to one concrete tab.
|
||||
|
||||
```bash
|
||||
orca tab list --json
|
||||
orca snapshot --page page-123 --json
|
||||
orca click --page page-123 --element @e3 --json
|
||||
orca screenshot --page page-123 --json
|
||||
orca tab switch --page page-123 --json
|
||||
orca tab close --page page-123 --json
|
||||
```
|
||||
|
||||
If you also pass `--worktree`, Orca treats it as extra scoping/validation for that page id. Without `--page`, commands still fall back to the current worktree's active tab.
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
orca goto --url <url> [--json] # Navigate to URL, waits for page load
|
||||
orca back [--json] # Go back in browser history
|
||||
orca forward [--json] # Go forward in browser history
|
||||
orca reload [--json] # Reload the current page
|
||||
```
|
||||
|
||||
### Observation
|
||||
|
||||
```bash
|
||||
orca snapshot [--page <browserPageId>] [--json] # Accessibility tree snapshot with element refs
|
||||
orca screenshot [--page <browserPageId>] [--format <png|jpeg>] [--json] # Viewport screenshot (base64)
|
||||
orca full-screenshot [--page <browserPageId>] [--format <png|jpeg>] [--json] # Full-page screenshot (base64)
|
||||
orca pdf [--page <browserPageId>] [--json] # Export page as PDF (base64)
|
||||
```
|
||||
|
||||
### Interaction
|
||||
|
||||
```bash
|
||||
orca click --element <ref> [--page <browserPageId>] [--json] # Click an element by ref
|
||||
orca dblclick --element <ref> [--page <browserPageId>] [--json] # Double-click an element
|
||||
orca fill --element <ref> --value <text> [--page <browserPageId>] [--json] # Clear and fill an input
|
||||
orca type --input <text> [--page <browserPageId>] [--json] # Type at current focus (no element targeting)
|
||||
orca select --element <ref> --value <value> [--page <browserPageId>] [--json] # Select dropdown option
|
||||
orca check --element <ref> [--page <browserPageId>] [--json] # Check a checkbox
|
||||
orca uncheck --element <ref> [--page <browserPageId>] [--json] # Uncheck a checkbox
|
||||
orca scroll --direction <up|down> [--amount <pixels>] [--page <browserPageId>] [--json] # Scroll viewport
|
||||
orca scrollintoview --element <ref> [--page <browserPageId>] [--json] # Scroll element into view
|
||||
orca hover --element <ref> [--page <browserPageId>] [--json] # Hover over an element
|
||||
orca focus --element <ref> [--page <browserPageId>] [--json] # Focus an element
|
||||
orca drag --from <ref> --to <ref> [--page <browserPageId>] [--json] # Drag from one element to another
|
||||
orca clear --element <ref> [--page <browserPageId>] [--json] # Clear an input field
|
||||
orca select-all --element <ref> [--page <browserPageId>] [--json] # Select all text in an element
|
||||
orca keypress --key <key> [--page <browserPageId>] [--json] # Press a key (Enter, Tab, Escape, etc.)
|
||||
orca upload --element <ref> --files <paths> [--page <browserPageId>] [--json] # Upload files to a file input
|
||||
```
|
||||
|
||||
### Tab Management
|
||||
|
||||
```bash
|
||||
orca tab list [--json] # List open browser tabs
|
||||
orca tab switch (--index <n> | --page <browserPageId>) [--json] # Switch active tab (invalidates refs)
|
||||
orca tab create [--url <url>] [--json] # Open a new browser tab
|
||||
orca tab close [--index <n> | --page <browserPageId>] [--json] # Close a browser tab
|
||||
```
|
||||
|
||||
### Wait / Synchronization
|
||||
|
||||
```bash
|
||||
orca wait [--timeout <ms>] [--json] # Wait for timeout (default 1000ms)
|
||||
orca wait --selector <css> [--state <visible|hidden>] [--timeout <ms>] [--json] # Wait for element
|
||||
orca wait --text <string> [--timeout <ms>] [--json] # Wait for text to appear on page
|
||||
orca wait --url <substring> [--timeout <ms>] [--json] # Wait for URL to contain substring
|
||||
orca wait --load <networkidle|load|domcontentloaded> [--timeout <ms>] [--json] # Wait for load state
|
||||
orca wait --fn <js-expression> [--timeout <ms>] [--json] # Wait for JS condition to be truthy
|
||||
```
|
||||
|
||||
After any page-changing action, pick one:
|
||||
|
||||
- Wait for specific content: `orca wait --text "Dashboard" --json`
|
||||
- Wait for URL change: `orca wait --url "/dashboard" --json`
|
||||
- Wait for network idle (catch-all for SPA navigation): `orca wait --load networkidle --json`
|
||||
- Wait for an element: `orca wait --selector ".results" --json`
|
||||
|
||||
Avoid bare `orca wait --timeout 2000` except when debugging — it makes scripts slow and flaky.
|
||||
|
||||
### Data Extraction
|
||||
|
||||
```bash
|
||||
orca exec --command "get text @e1" [--json] # Get visible text of an element
|
||||
orca exec --command "get html @e1" [--json] # Get innerHTML
|
||||
orca exec --command "get value @e1" [--json] # Get input value
|
||||
orca exec --command "get attr @e1 href" [--json] # Get element attribute
|
||||
orca exec --command "get title" [--json] # Get page title
|
||||
orca exec --command "get url" [--json] # Get current URL
|
||||
orca exec --command "get count .item" [--json] # Count matching elements
|
||||
```
|
||||
|
||||
### State Checks
|
||||
|
||||
```bash
|
||||
orca exec --command "is visible @e1" [--json] # Check if element is visible
|
||||
orca exec --command "is enabled @e1" [--json] # Check if element is enabled
|
||||
orca exec --command "is checked @e1" [--json] # Check if checkbox is checked
|
||||
```
|
||||
|
||||
### Page Inspection
|
||||
|
||||
```bash
|
||||
orca eval --expression <js> [--json] # Evaluate JS in page context
|
||||
```
|
||||
|
||||
### Cookie Management
|
||||
|
||||
```bash
|
||||
orca cookie get [--url <url>] [--json] # List cookies
|
||||
orca cookie set --name <n> --value <v> [--domain <d>] [--json] # Set a cookie
|
||||
orca cookie delete --name <n> [--domain <d>] [--json] # Delete a cookie
|
||||
```
|
||||
|
||||
### Emulation
|
||||
|
||||
```bash
|
||||
orca viewport --width <w> --height <h> [--scale <n>] [--mobile] [--json]
|
||||
orca geolocation --latitude <lat> --longitude <lng> [--accuracy <m>] [--json]
|
||||
```
|
||||
|
||||
### Request Interception
|
||||
|
||||
```bash
|
||||
orca intercept enable [--patterns <list>] [--json] # Start intercepting requests
|
||||
orca intercept disable [--json] # Stop intercepting
|
||||
orca intercept list [--json] # List paused requests
|
||||
```
|
||||
|
||||
> **Note:** Per-request `intercept continue` and `intercept block` are not yet supported.
|
||||
> They will be added once agent-browser supports per-request interception decisions.
|
||||
|
||||
### Console / Network Capture
|
||||
|
||||
```bash
|
||||
orca capture start [--json] # Start capturing console + network
|
||||
orca capture stop [--json] # Stop capturing
|
||||
orca console [--limit <n>] [--json] # Read captured console entries
|
||||
orca network [--limit <n>] [--json] # Read captured network entries
|
||||
```
|
||||
|
||||
### Mouse Control
|
||||
|
||||
```bash
|
||||
orca exec --command "mouse move 100 200" [--json] # Move mouse to coordinates
|
||||
orca exec --command "mouse down left" [--json] # Press mouse button
|
||||
orca exec --command "mouse up left" [--json] # Release mouse button
|
||||
orca exec --command "mouse wheel 100" [--json] # Scroll wheel
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
orca exec --command "keyboard inserttext \"text\"" [--json] # Insert text bypassing key events
|
||||
orca exec --command "keyboard type \"text\"" [--json] # Raw keystrokes
|
||||
orca exec --command "keydown Shift" [--json] # Hold key down
|
||||
orca exec --command "keyup Shift" [--json] # Release key
|
||||
```
|
||||
|
||||
### Frames (Iframes)
|
||||
|
||||
Iframes are auto-inlined in snapshots — refs inside iframes work transparently. For scoped interaction:
|
||||
|
||||
```bash
|
||||
orca exec --command "frame @e3" [--json] # Switch to iframe by ref
|
||||
orca exec --command "frame \"#iframe\"" [--json] # Switch to iframe by CSS selector
|
||||
orca exec --command "frame main" [--json] # Return to main frame
|
||||
```
|
||||
|
||||
### Semantic Locators (alternative to refs)
|
||||
|
||||
When refs aren't available or you want to skip a snapshot:
|
||||
|
||||
```bash
|
||||
orca exec --command "find role button click --name \"Submit\"" [--json]
|
||||
orca exec --command "find text \"Sign In\" click" [--json]
|
||||
orca exec --command "find label \"Email\" fill \"user@test.com\"" [--json]
|
||||
orca exec --command "find placeholder \"Search\" type \"query\"" [--json]
|
||||
orca exec --command "find testid \"submit-btn\" click" [--json]
|
||||
```
|
||||
|
||||
### Dialogs
|
||||
|
||||
`alert` and `beforeunload` are auto-accepted. For `confirm` and `prompt`:
|
||||
|
||||
```bash
|
||||
orca exec --command "dialog status" [--json] # Check for pending dialog
|
||||
orca exec --command "dialog accept" [--json] # Accept
|
||||
orca exec --command "dialog accept \"text\"" [--json] # Accept with prompt input
|
||||
orca exec --command "dialog dismiss" [--json] # Dismiss/cancel
|
||||
```
|
||||
|
||||
### Extended Commands (Passthrough)
|
||||
|
||||
```bash
|
||||
orca exec --command "<agent-browser command>" [--json]
|
||||
```
|
||||
|
||||
The `exec` command provides access to agent-browser's full command surface. Useful for commands without typed Orca handlers:
|
||||
|
||||
```bash
|
||||
orca exec --command "set device \"iPhone 14\"" --json # Emulate device
|
||||
orca exec --command "set offline on" --json # Toggle offline mode
|
||||
orca exec --command "set media dark" --json # Emulate color scheme
|
||||
orca exec --command "network requests" --json # View tracked network requests
|
||||
orca exec --command "help" --json # See all available commands
|
||||
```
|
||||
|
||||
**Important:** Do not use `orca exec --command "tab ..."` for tab management. Use `orca tab list/create/close/switch` instead — those operate at the Orca level and keep the UI synchronized.
|
||||
|
||||
### `fill` vs `type`
|
||||
|
||||
- **`fill`** targets a specific element by ref, clears its value first, then enters text. Use for form fields.
|
||||
- **`type`** types at whatever currently has focus. Use for search boxes or after clicking into an input.
|
||||
|
||||
If neither works on a custom input component, try:
|
||||
|
||||
```bash
|
||||
orca focus --element @e1 --json
|
||||
orca exec --command "keyboard inserttext \"text\"" --json # bypasses key events
|
||||
```
|
||||
|
||||
### Browser Error Codes
|
||||
|
||||
| Error Code | Meaning | Recovery |
|
||||
|-----------|---------|----------|
|
||||
| `browser_no_tab` | No browser tab is open in this worktree | Open a tab, or use `--worktree all` to check other worktrees |
|
||||
| `browser_stale_ref` | Ref is invalid (page changed since snapshot) | Run `orca snapshot` to get fresh refs |
|
||||
| `browser_tab_not_found` | Tab index does not exist | Run `orca tab list` to see available tabs |
|
||||
| `browser_error` | Error from the browser automation engine | Read the message for details; common causes: element not found, navigation timeout, JS error |
|
||||
|
||||
### Browser Worked Example
|
||||
|
||||
Agent fills a login form and verifies the dashboard loads:
|
||||
|
||||
```bash
|
||||
# Navigate to the login page
|
||||
orca goto --url https://app.example.com/login --json
|
||||
|
||||
# See what's on the page
|
||||
orca snapshot --json
|
||||
# Output includes:
|
||||
# [@e1] text input "Email"
|
||||
# [@e2] text input "Password"
|
||||
# [@e3] button "Sign In"
|
||||
|
||||
# Fill the form
|
||||
orca fill --element @e1 --value "user@example.com" --json
|
||||
orca fill --element @e2 --value "s3cret" --json
|
||||
|
||||
# Submit
|
||||
orca click --element @e3 --json
|
||||
|
||||
# Verify the dashboard loaded
|
||||
orca snapshot --json
|
||||
# Output should show dashboard content, not the login form
|
||||
```
|
||||
|
||||
### Browser Troubleshooting
|
||||
|
||||
**"Ref not found" / `browser_stale_ref`**
|
||||
Page changed since the snapshot. Run `orca snapshot --json` again, then use the new refs.
|
||||
|
||||
**Element exists but not in snapshot**
|
||||
It may be off-screen or not yet rendered. Try:
|
||||
|
||||
```bash
|
||||
orca scroll --direction down --amount 1000 --json
|
||||
orca snapshot --json
|
||||
# or wait for it:
|
||||
orca wait --text "..." --json
|
||||
orca snapshot --json
|
||||
```
|
||||
|
||||
**Click does nothing / overlay swallows the click**
|
||||
Modals or cookie banners may be blocking. Snapshot, find the dismiss button, click it, then re-snapshot.
|
||||
|
||||
**Fill/type doesn't work on a custom input**
|
||||
Some components intercept key events. Use `keyboard inserttext`:
|
||||
|
||||
```bash
|
||||
orca focus --element @e1 --json
|
||||
orca exec --command "keyboard inserttext \"text\"" --json
|
||||
```
|
||||
|
||||
**`browser_no_tab` error**
|
||||
No browser tab is open in the current worktree. Open one with `orca tab create --url <url> --json`.
|
||||
|
||||
### Auto-Switch Worktree
|
||||
|
||||
Browser commands automatically activate the target worktree in the Orca UI when needed. If the agent issues a browser command targeting a worktree that isn't currently active, Orca will switch to that worktree before executing the command.
|
||||
|
||||
### Tab Create Auto-Activation
|
||||
|
||||
When `orca tab create` opens a new tab, it is automatically set as the active tab for the worktree. Subsequent commands (`snapshot`, `click`, etc.) will target the newly created tab without needing an explicit `tab switch`.
|
||||
|
||||
### Browser Agent Guidance
|
||||
|
||||
- Always snapshot before interacting with elements.
|
||||
- After navigation (`goto`, `back`, `reload`, clicking a link), re-snapshot to get fresh refs.
|
||||
- After switching tabs, re-snapshot.
|
||||
- If you get `browser_stale_ref`, re-snapshot and retry with the new refs.
|
||||
- Use `orca tab list` before `orca tab switch` to know which tabs exist.
|
||||
- For concurrent browser workflows, prefer `orca tab list --json` and reuse `tabs[].browserPageId` with `--page` on later commands.
|
||||
- Use `orca wait` to synchronize after actions that trigger async updates (form submits, SPA navigation, modals) instead of arbitrary sleeps.
|
||||
- Use `orca eval` as an escape hatch for interactions not covered by other commands.
|
||||
- Use `orca exec --command "help"` to discover extended commands.
|
||||
- Worktree scoping is automatic — you'll only see tabs from your worktree by default.
|
||||
- Bare browser commands without `--page` still target the current worktree's active tab, which is convenient but less robust for multi-process automation.
|
||||
- Tab creation auto-activates the new tab — no need for `tab switch` after `tab create`.
|
||||
- Browser commands auto-switch the active worktree if needed — no manual worktree activation required.
|
||||
|
||||
## Important Constraints
|
||||
|
||||
- Orca CLI only talks to a running Orca editor.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
/* oxlint-disable max-lines -- Why: CLI parsing behavior is exercised end-to-end
|
||||
in one file so command and flag interactions stay visible in a single suite. */
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
|
@ -35,7 +37,24 @@ vi.mock('./runtime-client', () => {
|
|||
}
|
||||
})
|
||||
|
||||
import { buildCurrentWorktreeSelector, main, normalizeWorktreeSelector } from './index'
|
||||
import {
|
||||
buildCurrentWorktreeSelector,
|
||||
COMMAND_SPECS,
|
||||
main,
|
||||
normalizeWorktreeSelector
|
||||
} from './index'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
describe('COMMAND_SPECS collision check', () => {
|
||||
it('has no duplicate command paths', () => {
|
||||
const seen = new Set<string>()
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
const key = spec.path.join(' ')
|
||||
expect(seen.has(key), `Duplicate COMMAND_SPECS path: "${key}"`).toBe(false)
|
||||
seen.add(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -303,3 +322,312 @@ describe('orca cli worktree awareness', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca cli browser page targeting', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('passes explicit page ids to snapshot without resolving the current worktree', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_snapshot',
|
||||
ok: true,
|
||||
result: {
|
||||
browserPageId: 'page-1',
|
||||
snapshot: 'tree',
|
||||
refs: [],
|
||||
url: 'https://example.com',
|
||||
title: 'Example'
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['snapshot', '--page', 'page-1', '--json'], '/tmp/not-an-orca-worktree')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.snapshot', {
|
||||
page: 'page-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves current worktree only when --page is combined with --worktree current', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req_list',
|
||||
ok: true,
|
||||
result: {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'repo::/tmp/repo/feature',
|
||||
repoId: 'repo',
|
||||
path: '/tmp/repo/feature',
|
||||
branch: 'feature/foo',
|
||||
linkedIssue: null,
|
||||
git: {
|
||||
path: '/tmp/repo/feature',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
displayName: '',
|
||||
comment: ''
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req_snapshot',
|
||||
ok: true,
|
||||
result: {
|
||||
browserPageId: 'page-1',
|
||||
snapshot: 'tree',
|
||||
refs: [],
|
||||
url: 'https://example.com',
|
||||
title: 'Example'
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['snapshot', '--page', 'page-1', '--worktree', 'current', '--json'],
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', {
|
||||
page: 'page-1',
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
})
|
||||
})
|
||||
|
||||
it('passes page-targeted tab switches through without auto-scoping to the current worktree', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_switch',
|
||||
ok: true,
|
||||
result: {
|
||||
switched: 2,
|
||||
browserPageId: 'page-2'
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['tab', 'switch', '--page', 'page-2', '--json'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', {
|
||||
index: undefined,
|
||||
page: 'page-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('still resolves the current worktree when tab switch --page is combined with --worktree current', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req_list',
|
||||
ok: true,
|
||||
result: {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'repo::/tmp/repo/feature',
|
||||
repoId: 'repo',
|
||||
path: '/tmp/repo/feature',
|
||||
branch: 'feature/foo',
|
||||
linkedIssue: null,
|
||||
git: {
|
||||
path: '/tmp/repo/feature',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
displayName: '',
|
||||
comment: ''
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req_switch',
|
||||
ok: true,
|
||||
result: {
|
||||
switched: 2,
|
||||
browserPageId: 'page-2'
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['tab', 'switch', '--page', 'page-2', '--worktree', 'current', '--json'],
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', {
|
||||
index: undefined,
|
||||
page: 'page-2',
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca cli browser waits and viewport flags', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('gives selector waits an explicit RPC timeout budget', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_wait',
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['wait', '--selector', '#ready', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'browser.wait',
|
||||
{
|
||||
selector: '#ready',
|
||||
timeout: undefined,
|
||||
text: undefined,
|
||||
url: undefined,
|
||||
load: undefined,
|
||||
fn: undefined,
|
||||
state: undefined,
|
||||
worktree: undefined
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('extends selector wait RPC timeout when the user passes --timeout', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_wait',
|
||||
ok: true,
|
||||
result: { ok: true },
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['wait', '--selector', '#ready', '--timeout', '12000', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'browser.wait',
|
||||
{
|
||||
selector: '#ready',
|
||||
timeout: 12000,
|
||||
text: undefined,
|
||||
url: undefined,
|
||||
load: undefined,
|
||||
fn: undefined,
|
||||
state: undefined,
|
||||
worktree: undefined
|
||||
},
|
||||
{ timeoutMs: 17000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not tell users Orca is down for a generic runtime timeout', async () => {
|
||||
callMock.mockRejectedValueOnce(
|
||||
new RuntimeClientError(
|
||||
'runtime_timeout',
|
||||
'Timed out waiting for the Orca runtime to respond.'
|
||||
)
|
||||
)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['wait', '--selector', '#ready', '--worktree', 'all'], '/tmp/not-an-orca-worktree')
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Timed out waiting for the Orca runtime to respond.')
|
||||
})
|
||||
|
||||
it('passes the mobile viewport flag through to browser.viewport', async () => {
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_viewport',
|
||||
ok: true,
|
||||
result: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'viewport',
|
||||
'--width',
|
||||
'375',
|
||||
'--height',
|
||||
'812',
|
||||
'--scale',
|
||||
'2',
|
||||
'--mobile',
|
||||
'--worktree',
|
||||
'all',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('browser.viewport', {
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true,
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
1508
src/cli/index.ts
1508
src/cli/index.ts
File diff suppressed because it is too large
Load diff
|
|
@ -58,7 +58,10 @@ export class RuntimeClient {
|
|||
private readonly userDataPath: string
|
||||
private readonly requestTimeoutMs: number
|
||||
|
||||
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 15000) {
|
||||
// Why: browser commands trigger first-time session init (agent-browser connect +
|
||||
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
|
||||
// being so large that genuine hangs go unnoticed.
|
||||
constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
|
||||
this.userDataPath = userDataPath
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
}
|
||||
|
|
@ -383,6 +386,12 @@ export function getDefaultUserDataPath(
|
|||
platform: NodeJS.Platform = process.platform,
|
||||
homeDir = homedir()
|
||||
): string {
|
||||
// Why: in dev mode, the Electron app writes runtime metadata to `orca-dev`
|
||||
// instead of `orca` to avoid clobbering the production app's metadata. The
|
||||
// CLI needs to find the same metadata file, so respect this env var override.
|
||||
if (process.env.ORCA_USER_DATA_PATH) {
|
||||
return process.env.ORCA_USER_DATA_PATH
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return join(homeDir, 'Library', 'Application Support', 'orca')
|
||||
}
|
||||
|
|
|
|||
1061
src/main/browser/agent-browser-bridge.test.ts
Normal file
1061
src/main/browser/agent-browser-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1918
src/main/browser/agent-browser-bridge.ts
Normal file
1918
src/main/browser/agent-browser-bridge.ts
Normal file
File diff suppressed because it is too large
Load diff
88
src/main/browser/anti-detection.ts
Normal file
88
src/main/browser/anti-detection.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Why: Cloudflare Turnstile and similar bot detectors probe multiple browser
|
||||
// APIs beyond navigator.webdriver. This script runs via
|
||||
// Page.addScriptToEvaluateOnNewDocument before any page JS to mask automation
|
||||
// signals that CDP debugger attachment and Electron's webview expose.
|
||||
export const ANTI_DETECTION_SCRIPT = `(function() {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
// Why: Electron webviews expose an empty plugins array. Real Chrome always
|
||||
// has at least a few default plugins (PDF Viewer, etc.). An empty array is
|
||||
// a strong automation signal.
|
||||
if (navigator.plugins.length === 0) {
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
|
||||
]
|
||||
});
|
||||
}
|
||||
// Why: Electron webviews may not have the window.chrome object that real
|
||||
// Chrome exposes. Turnstile checks for its presence. The csi() and
|
||||
// loadTimes() stubs satisfy deeper probes that check for these Chrome-
|
||||
// specific APIs beyond just chrome.runtime.
|
||||
if (!window.chrome) {
|
||||
window.chrome = {};
|
||||
}
|
||||
if (!window.chrome.runtime) {
|
||||
window.chrome.runtime = {};
|
||||
}
|
||||
if (!window.chrome.csi) {
|
||||
window.chrome.csi = function() {
|
||||
return {
|
||||
startE: Date.now(),
|
||||
onloadT: Date.now(),
|
||||
pageT: performance.now(),
|
||||
tran: 15
|
||||
};
|
||||
};
|
||||
}
|
||||
if (!window.chrome.loadTimes) {
|
||||
window.chrome.loadTimes = function() {
|
||||
return {
|
||||
commitLoadTime: Date.now() / 1000,
|
||||
connectionInfo: 'h2',
|
||||
finishDocumentLoadTime: Date.now() / 1000,
|
||||
finishLoadTime: Date.now() / 1000,
|
||||
firstPaintAfterLoadTime: 0,
|
||||
firstPaintTime: Date.now() / 1000,
|
||||
navigationType: 'Other',
|
||||
npnNegotiatedProtocol: 'h2',
|
||||
requestTime: Date.now() / 1000 - 0.16,
|
||||
startLoadTime: Date.now() / 1000 - 0.3,
|
||||
wasAlternateProtocolAvailable: false,
|
||||
wasFetchedViaSpdy: true,
|
||||
wasNpnNegotiated: true
|
||||
};
|
||||
};
|
||||
}
|
||||
// Why: Electron's Permission API defaults to 'denied' for most permissions,
|
||||
// but real Chrome returns 'prompt' for ungranted permissions. Returning
|
||||
// 'denied' is a strong bot signal. Override the query result for common
|
||||
// permissions that Turnstile and similar detectors probe.
|
||||
const promptPerms = new Set([
|
||||
'notifications', 'geolocation', 'camera', 'microphone',
|
||||
'midi', 'idle-detection', 'storage-access'
|
||||
]);
|
||||
const origQuery = Permissions.prototype.query;
|
||||
Permissions.prototype.query = function(desc) {
|
||||
if (promptPerms.has(desc.name)) {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||
}
|
||||
return origQuery.call(this, desc);
|
||||
};
|
||||
// Why: Electron may report Notification.permission as 'denied' by default
|
||||
// whereas real Chrome reports 'default' for sites that haven't been granted
|
||||
// or blocked. Turnstile cross-references this with the Permissions API.
|
||||
try {
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
get: () => 'default'
|
||||
});
|
||||
} catch {}
|
||||
// Why: Electron webviews may have an empty languages array. Real Chrome
|
||||
// always has at least one entry. An empty array is an automation signal.
|
||||
if (!navigator.languages || navigator.languages.length === 0) {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en']
|
||||
});
|
||||
}
|
||||
})()`
|
||||
|
|
@ -46,6 +46,7 @@ import type {
|
|||
BrowserSessionProfileSource
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser detection
|
||||
|
|
@ -1578,7 +1579,7 @@ export async function importCookiesFromBrowser(
|
|||
const ua = getUserAgentForBrowser(browser.family)
|
||||
if (ua) {
|
||||
targetSession.setUserAgent(ua)
|
||||
browserSessionRegistry.setupClientHintsOverride(targetSession, ua)
|
||||
setupClientHintsOverride(targetSession, ua)
|
||||
browserSessionRegistry.persistUserAgent(ua)
|
||||
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
const {
|
||||
shellOpenExternalMock,
|
||||
browserWindowFromWebContentsMock,
|
||||
menuBuildFromTemplateMock,
|
||||
guestOffMock,
|
||||
guestOnMock,
|
||||
|
|
@ -13,6 +14,7 @@ const {
|
|||
screenGetCursorScreenPointMock
|
||||
} = vi.hoisted(() => ({
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
|
|
@ -24,6 +26,9 @@ const {
|
|||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
fromWebContents: browserWindowFromWebContentsMock
|
||||
},
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: shellOpenExternalMock },
|
||||
Menu: {
|
||||
|
|
@ -44,6 +49,7 @@ describe('browserManager', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
shellOpenExternalMock.mockReset()
|
||||
browserWindowFromWebContentsMock.mockReset()
|
||||
menuBuildFromTemplateMock.mockReset()
|
||||
guestOffMock.mockReset()
|
||||
guestOnMock.mockReset()
|
||||
|
|
@ -148,6 +154,295 @@ describe('browserManager', () => {
|
|||
expect(shellOpenExternalMock).toHaveBeenCalledWith('https://example.com/login')
|
||||
})
|
||||
|
||||
it('activates the owning browser workspace when ensuring a page-backed guest is visible', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
prevTabType: 'terminal',
|
||||
prevActiveWorktreeId: 'wt-1',
|
||||
prevActiveBrowserWorkspaceId: 'workspace-prev',
|
||||
prevActiveBrowserPageId: 'page-prev',
|
||||
prevFocusedGroupTabId: 'tab-prev',
|
||||
targetWorktreeId: 'wt-1',
|
||||
targetBrowserWorkspaceId: 'workspace-1',
|
||||
targetBrowserPageId: 'page-1'
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const guest = {
|
||||
id: 707,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
browserWindowFromWebContentsMock.mockReturnValue({ isFocused: vi.fn(() => true) })
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restore = await browserManager.ensureWebviewVisible(guest.id)
|
||||
|
||||
const activationScript = rendererExecuteJavaScriptMock.mock.calls[0]?.[0]
|
||||
expect(activationScript).toContain('var browserWorkspaceId = "workspace-1";')
|
||||
expect(activationScript).toContain('var browserPageId = "page-1";')
|
||||
expect(activationScript).toContain('state.setActiveBrowserTab(browserWorkspaceId);')
|
||||
expect(activationScript).toContain(
|
||||
'state.setActiveBrowserPage(browserWorkspaceId, browserPageId);'
|
||||
)
|
||||
expect(activationScript).toContain('var targetWorktreeId = "wt-1";')
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
it('restores the previously focused browser workspace after screenshot prep changes tabs', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
prevTabType: 'browser',
|
||||
prevActiveWorktreeId: 'wt-prev',
|
||||
prevActiveBrowserWorkspaceId: 'workspace-prev',
|
||||
prevActiveBrowserPageId: 'page-prev',
|
||||
prevFocusedGroupTabId: 'tab-prev',
|
||||
targetWorktreeId: 'wt-target',
|
||||
targetBrowserWorkspaceId: 'workspace-target',
|
||||
targetBrowserPageId: 'page-target'
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const guest = {
|
||||
id: 708,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
browserWindowFromWebContentsMock.mockReturnValue({ isFocused: vi.fn(() => true) })
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-target',
|
||||
workspaceId: 'workspace-target',
|
||||
worktreeId: 'wt-target',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restore = await browserManager.ensureWebviewVisible(guest.id)
|
||||
restore()
|
||||
|
||||
const restoreScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0]
|
||||
expect(restoreScript).toContain('state.setActiveWorktree("wt-prev");')
|
||||
expect(restoreScript).toContain('state.setActiveBrowserTab("workspace-prev");')
|
||||
})
|
||||
|
||||
it('restores the previously active page when screenshot prep switches pages inside one workspace', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
prevTabType: 'browser',
|
||||
prevActiveWorktreeId: 'wt-target',
|
||||
prevActiveBrowserWorkspaceId: 'workspace-target',
|
||||
prevActiveBrowserPageId: 'page-prev',
|
||||
prevFocusedGroupTabId: null,
|
||||
targetWorktreeId: 'wt-target',
|
||||
targetBrowserWorkspaceId: 'workspace-target',
|
||||
targetBrowserPageId: 'page-target'
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const guest = {
|
||||
id: 709,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
browserWindowFromWebContentsMock.mockReturnValue({ isFocused: vi.fn(() => true) })
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-target',
|
||||
workspaceId: 'workspace-target',
|
||||
worktreeId: 'wt-target',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restore = await browserManager.ensureWebviewVisible(guest.id)
|
||||
restore()
|
||||
|
||||
const restoreScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0]
|
||||
expect(restoreScript).toContain('state.setActiveBrowserPage(')
|
||||
expect(restoreScript).toContain('"workspace-target"')
|
||||
expect(restoreScript).toContain('"page-prev"')
|
||||
})
|
||||
|
||||
it('restores remembered browser workspace/page even when the visible pane was terminal', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
prevTabType: 'terminal',
|
||||
prevActiveWorktreeId: 'wt-target',
|
||||
prevActiveBrowserWorkspaceId: 'workspace-prev',
|
||||
prevActiveBrowserPageId: 'page-prev',
|
||||
prevFocusedGroupTabId: 'tab-prev',
|
||||
targetWorktreeId: 'wt-target',
|
||||
targetBrowserWorkspaceId: 'workspace-target',
|
||||
targetBrowserPageId: 'page-target'
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const guest = {
|
||||
id: 7091,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
browserWindowFromWebContentsMock.mockReturnValue({ isFocused: vi.fn(() => true) })
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-target',
|
||||
workspaceId: 'workspace-target',
|
||||
worktreeId: 'wt-target',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restore = await browserManager.ensureWebviewVisible(guest.id)
|
||||
restore()
|
||||
|
||||
const restoreScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0]
|
||||
expect(restoreScript).toContain('state.setActiveBrowserTab("workspace-prev");')
|
||||
expect(restoreScript).toContain('state.setActiveBrowserPage(')
|
||||
expect(restoreScript).toContain('"workspace-prev"')
|
||||
expect(restoreScript).toContain('"page-prev"')
|
||||
expect(restoreScript).toContain('state.activateTab("tab-prev");')
|
||||
expect(restoreScript).toContain('state.setActiveTabType("terminal");')
|
||||
})
|
||||
|
||||
it('does not focus the Orca window while preparing a screenshot', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi.fn().mockResolvedValueOnce({
|
||||
prevTabType: 'terminal',
|
||||
prevActiveWorktreeId: 'wt-1',
|
||||
prevActiveBrowserWorkspaceId: 'workspace-prev',
|
||||
prevActiveBrowserPageId: 'page-prev',
|
||||
prevFocusedGroupTabId: 'tab-prev',
|
||||
targetWorktreeId: 'wt-1',
|
||||
targetBrowserWorkspaceId: 'workspace-1',
|
||||
targetBrowserPageId: 'page-1'
|
||||
})
|
||||
const guest = {
|
||||
id: 710,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
await browserManager.ensureWebviewVisible(guest.id)
|
||||
|
||||
expect(browserWindowFromWebContentsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers opening a link in another Orca browser tab from the guest context menu', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
|
|
@ -455,6 +750,101 @@ describe('browserManager', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('retires stale guest mappings when a page re-registers after a process swap', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const oldGuestOnMock = vi.fn()
|
||||
const oldGuestOffMock = vi.fn()
|
||||
const newGuestOnMock = vi.fn()
|
||||
const newGuestOffMock = vi.fn()
|
||||
const oldGuest = {
|
||||
id: 501,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: oldGuestOnMock,
|
||||
off: oldGuestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://old.example')
|
||||
}
|
||||
const newGuest = {
|
||||
id: 502,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: newGuestOnMock,
|
||||
off: newGuestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://new.example')
|
||||
}
|
||||
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === oldGuest.id) {
|
||||
return oldGuest
|
||||
}
|
||||
if (id === newGuest.id) {
|
||||
return newGuest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(oldGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: oldGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(newGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-1',
|
||||
webContentsId: newGuest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const oldDidFailLoadHandler = oldGuestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedUrl: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
const newDidFailLoadHandler = newGuestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedUrl: string,
|
||||
isMainFrame: boolean
|
||||
) => void)
|
||||
| undefined
|
||||
|
||||
oldDidFailLoadHandler?.(null, -105, 'Old guest failed', 'https://old.example', true)
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
|
||||
newDidFailLoadHandler?.(null, -106, 'New guest failed', 'https://new.example', true)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('browser:guest-load-failed', {
|
||||
browserPageId: 'browser-1',
|
||||
loadError: {
|
||||
code: -106,
|
||||
description: 'New guest failed',
|
||||
validatedUrl: 'https://new.example'
|
||||
}
|
||||
})
|
||||
expect(oldGuestOffMock).toHaveBeenCalled()
|
||||
expect(browserManager.getGuestWebContentsId('browser-1')).toBe(newGuest.id)
|
||||
})
|
||||
|
||||
it('does not forward ctrl/cmd+r or readline chords from browser guests', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ import {
|
|||
setupGuestContextMenu,
|
||||
setupGuestShortcutForwarding
|
||||
} from './browser-guest-ui'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
export type BrowserGuestRegistration = {
|
||||
browserPageId?: string
|
||||
browserTabId?: string
|
||||
workspaceId?: string
|
||||
worktreeId?: string
|
||||
webContentsId: number
|
||||
rendererWebContentsId: number
|
||||
}
|
||||
|
|
@ -71,15 +73,20 @@ function safeOrigin(rawUrl: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
class BrowserManager {
|
||||
export class BrowserManager {
|
||||
private readonly webContentsIdByTabId = new Map<string, number>()
|
||||
// Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear
|
||||
// scans on every mouse event, load failure, permission, and popup event.
|
||||
private readonly tabIdByWebContentsId = new Map<number, string>()
|
||||
// Why: guest registration is keyed by browser page id, but renderer
|
||||
// visibility/focus state is keyed by browser workspace id. Screenshot prep
|
||||
// has to bridge that mismatch to activate the right tab before capture.
|
||||
private readonly workspaceIdByPageId = new Map<string, string>()
|
||||
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly worktreeIdByTabId = new Map<string, string>()
|
||||
private readonly policyAttachedGuestIds = new Set<number>()
|
||||
private readonly policyCleanupByGuestId = new Map<number, () => void>()
|
||||
private readonly pendingLoadFailuresByGuestId = new Map<
|
||||
|
|
@ -92,6 +99,66 @@ class BrowserManager {
|
|||
private readonly downloadsById = new Map<string, ActiveDownload>()
|
||||
private readonly grabSessionController = new BrowserGrabSessionController()
|
||||
|
||||
// Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the
|
||||
// only reliable way to run JS before page scripts on every navigation.
|
||||
// The previous approach — executeJavaScript on did-start-navigation — ran
|
||||
// on the OLD page context during navigation, so overrides were never
|
||||
// present when the new page's Turnstile script executed.
|
||||
//
|
||||
// Returns a cleanup function that removes the detach listener and prevents
|
||||
// further re-attach attempts.
|
||||
private injectAntiDetection(guest: Electron.WebContents): () => void {
|
||||
let disposed = false
|
||||
|
||||
const attach = (): void => {
|
||||
if (disposed || guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!guest.debugger.isAttached()) {
|
||||
guest.debugger.attach('1.3')
|
||||
}
|
||||
void guest.debugger
|
||||
.sendCommand('Page.enable', {})
|
||||
.then(() =>
|
||||
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
)
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
/* best-effort — debugger may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the CDP proxy and bridge detach the debugger when they stop,
|
||||
// which removes addScriptToEvaluateOnNewDocument injections. Re-attach
|
||||
// so manual browsing retains anti-detection overrides after agent
|
||||
// sessions end. The 500ms delay avoids racing with the proxy/bridge if
|
||||
// it is mid-restart (detach → re-attach).
|
||||
const onDetach = (): void => {
|
||||
if (!disposed && !guest.isDestroyed()) {
|
||||
setTimeout(attach, 500)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
attach()
|
||||
guest.debugger.on('detach', onDetach)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
try {
|
||||
guest.debugger.off('detach', onDetach)
|
||||
} catch {
|
||||
/* guest may already be destroyed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
|
||||
return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null
|
||||
}
|
||||
|
|
@ -108,12 +175,237 @@ class BrowserManager {
|
|||
return renderer
|
||||
}
|
||||
|
||||
// Why: screenshot sessions target guest page ids, but Orca's visible browser
|
||||
// chrome is keyed by workspace ids. If we activate the page id directly, the
|
||||
// webview stays hidden under the terminal pane and Page.captureScreenshot
|
||||
// times out even though the guest still exists.
|
||||
async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> {
|
||||
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
|
||||
if (!browserPageId) {
|
||||
return () => {}
|
||||
}
|
||||
const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId
|
||||
const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null
|
||||
const renderer = this.resolveRendererForBrowserTab(browserPageId)
|
||||
if (!renderer || renderer.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const prev = await renderer
|
||||
.executeJavaScript(
|
||||
`(function() {
|
||||
var store = window.__store;
|
||||
if (!store) return null;
|
||||
var state = store.getState();
|
||||
var prevTabType = state.activeTabType;
|
||||
var prevActiveWorktreeId = state.activeWorktreeId || null;
|
||||
var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null;
|
||||
var prevActiveBrowserPageId = null;
|
||||
var prevFocusedGroupTabId = null;
|
||||
var targetWorktreeId = ${JSON.stringify(worktreeId)};
|
||||
var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)};
|
||||
var browserPageId = ${JSON.stringify(browserPageId)};
|
||||
var browserTabsByWorktree = state.browserTabsByWorktree || {};
|
||||
|
||||
if (prevActiveWorktreeId) {
|
||||
var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId];
|
||||
var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || [];
|
||||
for (var pg = 0; pg < prevGroups.length; pg++) {
|
||||
if (prevGroups[pg].id === prevFocusedGroupId) {
|
||||
prevFocusedGroupTabId = prevGroups[pg].activeTabId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prevActiveBrowserWorkspaceId) {
|
||||
for (var prevWtId in browserTabsByWorktree) {
|
||||
var prevBrowserTabs = browserTabsByWorktree[prevWtId] || [];
|
||||
for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) {
|
||||
if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) {
|
||||
prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevActiveBrowserPageId) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
targetWorktreeId &&
|
||||
prevActiveWorktreeId !== targetWorktreeId &&
|
||||
typeof state.setActiveWorktree === 'function'
|
||||
) {
|
||||
state.setActiveWorktree(targetWorktreeId);
|
||||
state = store.getState();
|
||||
}
|
||||
|
||||
var foundWorkspace = null;
|
||||
for (var wtId in browserTabsByWorktree) {
|
||||
var tabs = browserTabsByWorktree[wtId] || [];
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
if (tabs[i].id === browserWorkspaceId) {
|
||||
foundWorkspace = tabs[i];
|
||||
if (!targetWorktreeId) {
|
||||
targetWorktreeId = wtId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundWorkspace) break;
|
||||
}
|
||||
|
||||
var hasTargetPage = false;
|
||||
var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || [];
|
||||
for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) {
|
||||
if (targetPages[pageIndex].id === browserPageId) {
|
||||
hasTargetPage = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWorkspace) {
|
||||
if (typeof state.setActiveBrowserTab === 'function') {
|
||||
state.setActiveBrowserTab(browserWorkspaceId);
|
||||
state = store.getState();
|
||||
} else {
|
||||
var allTabs = state.unifiedTabsByWorktree || {};
|
||||
var found = null;
|
||||
for (var unifiedWtId in allTabs) {
|
||||
var unifiedTabs = allTabs[unifiedWtId] || [];
|
||||
for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) {
|
||||
if (
|
||||
unifiedTabs[unifiedIndex].contentType === 'browser' &&
|
||||
unifiedTabs[unifiedIndex].entityId === browserWorkspaceId
|
||||
) {
|
||||
found = unifiedTabs[unifiedIndex];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (found) {
|
||||
state.activateTab(found.id);
|
||||
}
|
||||
state.setActiveTabType('browser');
|
||||
state = store.getState();
|
||||
}
|
||||
// Why: activating the workspace alone is not enough for screenshot
|
||||
// capture when a browser workspace contains multiple pages. The
|
||||
// compositor only paints the currently mounted page guest.
|
||||
if (
|
||||
hasTargetPage &&
|
||||
foundWorkspace.activePageId !== browserPageId &&
|
||||
typeof state.setActiveBrowserPage === 'function'
|
||||
) {
|
||||
state.setActiveBrowserPage(browserWorkspaceId, browserPageId);
|
||||
state = store.getState();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prevTabType: prevTabType,
|
||||
prevActiveWorktreeId: prevActiveWorktreeId,
|
||||
prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId,
|
||||
prevActiveBrowserPageId: prevActiveBrowserPageId,
|
||||
prevFocusedGroupTabId: prevFocusedGroupTabId,
|
||||
targetWorktreeId: targetWorktreeId,
|
||||
targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null,
|
||||
targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null
|
||||
};
|
||||
})()`
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
const needsRestore =
|
||||
prev &&
|
||||
(prev.prevTabType !== 'browser' ||
|
||||
prev.prevActiveWorktreeId !== prev.targetWorktreeId ||
|
||||
prev.prevFocusedGroupTabId !== null ||
|
||||
prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId ||
|
||||
prev.prevActiveBrowserPageId !== prev.targetBrowserPageId)
|
||||
|
||||
if (!needsRestore) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!prev || !renderer || renderer.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
renderer
|
||||
.executeJavaScript(
|
||||
`(function() {
|
||||
var store = window.__store;
|
||||
if (!store) return;
|
||||
var state = store.getState();
|
||||
if (
|
||||
${JSON.stringify(prev?.prevActiveWorktreeId)} &&
|
||||
${JSON.stringify(prev?.prevActiveWorktreeId)} !==
|
||||
${JSON.stringify(prev?.targetWorktreeId)} &&
|
||||
typeof state.setActiveWorktree === 'function'
|
||||
) {
|
||||
state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)});
|
||||
state = store.getState();
|
||||
}
|
||||
if (
|
||||
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} &&
|
||||
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !==
|
||||
${JSON.stringify(prev?.targetBrowserWorkspaceId)} &&
|
||||
typeof state.setActiveBrowserTab === 'function'
|
||||
) {
|
||||
state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)});
|
||||
state = store.getState();
|
||||
}
|
||||
if (
|
||||
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} &&
|
||||
${JSON.stringify(prev?.prevActiveBrowserPageId)} &&
|
||||
${JSON.stringify(prev?.prevActiveBrowserPageId)} !==
|
||||
${JSON.stringify(prev?.targetBrowserPageId)} &&
|
||||
typeof state.setActiveBrowserPage === 'function'
|
||||
) {
|
||||
// Why: Orca remembers the last browser workspace/page even when
|
||||
// the user is currently in terminal/editor view. Screenshot prep
|
||||
// temporarily switches that hidden browser selection state, so
|
||||
// restore it independently of the visible tab type.
|
||||
state.setActiveBrowserPage(
|
||||
${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)},
|
||||
${JSON.stringify(prev?.prevActiveBrowserPageId)}
|
||||
);
|
||||
state = store.getState();
|
||||
}
|
||||
if (
|
||||
${JSON.stringify(prev?.prevTabType)} !== 'browser' &&
|
||||
${JSON.stringify(prev?.prevFocusedGroupTabId)}
|
||||
) {
|
||||
state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)});
|
||||
}
|
||||
if (${JSON.stringify(prev?.prevTabType)} !== 'browser') {
|
||||
state.setActiveTabType(${JSON.stringify(prev?.prevTabType)});
|
||||
}
|
||||
})()`
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
attachGuestPolicies(guest: Electron.WebContents): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
guest.setBackgroundThrottling(true)
|
||||
|
||||
// Why: Cloudflare Turnstile and similar bot detectors probe browser APIs
|
||||
// (navigator.webdriver, plugins, window.chrome) that differ in Electron
|
||||
// webviews vs real Chrome. Inject overrides on every page load so manual
|
||||
// browsing passes challenges even without the CDP debugger attached.
|
||||
const disposeAntiDetection = this.injectAntiDetection(guest)
|
||||
|
||||
// Why: background throttling must be disabled so agent-driven screenshots
|
||||
// (Page.captureScreenshot via CDP proxy) can capture frames even when the
|
||||
// Orca window is not the focused foreground app. With throttling enabled,
|
||||
// the compositor stops producing frames and capturePage() returns empty.
|
||||
guest.setBackgroundThrottling(false)
|
||||
guest.setWindowOpenHandler(({ url }) => {
|
||||
const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
||||
const browserUrl = normalizeBrowserNavigationUrl(url)
|
||||
|
|
@ -149,6 +441,14 @@ class BrowserManager {
|
|||
})
|
||||
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
// Why: blob: URLs are same-origin (inherit the creator's origin) and are
|
||||
// used by Cloudflare Turnstile to load challenge resources inside iframes.
|
||||
// Blocking them triggers error 600010 ("bot behavior detected"). Only
|
||||
// allow blobs whose embedded origin is http(s) to maintain defense-in-depth
|
||||
// against blob:null or other opaque-origin blobs.
|
||||
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
|
||||
return
|
||||
}
|
||||
if (!normalizeBrowserNavigationUrl(url)) {
|
||||
// Why: `will-attach-webview` only validates the initial src. Main must
|
||||
// keep enforcing the same allowlist for later guest navigations too.
|
||||
|
|
@ -181,6 +481,7 @@ class BrowserManager {
|
|||
// guest surface is torn down, preventing the callbacks from preventing GC of
|
||||
// the underlying WebContents wrapper.
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeAntiDetection()
|
||||
if (!guest.isDestroyed()) {
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
|
|
@ -189,9 +490,30 @@ class BrowserManager {
|
|||
})
|
||||
}
|
||||
|
||||
private retireStaleGuestWebContents(previousWebContentsId: number): void {
|
||||
// Why: a browser page can re-register with a new guest id after Chromium
|
||||
// swaps renderer processes. Late events from the dead guest must stop
|
||||
// resolving to the live page, or stale download/popup/permission callbacks
|
||||
// can be delivered to the wrong session after the swap.
|
||||
this.tabIdByWebContentsId.delete(previousWebContentsId)
|
||||
|
||||
const policyCleanup = this.policyCleanupByGuestId.get(previousWebContentsId)
|
||||
if (policyCleanup) {
|
||||
policyCleanup()
|
||||
this.policyCleanupByGuestId.delete(previousWebContentsId)
|
||||
}
|
||||
this.policyAttachedGuestIds.delete(previousWebContentsId)
|
||||
this.pendingLoadFailuresByGuestId.delete(previousWebContentsId)
|
||||
this.pendingPermissionEventsByGuestId.delete(previousWebContentsId)
|
||||
this.pendingPopupEventsByGuestId.delete(previousWebContentsId)
|
||||
this.pendingDownloadIdsByGuestId.delete(previousWebContentsId)
|
||||
}
|
||||
|
||||
registerGuest({
|
||||
browserPageId,
|
||||
browserTabId: legacyBrowserTabId,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
webContentsId,
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): void {
|
||||
|
|
@ -231,9 +553,20 @@ class BrowserManager {
|
|||
return
|
||||
}
|
||||
|
||||
const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) {
|
||||
this.retireStaleGuestWebContents(previousWebContentsId)
|
||||
}
|
||||
|
||||
this.webContentsIdByTabId.set(browserTabId, webContentsId)
|
||||
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
|
||||
if (workspaceId) {
|
||||
this.workspaceIdByPageId.set(browserTabId, workspaceId)
|
||||
}
|
||||
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
|
||||
if (worktreeId) {
|
||||
this.worktreeIdByTabId.set(browserTabId, worktreeId)
|
||||
}
|
||||
|
||||
this.setupContextMenu(browserTabId, guest)
|
||||
this.setupGrabShortcut(browserTabId, guest)
|
||||
|
|
@ -292,6 +625,8 @@ class BrowserManager {
|
|||
}
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
this.rendererWebContentsIdByTabId.delete(browserTabId)
|
||||
this.workspaceIdByPageId.delete(browserTabId)
|
||||
this.worktreeIdByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
|
|
@ -313,6 +648,7 @@ class BrowserManager {
|
|||
}
|
||||
this.policyCleanupByGuestId.clear()
|
||||
this.tabIdByWebContentsId.clear()
|
||||
this.worktreeIdByTabId.clear()
|
||||
this.pendingLoadFailuresByGuestId.clear()
|
||||
this.pendingPermissionEventsByGuestId.clear()
|
||||
this.pendingPopupEventsByGuestId.clear()
|
||||
|
|
@ -323,6 +659,14 @@ class BrowserManager {
|
|||
return this.webContentsIdByTabId.get(browserTabId) ?? null
|
||||
}
|
||||
|
||||
getWebContentsIdByTabId(): Map<string, number> {
|
||||
return this.webContentsIdByTabId
|
||||
}
|
||||
|
||||
getWorktreeIdForTab(browserTabId: string): string | undefined {
|
||||
return this.worktreeIdByTabId.get(browserTabId)
|
||||
}
|
||||
|
||||
notifyPermissionDenied(args: {
|
||||
guestWebContentsId: number
|
||||
permission: string
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ vi.mock('./browser-manager', () => ({
|
|||
}))
|
||||
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
|
||||
describe('BrowserSessionRegistry', () => {
|
||||
|
|
@ -153,7 +154,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const edgeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(mockSess, edgeUa)
|
||||
setupClientHintsOverride(mockSess, edgeUa)
|
||||
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
|
|
@ -178,7 +179,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(mockSess, chromeUa)
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
|
|
@ -192,10 +193,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (compatible; MSIE 10.0)'
|
||||
)
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
|
||||
|
||||
expect(onBeforeSendHeaders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -203,10 +201,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
it('leaves non-Client-Hints headers unchanged', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
browserSessionRegistry.setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36'
|
||||
)
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { app, type Session, session } from 'electron'
|
||||
/* eslint-disable max-lines -- Why: the registry is the single source of truth for
|
||||
browser session profiles, partition allowlisting, cookie import staging, and
|
||||
per-partition permission/download policies. Splitting further would scatter the
|
||||
security boundary across modules. */
|
||||
import { app, session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
|
|
@ -12,6 +16,7 @@ import { join } from 'node:path'
|
|||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
|
||||
import { browserManager } from './browser-manager'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
|
||||
type BrowserSessionMeta = {
|
||||
defaultSource: BrowserSessionProfile['source']
|
||||
|
|
@ -107,7 +112,18 @@ class BrowserSessionRegistry {
|
|||
if (meta.userAgent) {
|
||||
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
|
||||
sess.setUserAgent(meta.userAgent)
|
||||
this.setupClientHintsOverride(sess, meta.userAgent)
|
||||
setupClientHintsOverride(sess, meta.userAgent)
|
||||
} else {
|
||||
// Why: even without an imported session, the default Electron UA contains
|
||||
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
|
||||
try {
|
||||
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
} catch {
|
||||
/* session not available yet (e.g. unit tests or pre-ready) */
|
||||
}
|
||||
}
|
||||
if (meta.defaultSource) {
|
||||
const current = this.profiles.get('default')
|
||||
|
|
@ -120,46 +136,6 @@ class BrowserSessionRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
|
||||
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
|
||||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
|
||||
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = secChUaFull
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the import writes cookies to a staging DB because CookieMonster holds
|
||||
// the live DB's data in memory and would overwrite our changes on its next
|
||||
// flush. This method MUST run before any session.fromPartition() call so
|
||||
|
|
@ -373,8 +349,17 @@ class BrowserSessionRegistry {
|
|||
this.configuredPartitions.add(partition)
|
||||
|
||||
const sess = session.fromPartition(partition)
|
||||
if (typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
}
|
||||
// Why: clipboard-read and clipboard-sanitized-write are required for agent-browser's
|
||||
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
|
||||
// throws NotAllowedError even when invoked via CDP with userGesture:true.
|
||||
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const allowed = permission === 'fullscreen'
|
||||
const allowed = autoGranted.has(permission)
|
||||
if (!allowed) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
|
|
@ -385,7 +370,7 @@ class BrowserSessionRegistry {
|
|||
callback(allowed)
|
||||
})
|
||||
sess.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return permission === 'fullscreen'
|
||||
return autoGranted.has(permission)
|
||||
})
|
||||
sess.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
callback({ video: undefined, audio: undefined })
|
||||
|
|
|
|||
55
src/main/browser/browser-session-ua.ts
Normal file
55
src/main/browser/browser-session-ua.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { Session } from 'electron'
|
||||
|
||||
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
|
||||
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
|
||||
// flag as non-human traffic. Strip those tokens so the webview's UA and
|
||||
// sec-ch-ua Client Hints look like standard Chrome.
|
||||
export function cleanElectronUserAgent(ua: string): string {
|
||||
return (
|
||||
ua
|
||||
.replace(/\s+Electron\/\S+/, '')
|
||||
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
|
||||
// including pre-release semver strings that [\d.]+ would miss.
|
||||
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
|
||||
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
|
||||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
export function setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
|
||||
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = secChUaFull
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
535
src/main/browser/cdp-bridge-integration.test.ts
Normal file
535
src/main/browser/cdp-bridge-integration.test.ts
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
/* eslint-disable max-lines -- Why: integration test covering the full browser automation pipeline end-to-end. */
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createConnection } from 'net'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ── Electron mocks ──
|
||||
|
||||
const { webContentsFromIdMock } = vi.hoisted(() => ({
|
||||
webContentsFromIdMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: webContentsFromIdMock },
|
||||
shell: { openExternal: vi.fn() },
|
||||
ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), on: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
import { BrowserManager } from './browser-manager'
|
||||
import { CdpBridge } from './cdp-bridge'
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import { readRuntimeMetadata } from '../runtime/runtime-metadata'
|
||||
|
||||
// ── CDP response builders ──
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
role?: { type: string; value: string }
|
||||
name?: { type: string; value: string }
|
||||
properties?: { name: string; value: { type: string; value: unknown } }[]
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
function axNode(
|
||||
id: string,
|
||||
role: string,
|
||||
name: string,
|
||||
opts?: { childIds?: string[]; backendDOMNodeId?: number }
|
||||
): AXNode {
|
||||
return {
|
||||
nodeId: id,
|
||||
backendDOMNodeId: opts?.backendDOMNodeId ?? parseInt(id, 10) * 100,
|
||||
role: { type: 'role', value: role },
|
||||
name: { type: 'computedString', value: name },
|
||||
childIds: opts?.childIds
|
||||
}
|
||||
}
|
||||
|
||||
const EXAMPLE_COM_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Example Domain', { childIds: ['2', '3', '4'] }),
|
||||
axNode('2', 'heading', 'Example Domain'),
|
||||
axNode('3', 'staticText', 'This domain is for use in illustrative examples.'),
|
||||
axNode('4', 'link', 'More information...', { backendDOMNodeId: 400 })
|
||||
]
|
||||
|
||||
const SEARCH_PAGE_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Search', { childIds: ['2', '3', '4', '5'] }),
|
||||
axNode('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
|
||||
axNode('3', 'link', 'Home', { backendDOMNodeId: 300 }),
|
||||
axNode('4', 'textbox', 'Search query', { backendDOMNodeId: 400 }),
|
||||
axNode('5', 'button', 'Search', { backendDOMNodeId: 500 })
|
||||
]
|
||||
|
||||
// ── Mock WebContents factory ──
|
||||
|
||||
function createMockGuest(id: number, url: string, title: string) {
|
||||
let currentUrl = url
|
||||
let currentTitle = title
|
||||
let currentTree = EXAMPLE_COM_TREE
|
||||
let navHistoryId = 1
|
||||
|
||||
const sendCommandMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
switch (method) {
|
||||
case 'Page.enable':
|
||||
case 'DOM.enable':
|
||||
case 'Accessibility.enable':
|
||||
return {}
|
||||
case 'Accessibility.getFullAXTree':
|
||||
return { nodes: currentTree }
|
||||
case 'Page.getNavigationHistory':
|
||||
return {
|
||||
entries: [{ id: navHistoryId, url: currentUrl }],
|
||||
currentIndex: 0
|
||||
}
|
||||
case 'Page.navigate': {
|
||||
const targetUrl = (params as { url: string }).url
|
||||
if (targetUrl.includes('nonexistent.invalid')) {
|
||||
return { errorText: 'net::ERR_NAME_NOT_RESOLVED' }
|
||||
}
|
||||
navHistoryId++
|
||||
currentUrl = targetUrl
|
||||
if (targetUrl.includes('search.example.com')) {
|
||||
currentTitle = 'Search'
|
||||
currentTree = SEARCH_PAGE_TREE
|
||||
} else {
|
||||
currentTitle = 'Example Domain'
|
||||
currentTree = EXAMPLE_COM_TREE
|
||||
}
|
||||
return {}
|
||||
}
|
||||
case 'Runtime.evaluate': {
|
||||
const expr = (params as { expression: string }).expression
|
||||
if (expr === 'document.readyState') {
|
||||
return { result: { value: 'complete' } }
|
||||
}
|
||||
if (expr === 'location.origin') {
|
||||
return { result: { value: new URL(currentUrl).origin } }
|
||||
}
|
||||
if (expr.includes('innerWidth')) {
|
||||
return { result: { value: JSON.stringify({ w: 1280, h: 720 }) } }
|
||||
}
|
||||
if (expr.includes('scrollBy')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
if (expr.includes('dispatchEvent')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
// eslint-disable-next-line no-eval
|
||||
return { result: { value: String(eval(expr)), type: 'string' } }
|
||||
}
|
||||
case 'DOM.scrollIntoViewIfNeeded':
|
||||
return {}
|
||||
case 'DOM.getBoxModel':
|
||||
return { model: { content: [100, 200, 300, 200, 300, 250, 100, 250] } }
|
||||
case 'Input.dispatchMouseEvent':
|
||||
return {}
|
||||
case 'Input.insertText':
|
||||
return {}
|
||||
case 'Input.dispatchKeyEvent':
|
||||
return {}
|
||||
case 'DOM.focus':
|
||||
return {}
|
||||
case 'DOM.describeNode':
|
||||
return { node: { nodeId: 1 } }
|
||||
case 'DOM.requestNode':
|
||||
return { nodeId: 1 }
|
||||
case 'DOM.resolveNode':
|
||||
return { object: { objectId: 'obj-1' } }
|
||||
case 'Runtime.callFunctionOn':
|
||||
return { result: { value: undefined } }
|
||||
case 'DOM.setFileInputFiles':
|
||||
return {}
|
||||
case 'Page.captureScreenshot':
|
||||
return {
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
}
|
||||
case 'Page.reload':
|
||||
return {}
|
||||
case 'Network.enable':
|
||||
return {}
|
||||
case 'Target.setAutoAttach':
|
||||
return {}
|
||||
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||
return { identifier: 'mock-script-id' }
|
||||
case 'Runtime.enable':
|
||||
return {}
|
||||
default:
|
||||
throw new Error(`Unexpected CDP method: ${method}`)
|
||||
}
|
||||
})
|
||||
|
||||
const debuggerListeners = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const guest = {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
getTitle: vi.fn(() => currentTitle),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
debugger: {
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
sendCommand: sendCommandMock,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
debuggerListeners.set(event, handlers)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
const idx = handlers.indexOf(handler)
|
||||
if (idx >= 0) {
|
||||
handlers.splice(idx, 1)
|
||||
}
|
||||
}),
|
||||
removeAllListeners: vi.fn((event: string) => {
|
||||
debuggerListeners.set(event, [])
|
||||
}),
|
||||
off: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
return { guest, sendCommandMock }
|
||||
}
|
||||
|
||||
// ── RPC helper ──
|
||||
|
||||
async function sendRequest(
|
||||
endpoint: string,
|
||||
request: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
const message = buffer.slice(0, newlineIndex)
|
||||
socket.end()
|
||||
resolve(JSON.parse(message) as Record<string, unknown>)
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
describe('Browser automation pipeline (integration)', () => {
|
||||
let server: OrcaRuntimeRpcServer
|
||||
let endpoint: string
|
||||
let authToken: string
|
||||
|
||||
const GUEST_WC_ID = 5001
|
||||
const RENDERER_WC_ID = 1
|
||||
|
||||
beforeEach(async () => {
|
||||
const { guest } = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === GUEST_WC_ID) {
|
||||
return guest
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const browserManager = new BrowserManager()
|
||||
// Simulate the attach-time policy (normally done in will-attach-webview)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-1',
|
||||
webContentsId: GUEST_WC_ID,
|
||||
rendererWebContentsId: RENDERER_WC_ID
|
||||
})
|
||||
|
||||
const cdpBridge = new CdpBridge(browserManager)
|
||||
cdpBridge.setActiveTab(GUEST_WC_ID)
|
||||
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runtime.setAgentBrowserBridge(cdpBridge as any)
|
||||
|
||||
server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)!
|
||||
endpoint = metadata.transport!.endpoint
|
||||
authToken = metadata.authToken!
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
async function rpc(method: string, params?: Record<string, unknown>) {
|
||||
const response = await sendRequest(endpoint, {
|
||||
id: `req_${method}`,
|
||||
authToken,
|
||||
method,
|
||||
...(params ? { params } : {})
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// ── Snapshot ──
|
||||
|
||||
it('takes a snapshot and returns refs for interactive elements', async () => {
|
||||
const res = await rpc('browser.snapshot')
|
||||
expect(res.ok).toBe(true)
|
||||
|
||||
const result = res.result as {
|
||||
snapshot: string
|
||||
refs: { ref: string; role: string; name: string }[]
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
expect(result.url).toBe('https://example.com')
|
||||
expect(result.title).toBe('Example Domain')
|
||||
expect(result.snapshot).toContain('heading "Example Domain"')
|
||||
expect(result.snapshot).toContain('link "More information..."')
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0]).toMatchObject({
|
||||
ref: '@e1',
|
||||
role: 'link',
|
||||
name: 'More information...'
|
||||
})
|
||||
})
|
||||
|
||||
// ── Click ──
|
||||
|
||||
it('clicks an element by ref after snapshot', async () => {
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const res = await rpc('browser.click', { element: '@e1' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { clicked: string }).clicked).toBe('@e1')
|
||||
})
|
||||
|
||||
it('returns error when clicking without a prior snapshot', async () => {
|
||||
const res = await rpc('browser.click', { element: '@e1' })
|
||||
expect(res.ok).toBe(false)
|
||||
expect((res.error as { code: string }).code).toBe('browser_stale_ref')
|
||||
})
|
||||
|
||||
it('returns error for non-existent ref', async () => {
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const res = await rpc('browser.click', { element: '@e999' })
|
||||
expect(res.ok).toBe(false)
|
||||
expect((res.error as { code: string }).code).toBe('browser_ref_not_found')
|
||||
})
|
||||
|
||||
// ── Navigation ──
|
||||
|
||||
it('navigates to a URL and invalidates refs', async () => {
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
expect(gotoRes.ok).toBe(true)
|
||||
const gotoResult = gotoRes.result as { url: string; title: string }
|
||||
expect(gotoResult.url).toBe('https://search.example.com')
|
||||
expect(gotoResult.title).toBe('Search')
|
||||
|
||||
// Old refs should be stale after navigation
|
||||
const clickRes = await rpc('browser.click', { element: '@e1' })
|
||||
expect(clickRes.ok).toBe(false)
|
||||
expect((clickRes.error as { code: string }).code).toBe('browser_stale_ref')
|
||||
|
||||
// Re-snapshot should work and show new page
|
||||
const snapRes = await rpc('browser.snapshot')
|
||||
expect(snapRes.ok).toBe(true)
|
||||
const snapResult = snapRes.result as { snapshot: string; refs: { name: string }[] }
|
||||
expect(snapResult.snapshot).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Home')
|
||||
})
|
||||
|
||||
it('returns error for failed navigation', async () => {
|
||||
const res = await rpc('browser.goto', { url: 'https://nonexistent.invalid' })
|
||||
expect(res.ok).toBe(false)
|
||||
expect((res.error as { code: string }).code).toBe('browser_navigation_failed')
|
||||
})
|
||||
|
||||
// ── Fill ──
|
||||
|
||||
it('fills an input by ref', async () => {
|
||||
await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
// @e2 should be the textbox "Search query" on the search page
|
||||
const res = await rpc('browser.fill', { element: '@e2', value: 'hello world' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { filled: string }).filled).toBe('@e2')
|
||||
})
|
||||
|
||||
// ── Type ──
|
||||
|
||||
it('types text at current focus', async () => {
|
||||
const res = await rpc('browser.type', { input: 'some text' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { typed: boolean }).typed).toBe(true)
|
||||
})
|
||||
|
||||
// ── Select ──
|
||||
|
||||
it('selects a dropdown option by ref', async () => {
|
||||
await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const res = await rpc('browser.select', { element: '@e2', value: 'option-1' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { selected: string }).selected).toBe('@e2')
|
||||
})
|
||||
|
||||
// ── Scroll ──
|
||||
|
||||
it('scrolls the viewport', async () => {
|
||||
const res = await rpc('browser.scroll', { direction: 'down' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { scrolled: string }).scrolled).toBe('down')
|
||||
|
||||
const res2 = await rpc('browser.scroll', { direction: 'up', amount: 200 })
|
||||
expect(res2.ok).toBe(true)
|
||||
expect((res2.result as { scrolled: string }).scrolled).toBe('up')
|
||||
})
|
||||
|
||||
// ── Reload ──
|
||||
|
||||
it('reloads the page', async () => {
|
||||
const res = await rpc('browser.reload')
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { url: string }).url).toBe('https://example.com')
|
||||
})
|
||||
|
||||
// ── Screenshot ──
|
||||
|
||||
it('captures a screenshot', async () => {
|
||||
const res = await rpc('browser.screenshot', { format: 'png' })
|
||||
expect(res.ok).toBe(true)
|
||||
const result = res.result as { data: string; format: string }
|
||||
expect(result.format).toBe('png')
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── Eval ──
|
||||
|
||||
it('evaluates JavaScript in the page context', async () => {
|
||||
const res = await rpc('browser.eval', { expression: '2 + 2' })
|
||||
expect(res.ok).toBe(true)
|
||||
expect((res.result as { result: string }).result).toBe('4')
|
||||
})
|
||||
|
||||
// ── Tab management ──
|
||||
|
||||
it('lists open tabs', async () => {
|
||||
const res = await rpc('browser.tabList')
|
||||
expect(res.ok).toBe(true)
|
||||
const result = res.result as { tabs: { index: number; url: string; active: boolean }[] }
|
||||
expect(result.tabs).toHaveLength(1)
|
||||
expect(result.tabs[0]).toMatchObject({
|
||||
index: 0,
|
||||
url: 'https://example.com',
|
||||
active: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error for out-of-range tab switch', async () => {
|
||||
const res = await rpc('browser.tabSwitch', { index: 5 })
|
||||
expect(res.ok).toBe(false)
|
||||
expect((res.error as { code: string }).code).toBe('browser_tab_not_found')
|
||||
})
|
||||
|
||||
// ── Full agent workflow simulation ──
|
||||
|
||||
it('simulates a complete agent workflow: navigate → snapshot → interact → re-snapshot', async () => {
|
||||
// 1. Navigate to search page
|
||||
const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
expect(gotoRes.ok).toBe(true)
|
||||
|
||||
// 2. Snapshot the page
|
||||
const snap1 = await rpc('browser.snapshot')
|
||||
expect(snap1.ok).toBe(true)
|
||||
const snap1Result = snap1.result as {
|
||||
snapshot: string
|
||||
refs: { ref: string; role: string; name: string }[]
|
||||
}
|
||||
|
||||
// Verify we see the search page structure
|
||||
expect(snap1Result.snapshot).toContain('[Main Nav]')
|
||||
expect(snap1Result.snapshot).toContain('text input "Search query"')
|
||||
expect(snap1Result.snapshot).toContain('button "Search"')
|
||||
|
||||
// 3. Fill the search input
|
||||
const searchInput = snap1Result.refs.find((r) => r.name === 'Search query')
|
||||
expect(searchInput).toBeDefined()
|
||||
const fillRes = await rpc('browser.fill', {
|
||||
element: searchInput!.ref,
|
||||
value: 'integration testing'
|
||||
})
|
||||
expect(fillRes.ok).toBe(true)
|
||||
|
||||
// 4. Click the search button
|
||||
const searchBtn = snap1Result.refs.find((r) => r.name === 'Search')
|
||||
expect(searchBtn).toBeDefined()
|
||||
const clickRes = await rpc('browser.click', { element: searchBtn!.ref })
|
||||
expect(clickRes.ok).toBe(true)
|
||||
|
||||
// 5. Take a screenshot
|
||||
const ssRes = await rpc('browser.screenshot')
|
||||
expect(ssRes.ok).toBe(true)
|
||||
|
||||
// 6. Check tab list
|
||||
const tabRes = await rpc('browser.tabList')
|
||||
expect(tabRes.ok).toBe(true)
|
||||
const tabs = (tabRes.result as { tabs: { url: string }[] }).tabs
|
||||
expect(tabs[0].url).toBe('https://search.example.com')
|
||||
})
|
||||
|
||||
// ── No tab errors ──
|
||||
|
||||
it('returns browser_no_tab when no tabs are registered', async () => {
|
||||
// Create a fresh setup with no registered tabs
|
||||
const emptyManager = new BrowserManager()
|
||||
const emptyBridge = new CdpBridge(emptyManager)
|
||||
|
||||
const userDataPath2 = mkdtempSync(join(tmpdir(), 'browser-e2e-empty-'))
|
||||
const runtime2 = new OrcaRuntimeService()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runtime2.setAgentBrowserBridge(emptyBridge as any)
|
||||
|
||||
const server2 = new OrcaRuntimeRpcServer({ runtime: runtime2, userDataPath: userDataPath2 })
|
||||
await server2.start()
|
||||
|
||||
const metadata2 = readRuntimeMetadata(userDataPath2)!
|
||||
const res = await sendRequest(metadata2.transport!.endpoint, {
|
||||
id: 'req_no_tab',
|
||||
authToken: metadata2.authToken,
|
||||
method: 'browser.snapshot'
|
||||
})
|
||||
|
||||
expect(res.ok).toBe(false)
|
||||
expect((res.error as { code: string }).code).toBe('browser_no_tab')
|
||||
|
||||
await server2.stop()
|
||||
})
|
||||
})
|
||||
1768
src/main/browser/cdp-bridge.ts
Normal file
1768
src/main/browser/cdp-bridge.ts
Normal file
File diff suppressed because it is too large
Load diff
246
src/main/browser/cdp-screenshot.test.ts
Normal file
246
src/main/browser/cdp-screenshot.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { captureFullPageScreenshot, captureScreenshot } from './cdp-screenshot'
|
||||
|
||||
function createMockWebContents() {
|
||||
return {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
invalidate: vi.fn(),
|
||||
capturePage: vi.fn(),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => true),
|
||||
sendCommand: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('captureScreenshot', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('invalidates the guest before forwarding Page.captureScreenshot', async () => {
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockResolvedValueOnce({ data: 'png-data' })
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(webContents as never, { format: 'png' }, onResult, onError)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(webContents.invalidate).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', {
|
||||
format: 'png'
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledWith({ data: 'png-data' })
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to capturePage when Page.captureScreenshot stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {}))
|
||||
webContents.capturePage.mockResolvedValueOnce({
|
||||
isEmpty: () => false,
|
||||
toPNG: () => Buffer.from('fallback-png')
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(webContents as never, { format: 'png' }, onResult, onError)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(webContents.capturePage).toHaveBeenCalledTimes(1)
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
data: Buffer.from('fallback-png').toString('base64')
|
||||
})
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('crops the fallback image when the request includes a visible clip rect', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const croppedImage = {
|
||||
isEmpty: () => false,
|
||||
toPNG: () => Buffer.from('cropped-png')
|
||||
}
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {}))
|
||||
webContents.capturePage.mockResolvedValueOnce({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: 400, height: 300 }),
|
||||
crop: vi.fn(() => croppedImage),
|
||||
toPNG: () => Buffer.from('full-png')
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(
|
||||
webContents as never,
|
||||
{
|
||||
format: 'png',
|
||||
clip: { x: 10, y: 20, width: 30, height: 40, scale: 2 }
|
||||
},
|
||||
onResult,
|
||||
onError
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
const fallbackImage = await webContents.capturePage.mock.results[0]?.value
|
||||
expect(fallbackImage.crop).toHaveBeenCalledWith({ x: 20, y: 40, width: 60, height: 80 })
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
data: Buffer.from('cropped-png').toString('base64')
|
||||
})
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the timeout error when the request needs beyond-viewport pixels', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {}))
|
||||
webContents.capturePage.mockResolvedValueOnce({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: 400, height: 300 }),
|
||||
crop: vi.fn(),
|
||||
toPNG: () => Buffer.from('full-png')
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(
|
||||
webContents as never,
|
||||
{
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, y: 0, width: 800, height: 1200, scale: 1 }
|
||||
},
|
||||
onResult,
|
||||
onError
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
'Screenshot timed out — the browser tab may not be visible or the window may not have focus.'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the fallback result when CDP settles first after the timeout fires', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let resolveCapturePage: ((value: unknown) => void) | null = null
|
||||
let resolveSendCommand: ((value: unknown) => void) | null = null
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSendCommand = resolve
|
||||
})
|
||||
)
|
||||
webContents.capturePage.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCapturePage = resolve
|
||||
})
|
||||
)
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(webContents as never, { format: 'png' }, onResult, onError)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(resolveSendCommand).toBeTypeOf('function')
|
||||
resolveSendCommand!({ data: 'cdp-png' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(resolveCapturePage).toBeTypeOf('function')
|
||||
resolveCapturePage!({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: 100, height: 100 }),
|
||||
crop: vi.fn(),
|
||||
toPNG: () => Buffer.from('fallback-png')
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onResult).toHaveBeenCalledTimes(1)
|
||||
expect(onResult).toHaveBeenCalledWith({ data: 'cdp-png' })
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the original timeout when the fallback capture is unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {}))
|
||||
webContents.capturePage.mockResolvedValueOnce({
|
||||
isEmpty: () => true,
|
||||
toPNG: () => Buffer.from('unused')
|
||||
})
|
||||
const onResult = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
captureScreenshot(webContents as never, { format: 'png' }, onResult, onError)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
'Screenshot timed out — the browser tab may not be visible or the window may not have focus.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureFullPageScreenshot', () => {
|
||||
it('uses cssContentSize so HiDPI pages are captured at the real page size', async () => {
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation((method: string) => {
|
||||
if (method === 'Page.getLayoutMetrics') {
|
||||
return Promise.resolve({
|
||||
cssContentSize: { width: 640.25, height: 1280.75 },
|
||||
contentSize: { width: 1280.5, height: 2561.5 }
|
||||
})
|
||||
}
|
||||
if (method === 'Page.captureScreenshot') {
|
||||
return Promise.resolve({ data: 'full-page-data' })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(captureFullPageScreenshot(webContents as never, 'png')).resolves.toEqual({
|
||||
data: 'full-page-data',
|
||||
format: 'png'
|
||||
})
|
||||
expect(webContents.debugger.sendCommand).toHaveBeenNthCalledWith(1, 'Page.getLayoutMetrics', {})
|
||||
expect(webContents.debugger.sendCommand).toHaveBeenNthCalledWith(2, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, y: 0, width: 641, height: 1281, scale: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to legacy contentSize when cssContentSize is unavailable', async () => {
|
||||
const webContents = createMockWebContents()
|
||||
webContents.debugger.sendCommand.mockImplementation((method: string) => {
|
||||
if (method === 'Page.getLayoutMetrics') {
|
||||
return Promise.resolve({
|
||||
contentSize: { width: 800, height: 1600 }
|
||||
})
|
||||
}
|
||||
if (method === 'Page.captureScreenshot') {
|
||||
return Promise.resolve({ data: 'legacy-full-page-data' })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(captureFullPageScreenshot(webContents as never, 'jpeg')).resolves.toEqual({
|
||||
data: 'legacy-full-page-data',
|
||||
format: 'jpeg'
|
||||
})
|
||||
expect(webContents.debugger.sendCommand).toHaveBeenNthCalledWith(2, 'Page.captureScreenshot', {
|
||||
format: 'jpeg',
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, y: 0, width: 800, height: 1600, scale: 1 }
|
||||
})
|
||||
})
|
||||
})
|
||||
264
src/main/browser/cdp-screenshot.ts
Normal file
264
src/main/browser/cdp-screenshot.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import type { WebContents } from 'electron'
|
||||
|
||||
const SCREENSHOT_TIMEOUT_MS = 8000
|
||||
const SCREENSHOT_TIMEOUT_MESSAGE =
|
||||
'Screenshot timed out — the browser tab may not be visible or the window may not have focus.'
|
||||
|
||||
function applyFallbackClip(
|
||||
image: Electron.NativeImage,
|
||||
params: Record<string, unknown> | undefined
|
||||
): Electron.NativeImage | null {
|
||||
if (params?.captureBeyondViewport) {
|
||||
// Why: capturePage() can only see the currently painted viewport. If the
|
||||
// caller asked for beyond-viewport pixels, returning a viewport-sized image
|
||||
// would silently lie about what was captured.
|
||||
return null
|
||||
}
|
||||
|
||||
const clip = params?.clip
|
||||
if (!clip || typeof clip !== 'object') {
|
||||
return image
|
||||
}
|
||||
const clipRect = clip as Record<string, unknown>
|
||||
|
||||
const x = typeof clipRect.x === 'number' ? clipRect.x : NaN
|
||||
const y = typeof clipRect.y === 'number' ? clipRect.y : NaN
|
||||
const width = typeof clipRect.width === 'number' ? clipRect.width : NaN
|
||||
const height = typeof clipRect.height === 'number' ? clipRect.height : NaN
|
||||
const scale =
|
||||
typeof clipRect.scale === 'number' && Number.isFinite(clipRect.scale) && clipRect.scale > 0
|
||||
? clipRect.scale
|
||||
: 1
|
||||
|
||||
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cropRect = {
|
||||
x: Math.round(x * scale),
|
||||
y: Math.round(y * scale),
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
}
|
||||
const imageSize = image.getSize()
|
||||
if (
|
||||
cropRect.x < 0 ||
|
||||
cropRect.y < 0 ||
|
||||
cropRect.width <= 0 ||
|
||||
cropRect.height <= 0 ||
|
||||
cropRect.x + cropRect.width > imageSize.width ||
|
||||
cropRect.y + cropRect.height > imageSize.height
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return image.crop(cropRect)
|
||||
}
|
||||
|
||||
function encodeNativeImageScreenshot(
|
||||
image: Electron.NativeImage,
|
||||
params: Record<string, unknown> | undefined
|
||||
): { data: string } | null {
|
||||
if (image.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clippedImage = applyFallbackClip(image, params)
|
||||
if (!clippedImage || clippedImage.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const format = params?.format === 'jpeg' ? 'jpeg' : 'png'
|
||||
const quality =
|
||||
typeof params?.quality === 'number' && Number.isFinite(params.quality)
|
||||
? Math.max(0, Math.min(100, Math.round(params.quality)))
|
||||
: undefined
|
||||
const buffer = format === 'jpeg' ? clippedImage.toJPEG(quality ?? 90) : clippedImage.toPNG()
|
||||
return { data: buffer.toString('base64') }
|
||||
}
|
||||
|
||||
function getLayoutClip(metrics: {
|
||||
cssContentSize?: { width?: number; height?: number }
|
||||
contentSize?: { width?: number; height?: number }
|
||||
}): { x: number; y: number; width: number; height: number; scale: number } | null {
|
||||
// Why: Page.captureScreenshot clip coordinates are in CSS pixels. On HiDPI
|
||||
// Electron guests, `contentSize` can reflect device pixels, which makes
|
||||
// Chromium tile the page into a duplicated 2x2 grid. Prefer cssContentSize
|
||||
// and only fall back to contentSize when older Chromium builds omit it.
|
||||
const size = metrics.cssContentSize ?? metrics.contentSize
|
||||
const width = size?.width
|
||||
const height = size?.height
|
||||
if (
|
||||
typeof width !== 'number' ||
|
||||
!Number.isFinite(width) ||
|
||||
width <= 0 ||
|
||||
typeof height !== 'number' ||
|
||||
!Number.isFinite(height) ||
|
||||
height <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCommandWithTimeout<T>(
|
||||
webContents: WebContents,
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
timeoutMessage: string
|
||||
): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
webContents.debugger.sendCommand(method, params ?? {}) as Promise<T>,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(timeoutMessage)), SCREENSHOT_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureFullPageScreenshot(
|
||||
webContents: WebContents,
|
||||
format: 'png' | 'jpeg' = 'png'
|
||||
): Promise<{ data: string; format: 'png' | 'jpeg' }> {
|
||||
if (webContents.isDestroyed()) {
|
||||
throw new Error('WebContents destroyed')
|
||||
}
|
||||
const dbg = webContents.debugger
|
||||
if (!dbg.isAttached()) {
|
||||
throw new Error('Debugger not attached')
|
||||
}
|
||||
|
||||
try {
|
||||
webContents.invalidate()
|
||||
} catch {
|
||||
// Some guest teardown paths reject repaint requests. Fall through to CDP.
|
||||
}
|
||||
|
||||
const metrics = await sendCommandWithTimeout<{
|
||||
cssContentSize?: { width?: number; height?: number }
|
||||
contentSize?: { width?: number; height?: number }
|
||||
}>(webContents, 'Page.getLayoutMetrics', undefined, SCREENSHOT_TIMEOUT_MESSAGE)
|
||||
const clip = getLayoutClip(metrics)
|
||||
if (!clip) {
|
||||
throw new Error('Unable to determine full-page screenshot bounds')
|
||||
}
|
||||
|
||||
const { data } = await sendCommandWithTimeout<{ data: string }>(
|
||||
webContents,
|
||||
'Page.captureScreenshot',
|
||||
{
|
||||
format,
|
||||
captureBeyondViewport: true,
|
||||
clip
|
||||
},
|
||||
SCREENSHOT_TIMEOUT_MESSAGE
|
||||
)
|
||||
|
||||
return { data, format }
|
||||
}
|
||||
|
||||
// Why: Electron's capturePage() is unreliable on webview guests — the compositor
|
||||
// may not produce frames when the webview panel is inactive, unfocused, or in a
|
||||
// split-pane layout. Instead, use the debugger's Page.captureScreenshot which
|
||||
// renders server-side in the Blink compositor and doesn't depend on OS-level
|
||||
// window focus or display state. Guard with a timeout so agent-browser doesn't
|
||||
// hang on its 30s CDP timeout if the debugger stalls.
|
||||
export function captureScreenshot(
|
||||
webContents: WebContents,
|
||||
params: Record<string, unknown> | undefined,
|
||||
onResult: (result: unknown) => void,
|
||||
onError: (message: string) => void
|
||||
): void {
|
||||
if (webContents.isDestroyed()) {
|
||||
onError('WebContents destroyed')
|
||||
return
|
||||
}
|
||||
const dbg = webContents.debugger
|
||||
if (!dbg.isAttached()) {
|
||||
onError('Debugger not attached')
|
||||
return
|
||||
}
|
||||
|
||||
const screenshotParams: Record<string, unknown> = {}
|
||||
if (params?.format) {
|
||||
screenshotParams.format = params.format
|
||||
}
|
||||
if (params?.quality) {
|
||||
screenshotParams.quality = params.quality
|
||||
}
|
||||
if (params?.clip) {
|
||||
screenshotParams.clip = params.clip
|
||||
}
|
||||
if (params?.captureBeyondViewport != null) {
|
||||
screenshotParams.captureBeyondViewport = params.captureBeyondViewport
|
||||
}
|
||||
if (params?.fromSurface != null) {
|
||||
screenshotParams.fromSurface = params.fromSurface
|
||||
}
|
||||
|
||||
let settled = false
|
||||
// Why: a compositor invalidate is cheap and can recover guest instances that
|
||||
// are visible but have not produced a fresh frame since being reclaimed into
|
||||
// the active browser tab.
|
||||
try {
|
||||
webContents.invalidate()
|
||||
} catch {
|
||||
// Some guest teardown paths reject repaint requests. Fall through to CDP.
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
if (!settled) {
|
||||
try {
|
||||
const image = await webContents.capturePage()
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const fallback = encodeNativeImageScreenshot(image, params)
|
||||
if (fallback) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
onResult(fallback)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the original timeout error below.
|
||||
}
|
||||
|
||||
if (!settled) {
|
||||
settled = true
|
||||
onError(SCREENSHOT_TIMEOUT_MESSAGE)
|
||||
}
|
||||
}
|
||||
}, SCREENSHOT_TIMEOUT_MS)
|
||||
|
||||
dbg
|
||||
.sendCommand('Page.captureScreenshot', screenshotParams)
|
||||
.then((result) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
onResult(result)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
onError((err as Error).message)
|
||||
}
|
||||
})
|
||||
}
|
||||
311
src/main/browser/cdp-ws-proxy.test.ts
Normal file
311
src/main/browser/cdp-ws-proxy.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { CdpWsProxy } from './cdp-ws-proxy'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
type DebuggerListener = (...args: unknown[]) => void
|
||||
|
||||
function createMockWebContents() {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
|
||||
const debuggerObj = {
|
||||
isAttached: vi.fn(() => false),
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
sendCommand: vi.fn(async () => ({})),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
listeners.set(event, arr)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
webContents: {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => false,
|
||||
focus: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
listeners,
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('CdpWsProxy', () => {
|
||||
let mock: ReturnType<typeof createMockWebContents>
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = createMockWebContents()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
proxy = new CdpWsProxy(mock.webContents as any)
|
||||
endpoint = await proxy.start()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
function connect(): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
}
|
||||
|
||||
function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
it('starts on a random port and returns ws:// URL', () => {
|
||||
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(proxy.getPort()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('attaches debugger on start', () => {
|
||||
expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3')
|
||||
})
|
||||
|
||||
// ── CDP message ID correlation ──
|
||||
|
||||
it('correlates CDP request/response IDs', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
|
||||
|
||||
const ws = connect()
|
||||
const client = await ws
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 42,
|
||||
method: 'Accessibility.getFullAXTree',
|
||||
params: {}
|
||||
})
|
||||
|
||||
expect(response.id).toBe(42)
|
||||
expect(response.result).toEqual({ tree: 'nodes' })
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('returns error response when sendCommand fails', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
|
||||
|
||||
const client = await connect()
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 7,
|
||||
method: 'DOM.describeNode',
|
||||
params: { nodeId: 999 }
|
||||
})
|
||||
|
||||
expect(response.id).toBe(7)
|
||||
expect(response.error).toEqual({ code: -32000, message: 'Node not found' })
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Concurrent requests get correct responses ──
|
||||
|
||||
it('handles concurrent requests with correct correlation', async () => {
|
||||
let resolveFirst: (v: unknown) => void
|
||||
const firstPromise = new Promise((r) => {
|
||||
resolveFirst = r
|
||||
})
|
||||
|
||||
mock.webContents.debugger.sendCommand
|
||||
.mockImplementationOnce(async () => {
|
||||
await firstPromise
|
||||
return { result: 'slow' }
|
||||
})
|
||||
.mockResolvedValueOnce({ result: 'fast' })
|
||||
|
||||
const client = await connect()
|
||||
|
||||
const responses: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
})
|
||||
|
||||
client.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
client.send(JSON.stringify({ id: 2, method: 'Page.enable', params: {} }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
resolveFirst!(undefined)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(responses).toHaveLength(2)
|
||||
const resp1 = responses.find((r) => r.id === 1)
|
||||
const resp2 = responses.find((r) => r.id === 2)
|
||||
expect(resp1?.result).toEqual({ result: 'slow' })
|
||||
expect(resp2?.result).toEqual({ result: 'fast' })
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not deliver a late response from a closed client to a newer websocket', async () => {
|
||||
let resolveSlowCommand: ((value: { result: string }) => void) | null = null
|
||||
mock.webContents.debugger.sendCommand
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSlowCommand = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ result: 'new-client' })
|
||||
|
||||
const firstClient = await connect()
|
||||
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const secondClient = await connect()
|
||||
const responses: Record<string, unknown>[] = []
|
||||
secondClient.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
})
|
||||
|
||||
secondClient.send(JSON.stringify({ id: 2, method: 'Page.enable', params: {} }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
resolveSlowCommand!({ result: 'old-client' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(responses).toEqual([{ id: 2, result: { result: 'new-client' } }])
|
||||
|
||||
secondClient.close()
|
||||
})
|
||||
|
||||
// ── sessionId envelope translation ──
|
||||
|
||||
it('forwards sessionId to sendCommand for OOPIF support', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
|
||||
|
||||
const client = await connect()
|
||||
await sendAndReceive(client, {
|
||||
id: 1,
|
||||
method: 'DOM.enable',
|
||||
params: {},
|
||||
sessionId: 'oopif-session-123'
|
||||
})
|
||||
|
||||
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'DOM.enable',
|
||||
{},
|
||||
'oopif-session-123'
|
||||
)
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Event forwarding ──
|
||||
|
||||
it('forwards CDP events from debugger to client', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
})
|
||||
|
||||
mock.emit('message', {}, 'Console.messageAdded', { entry: { text: 'hello' } })
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.method).toBe('Console.messageAdded')
|
||||
expect(event.params).toEqual({ entry: { text: 'hello' } })
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('forwards sessionId in events when present', async () => {
|
||||
const client = await connect()
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
})
|
||||
|
||||
mock.emit('message', {}, 'DOM.nodeInserted', { node: {} }, 'iframe-session-456')
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.sessionId).toBe('iframe-session-456')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 9,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.readyState' }
|
||||
})
|
||||
|
||||
expect(mock.webContents.focus).not.toHaveBeenCalled()
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still focuses the guest for Input.insertText', async () => {
|
||||
const client = await connect()
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 10,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'hello' }
|
||||
})
|
||||
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Page.frameNavigated interception ──
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
it('detaches debugger and closes server on stop', async () => {
|
||||
const client = await connect()
|
||||
await proxy.stop()
|
||||
|
||||
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
|
||||
expect(proxy.getPort()).toBeGreaterThan(0) // port stays set but server is closed
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
client.on('close', () => resolve())
|
||||
if (client.readyState === WebSocket.CLOSED) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects inflight requests on stop', async () => {
|
||||
let resolveCommand: (v: unknown) => void
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveCommand = r as (v: unknown) => void
|
||||
})
|
||||
)
|
||||
|
||||
const client = await connect()
|
||||
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
await proxy.stop()
|
||||
|
||||
resolveCommand!({})
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
324
src/main/browser/cdp-ws-proxy.ts
Normal file
324
src/main/browser/cdp-ws-proxy.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import type { WebContents } from 'electron'
|
||||
import { captureScreenshot } from './cdp-screenshot'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
export class CdpWsProxy {
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private client: WebSocket | null = null
|
||||
private port = 0
|
||||
private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null
|
||||
private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null
|
||||
private attached = false
|
||||
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
|
||||
private clientSessionId: string | undefined = undefined
|
||||
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
|
||||
async start(): Promise<string> {
|
||||
await this.attachDebugger()
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res))
|
||||
this.wss = new WebSocketServer({ server: this.httpServer })
|
||||
this.wss.on('connection', (ws) => {
|
||||
if (this.client) {
|
||||
this.client.close()
|
||||
}
|
||||
this.client = ws
|
||||
ws.on('message', (data) => this.handleClientMessage(ws, data.toString()))
|
||||
ws.on('close', () => {
|
||||
if (this.client === ws) {
|
||||
this.client = null
|
||||
}
|
||||
})
|
||||
})
|
||||
this.httpServer.listen(0, '127.0.0.1', () => {
|
||||
const addr = this.httpServer!.address()
|
||||
if (typeof addr === 'object' && addr) {
|
||||
this.port = addr.port
|
||||
resolve(`ws://127.0.0.1:${this.port}`)
|
||||
} else {
|
||||
reject(new Error('Failed to bind proxy server'))
|
||||
}
|
||||
})
|
||||
this.httpServer.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.detachDebugger()
|
||||
if (this.client) {
|
||||
this.client.close()
|
||||
this.client = null
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close()
|
||||
this.wss = null
|
||||
}
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close()
|
||||
this.httpServer = null
|
||||
}
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
private send(payload: unknown, client = this.client): void {
|
||||
if (client?.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(payload))
|
||||
}
|
||||
}
|
||||
|
||||
private sendResult(clientId: number, result: unknown, client = this.client): void {
|
||||
this.send({ id: clientId, result }, client)
|
||||
}
|
||||
|
||||
private sendError(clientId: number, message: string, client = this.client): void {
|
||||
this.send({ id: clientId, error: { code: -32000, message } }, client)
|
||||
}
|
||||
|
||||
private buildTargetInfo(): Record<string, unknown> {
|
||||
const destroyed = this.webContents.isDestroyed()
|
||||
return {
|
||||
targetId: 'orca-proxy-target',
|
||||
type: 'page',
|
||||
title: destroyed ? '' : this.webContents.getTitle(),
|
||||
url: destroyed ? '' : this.webContents.getURL(),
|
||||
attached: true,
|
||||
canAccessOpener: false
|
||||
}
|
||||
}
|
||||
|
||||
private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = req.url ?? ''
|
||||
if (url === '/json/version' || url === '/json/version/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
// Why: agent-browser reads this endpoint to identify the browser. Returning
|
||||
// "Orca/CdpWsProxy" leaks that this is an embedded automation surface, which
|
||||
// could affect downstream detection heuristics.
|
||||
// Why: process.versions.chrome contains the exact Chromium version
|
||||
// bundled with Electron, producing a realistic version string.
|
||||
const chromeVersion = process.versions.chrome ?? '134.0.0.0'
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
Browser: `Chrome/${chromeVersion}`,
|
||||
'Protocol-Version': '1.3',
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (url === '/json' || url === '/json/' || url === '/json/list' || url === '/json/list/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify([
|
||||
{
|
||||
...this.buildTargetInfo(),
|
||||
id: 'orca-proxy-target',
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
|
||||
}
|
||||
])
|
||||
)
|
||||
return
|
||||
}
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
}
|
||||
|
||||
private async attachDebugger(): Promise<void> {
|
||||
if (this.attached) {
|
||||
return
|
||||
}
|
||||
if (!this.webContents.debugger.isAttached()) {
|
||||
try {
|
||||
this.webContents.debugger.attach('1.3')
|
||||
} catch {
|
||||
throw new Error('Could not attach debugger. DevTools may already be open for this tab.')
|
||||
}
|
||||
}
|
||||
this.attached = true
|
||||
|
||||
// Why: attaching the CDP debugger sets navigator.webdriver = true and
|
||||
// exposes other automation signals that Cloudflare Turnstile checks.
|
||||
// Inject before any page loads so challenges succeed.
|
||||
try {
|
||||
await this.webContents.debugger.sendCommand('Page.enable', {})
|
||||
await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
} catch {
|
||||
/* best-effort — page domain may not be ready yet */
|
||||
}
|
||||
|
||||
this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => {
|
||||
const [method, params, sessionId] = rest as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
string | undefined
|
||||
]
|
||||
if (!this.client || this.client.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
// Why: Electron passes empty string (not undefined) for root-session events, but
|
||||
// agent-browser filters events by the sessionId from Target.attachToTarget.
|
||||
const msg: Record<string, unknown> = { method, params }
|
||||
msg.sessionId = sessionId || this.clientSessionId
|
||||
this.client.send(JSON.stringify(msg))
|
||||
}
|
||||
this.debuggerDetachHandler = () => {
|
||||
this.attached = false
|
||||
this.stop()
|
||||
}
|
||||
this.webContents.debugger.on('message', this.debuggerMessageHandler as never)
|
||||
this.webContents.debugger.on('detach', this.debuggerDetachHandler as never)
|
||||
}
|
||||
|
||||
private detachDebugger(): void {
|
||||
if (this.debuggerMessageHandler) {
|
||||
this.webContents.debugger.removeListener('message', this.debuggerMessageHandler as never)
|
||||
this.debuggerMessageHandler = null
|
||||
}
|
||||
if (this.debuggerDetachHandler) {
|
||||
this.webContents.debugger.removeListener('detach', this.debuggerDetachHandler as never)
|
||||
this.debuggerDetachHandler = null
|
||||
}
|
||||
if (this.attached) {
|
||||
try {
|
||||
this.webContents.debugger.detach()
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
this.attached = false
|
||||
}
|
||||
}
|
||||
|
||||
private handleClientMessage(client: WebSocket, raw: string): void {
|
||||
let msg: { id?: number; method?: string; params?: Record<string, unknown>; sessionId?: string }
|
||||
try {
|
||||
msg = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (msg.id == null || !msg.method) {
|
||||
return
|
||||
}
|
||||
const clientId = msg.id
|
||||
|
||||
if (msg.method === 'Target.getTargets') {
|
||||
this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Target.getTargetInfo') {
|
||||
this.sendResult(clientId, { targetInfo: this.buildTargetInfo() }, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') {
|
||||
if (msg.method === 'Target.detachFromTarget') {
|
||||
this.clientSessionId = undefined
|
||||
}
|
||||
this.sendResult(clientId, {}, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Target.attachToTarget') {
|
||||
this.clientSessionId = 'orca-proxy-session'
|
||||
this.sendResult(clientId, { sessionId: this.clientSessionId }, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Browser.getVersion') {
|
||||
// Why: returning "Orca/Electron" identifies this as an embedded automation
|
||||
// surface to agent-browser. Use a generic Chrome product string instead.
|
||||
const chromeVersion = process.versions.chrome ?? '134.0.0.0'
|
||||
this.sendResult(
|
||||
clientId,
|
||||
{
|
||||
protocolVersion: '1.3',
|
||||
product: `Chrome/${chromeVersion}`,
|
||||
userAgent: '',
|
||||
jsVersion: ''
|
||||
},
|
||||
client
|
||||
)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Page.bringToFront') {
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
}
|
||||
this.sendResult(clientId, {}, client)
|
||||
return
|
||||
}
|
||||
// Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests.
|
||||
if (msg.method === 'Page.captureScreenshot') {
|
||||
this.handleScreenshot(client, clientId, msg.params)
|
||||
return
|
||||
}
|
||||
// Why: Input.insertText can still require native focus in Electron webviews.
|
||||
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
|
||||
// polling and read-only JS probes use those methods heavily, and focusing on
|
||||
// every eval steals the user's foreground window while background automation
|
||||
// is running.
|
||||
if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
}
|
||||
// Why: agent-browser waits for network idle to detect navigation completion.
|
||||
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
|
||||
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
|
||||
void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
|
||||
}
|
||||
|
||||
private forwardCommand(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
msgSessionId?: string
|
||||
): void {
|
||||
const sessionId =
|
||||
msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
this.webContents.debugger
|
||||
.sendCommand(method, params, sessionId)
|
||||
.then((result) => {
|
||||
this.sendResult(clientId, result, client)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.sendError(clientId, err.message, client)
|
||||
})
|
||||
}
|
||||
|
||||
private async navigateWithLifecycleEnsured(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dbg = this.webContents.debugger
|
||||
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
|
||||
await dbg.sendCommand('Network.enable', {})
|
||||
await dbg.sendCommand('Page.enable', {})
|
||||
await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Page.navigate', params)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params?: Record<string, unknown>
|
||||
): void {
|
||||
captureScreenshot(
|
||||
this.webContents,
|
||||
params,
|
||||
(result) => this.sendResult(clientId, result, client),
|
||||
(message) => this.sendError(clientId, message, client)
|
||||
)
|
||||
}
|
||||
}
|
||||
196
src/main/browser/snapshot-engine.test.ts
Normal file
196
src/main/browser/snapshot-engine.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSnapshot, type CdpCommandSender } from './snapshot-engine'
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
role?: { type: string; value: string }
|
||||
name?: { type: string; value: string }
|
||||
properties?: { name: string; value: { type: string; value: unknown } }[]
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
function makeSender(nodes: AXNode[]): CdpCommandSender {
|
||||
return vi.fn(async (method: string) => {
|
||||
if (method === 'Accessibility.enable') {
|
||||
return {}
|
||||
}
|
||||
if (method === 'Accessibility.getFullAXTree') {
|
||||
return { nodes }
|
||||
}
|
||||
throw new Error(`Unexpected CDP method: ${method}`)
|
||||
})
|
||||
}
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
role: string,
|
||||
name: string,
|
||||
opts?: {
|
||||
childIds?: string[]
|
||||
backendDOMNodeId?: number
|
||||
ignored?: boolean
|
||||
properties?: AXNode['properties']
|
||||
}
|
||||
): AXNode {
|
||||
return {
|
||||
nodeId: id,
|
||||
backendDOMNodeId: opts?.backendDOMNodeId ?? parseInt(id, 10),
|
||||
role: { type: 'role', value: role },
|
||||
name: { type: 'computedString', value: name },
|
||||
childIds: opts?.childIds,
|
||||
ignored: opts?.ignored,
|
||||
properties: opts?.properties
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildSnapshot', () => {
|
||||
it('returns empty snapshot for empty tree', async () => {
|
||||
const result = await buildSnapshot(makeSender([]))
|
||||
expect(result.snapshot).toBe('')
|
||||
expect(result.refs).toEqual([])
|
||||
expect(result.refMap.size).toBe(0)
|
||||
})
|
||||
|
||||
it('assigns refs to interactive elements', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2', '3'] }),
|
||||
node('2', 'button', 'Submit', { backendDOMNodeId: 10 }),
|
||||
node('3', 'link', 'Home', { backendDOMNodeId: 11 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
|
||||
expect(result.refs).toHaveLength(2)
|
||||
expect(result.refs[0]).toEqual({ ref: '@e1', role: 'button', name: 'Submit' })
|
||||
expect(result.refs[1]).toEqual({ ref: '@e2', role: 'link', name: 'Home' })
|
||||
expect(result.snapshot).toContain('[@e1] button "Submit"')
|
||||
expect(result.snapshot).toContain('[@e2] link "Home"')
|
||||
})
|
||||
|
||||
it('renders text inputs with friendly role name', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'textbox', 'Email', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('text input "Email"')
|
||||
})
|
||||
|
||||
it('renders landmarks without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
|
||||
node('3', 'link', 'About', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
|
||||
expect(result.snapshot).toContain('[Main Nav]')
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0].name).toBe('About')
|
||||
})
|
||||
|
||||
it('renders headings without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'heading', 'Welcome')
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('heading "Welcome"')
|
||||
expect(result.refs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders static text without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'staticText', 'Hello world')
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('text "Hello world"')
|
||||
expect(result.refs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips generic/none/presentation roles', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'generic', '', { childIds: ['3'] }),
|
||||
node('3', 'button', 'OK', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0].name).toBe('OK')
|
||||
expect(result.snapshot).not.toContain('generic')
|
||||
})
|
||||
|
||||
it('skips ignored nodes but walks their children', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'group', 'ignored group', { childIds: ['3'], ignored: true }),
|
||||
node('3', 'button', 'Deep', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0].name).toBe('Deep')
|
||||
})
|
||||
|
||||
it('skips interactive elements without a name', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2', '3'] }),
|
||||
node('2', 'button', '', { backendDOMNodeId: 10 }),
|
||||
node('3', 'button', 'Labeled', { backendDOMNodeId: 11 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0].name).toBe('Labeled')
|
||||
})
|
||||
|
||||
it('populates refMap with backendDOMNodeId', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'checkbox', 'Agree', { backendDOMNodeId: 42 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
const entry = result.refMap.get('@e1')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.backendDOMNodeId).toBe(42)
|
||||
expect(entry!.role).toBe('checkbox')
|
||||
expect(entry!.name).toBe('Agree')
|
||||
})
|
||||
|
||||
it('indents children under landmarks', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'main', '', { childIds: ['3'] }),
|
||||
node('3', 'button', 'Action', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
const lines = result.snapshot.split('\n')
|
||||
const mainLine = lines.find((l) => l.includes('[Main Content]'))
|
||||
const buttonLine = lines.find((l) => l.includes('Action'))
|
||||
expect(mainLine).toBeDefined()
|
||||
expect(buttonLine).toBeDefined()
|
||||
expect(buttonLine!.startsWith(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles a realistic page structure', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2', '3', '4'] }),
|
||||
node('2', 'banner', '', { childIds: ['5'] }),
|
||||
node('3', 'main', '', { childIds: ['6', '7', '8'] }),
|
||||
node('4', 'contentinfo', '', {}),
|
||||
node('5', 'link', 'Logo', { backendDOMNodeId: 10 }),
|
||||
node('6', 'heading', 'Dashboard'),
|
||||
node('7', 'textbox', 'Search', { backendDOMNodeId: 20 }),
|
||||
node('8', 'button', 'Go', { backendDOMNodeId: 21 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
|
||||
expect(result.refs).toHaveLength(3)
|
||||
expect(result.refs.map((r) => r.name)).toEqual(['Logo', 'Search', 'Go'])
|
||||
|
||||
expect(result.snapshot).toContain('[Header]')
|
||||
expect(result.snapshot).toContain('[Main Content]')
|
||||
expect(result.snapshot).toContain('[Footer]')
|
||||
expect(result.snapshot).toContain('heading "Dashboard"')
|
||||
})
|
||||
})
|
||||
451
src/main/browser/snapshot-engine.ts
Normal file
451
src/main/browser/snapshot-engine.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/* eslint-disable max-lines -- Why: snapshot building, AX tree walking, ref mapping, and cursor-interactive detection are tightly coupled and belong in one module. */
|
||||
import type { BrowserSnapshotRef } from '../../shared/runtime-types'
|
||||
|
||||
export type CdpCommandSender = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
role?: { type: string; value: string }
|
||||
name?: { type: string; value: string }
|
||||
properties?: { name: string; value: { type: string; value: unknown } }[]
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
type SnapshotEntry = {
|
||||
ref: string
|
||||
role: string
|
||||
name: string
|
||||
backendDOMNodeId: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type RefEntry = {
|
||||
backendDOMNodeId: number
|
||||
role: string
|
||||
name: string
|
||||
sessionId?: string
|
||||
// Why: when multiple elements share the same role+name, nth tracks which
|
||||
// occurrence this ref represents (1-indexed). Used during stale ref recovery
|
||||
// to disambiguate duplicates.
|
||||
nth?: number
|
||||
}
|
||||
|
||||
export type SnapshotResult = {
|
||||
snapshot: string
|
||||
refs: BrowserSnapshotRef[]
|
||||
refMap: Map<string, RefEntry>
|
||||
}
|
||||
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'textbox',
|
||||
'searchbox',
|
||||
'combobox',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'switch',
|
||||
'slider',
|
||||
'spinbutton',
|
||||
'menuitem',
|
||||
'menuitemcheckbox',
|
||||
'menuitemradio',
|
||||
'tab',
|
||||
'option',
|
||||
'treeitem'
|
||||
])
|
||||
|
||||
const LANDMARK_ROLES = new Set([
|
||||
'banner',
|
||||
'navigation',
|
||||
'main',
|
||||
'complementary',
|
||||
'contentinfo',
|
||||
'region',
|
||||
'form',
|
||||
'search'
|
||||
])
|
||||
|
||||
const HEADING_PATTERN = /^heading$/
|
||||
|
||||
const SKIP_ROLES = new Set(['none', 'presentation', 'generic'])
|
||||
|
||||
export async function buildSnapshot(
|
||||
sendCommand: CdpCommandSender,
|
||||
iframeSessions?: Map<string, string>,
|
||||
makeIframeSender?: (sessionId: string) => CdpCommandSender
|
||||
): Promise<SnapshotResult> {
|
||||
await sendCommand('Accessibility.enable')
|
||||
const { nodes } = (await sendCommand('Accessibility.getFullAXTree')) as { nodes: AXNode[] }
|
||||
|
||||
const nodeById = new Map<string, AXNode>()
|
||||
for (const node of nodes) {
|
||||
nodeById.set(node.nodeId, node)
|
||||
}
|
||||
|
||||
const entries: SnapshotEntry[] = []
|
||||
let refCounter = 1
|
||||
|
||||
const root = nodes[0]
|
||||
if (!root) {
|
||||
return { snapshot: '', refs: [], refMap: new Map() }
|
||||
}
|
||||
|
||||
walkTree(root, nodeById, 0, entries, () => refCounter++)
|
||||
|
||||
// Why: many modern SPAs use styled <div>s, <span>s, and custom elements as
|
||||
// interactive controls without proper ARIA roles. These elements are invisible
|
||||
// to the accessibility tree walk above but are clearly interactive (cursor:pointer,
|
||||
// onclick, tabindex, contenteditable). This DOM query pass discovers them and
|
||||
// promotes them to interactive refs so the agent can interact with them.
|
||||
const cursorInteractiveEntries = await findCursorInteractiveElements(sendCommand, entries)
|
||||
for (const cie of cursorInteractiveEntries) {
|
||||
cie.ref = `@e${refCounter++}`
|
||||
entries.push(cie)
|
||||
}
|
||||
|
||||
// Why: cross-origin iframes have their own AX trees accessible only through
|
||||
// their dedicated CDP session. Append their elements after the parent tree
|
||||
// so the agent can see and interact with iframe content.
|
||||
const iframeRefSessions: { ref: string; sessionId: string }[] = []
|
||||
if (iframeSessions && makeIframeSender && iframeSessions.size > 0) {
|
||||
for (const [_frameId, sessionId] of iframeSessions) {
|
||||
try {
|
||||
const iframeSender = makeIframeSender(sessionId)
|
||||
await iframeSender('Accessibility.enable')
|
||||
const { nodes: iframeNodes } = (await iframeSender('Accessibility.getFullAXTree')) as {
|
||||
nodes: AXNode[]
|
||||
}
|
||||
if (iframeNodes.length === 0) {
|
||||
continue
|
||||
}
|
||||
const iframeNodeById = new Map<string, AXNode>()
|
||||
for (const n of iframeNodes) {
|
||||
iframeNodeById.set(n.nodeId, n)
|
||||
}
|
||||
const iframeRoot = iframeNodes[0]
|
||||
if (iframeRoot) {
|
||||
const startRef = refCounter
|
||||
walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++)
|
||||
for (let i = startRef; i < refCounter; i++) {
|
||||
iframeRefSessions.push({ ref: `@e${i}`, sessionId })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Iframe session may be stale — skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refMap = new Map<string, RefEntry>()
|
||||
const refs: BrowserSnapshotRef[] = []
|
||||
const lines: string[] = []
|
||||
|
||||
// Why: when multiple elements share the same role+name (e.g. 3 "Submit"
|
||||
// buttons), the agent can't distinguish them from text alone. Appending a
|
||||
// disambiguation suffix like "(2nd)" lets the agent refer to duplicates.
|
||||
const nameCounts = new Map<string, number>()
|
||||
const nameOccurrence = new Map<string, number>()
|
||||
for (const entry of entries) {
|
||||
if (entry.ref) {
|
||||
const key = `${entry.role}:${entry.name}`
|
||||
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const indent = ' '.repeat(entry.depth)
|
||||
if (entry.ref) {
|
||||
const key = `${entry.role}:${entry.name}`
|
||||
const total = nameCounts.get(key) ?? 1
|
||||
let displayName = entry.name
|
||||
const nth = (nameOccurrence.get(key) ?? 0) + 1
|
||||
nameOccurrence.set(key, nth)
|
||||
if (total > 1 && nth > 1) {
|
||||
displayName = `${entry.name} (${ordinal(nth)})`
|
||||
}
|
||||
lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`)
|
||||
refs.push({ ref: entry.ref, role: entry.role, name: displayName })
|
||||
const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref)
|
||||
refMap.set(entry.ref, {
|
||||
backendDOMNodeId: entry.backendDOMNodeId,
|
||||
role: entry.role,
|
||||
name: entry.name,
|
||||
sessionId: iframeSession?.sessionId,
|
||||
nth: total > 1 ? nth : undefined
|
||||
})
|
||||
} else {
|
||||
lines.push(`${indent}${entry.role} "${entry.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return { snapshot: lines.join('\n'), refs, refMap }
|
||||
}
|
||||
|
||||
function walkTree(
|
||||
node: AXNode,
|
||||
nodeById: Map<string, AXNode>,
|
||||
depth: number,
|
||||
entries: SnapshotEntry[],
|
||||
nextRef: () => number
|
||||
): void {
|
||||
if (node.ignored) {
|
||||
walkChildren(node, nodeById, depth, entries, nextRef)
|
||||
return
|
||||
}
|
||||
|
||||
const role = node.role?.value ?? ''
|
||||
const name = node.name?.value ?? ''
|
||||
|
||||
if (SKIP_ROLES.has(role)) {
|
||||
walkChildren(node, nodeById, depth, entries, nextRef)
|
||||
return
|
||||
}
|
||||
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role)
|
||||
const isHeading = HEADING_PATTERN.test(role)
|
||||
const isLandmark = LANDMARK_ROLES.has(role)
|
||||
const isStaticText = role === 'staticText' || role === 'StaticText'
|
||||
|
||||
if (!isInteractive && !isHeading && !isLandmark && !isStaticText) {
|
||||
walkChildren(node, nodeById, depth, entries, nextRef)
|
||||
return
|
||||
}
|
||||
|
||||
if (!name && !isLandmark) {
|
||||
walkChildren(node, nodeById, depth, entries, nextRef)
|
||||
return
|
||||
}
|
||||
|
||||
const hasFocusable = isInteractive && isFocusable(node)
|
||||
|
||||
if (isLandmark) {
|
||||
entries.push({
|
||||
ref: '',
|
||||
role: formatLandmarkRole(role, name),
|
||||
name: name || role,
|
||||
backendDOMNodeId: node.backendDOMNodeId ?? 0,
|
||||
depth
|
||||
})
|
||||
walkChildren(node, nodeById, depth + 1, entries, nextRef)
|
||||
return
|
||||
}
|
||||
|
||||
if (isHeading) {
|
||||
entries.push({
|
||||
ref: '',
|
||||
role: 'heading',
|
||||
name,
|
||||
backendDOMNodeId: node.backendDOMNodeId ?? 0,
|
||||
depth
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isStaticText && name.trim().length > 0) {
|
||||
entries.push({
|
||||
ref: '',
|
||||
role: 'text',
|
||||
name: name.trim(),
|
||||
backendDOMNodeId: node.backendDOMNodeId ?? 0,
|
||||
depth
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isInteractive && (hasFocusable || node.backendDOMNodeId)) {
|
||||
const ref = `@e${nextRef()}`
|
||||
entries.push({
|
||||
ref,
|
||||
role: formatInteractiveRole(role),
|
||||
name: name || '(unlabeled)',
|
||||
backendDOMNodeId: node.backendDOMNodeId ?? 0,
|
||||
depth
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
walkChildren(node, nodeById, depth, entries, nextRef)
|
||||
}
|
||||
|
||||
function walkChildren(
|
||||
node: AXNode,
|
||||
nodeById: Map<string, AXNode>,
|
||||
depth: number,
|
||||
entries: SnapshotEntry[],
|
||||
nextRef: () => number
|
||||
): void {
|
||||
if (!node.childIds) {
|
||||
return
|
||||
}
|
||||
for (const childId of node.childIds) {
|
||||
const child = nodeById.get(childId)
|
||||
if (child) {
|
||||
walkTree(child, nodeById, depth, entries, nextRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFocusable(node: AXNode): boolean {
|
||||
if (!node.properties) {
|
||||
return true
|
||||
}
|
||||
const focusable = node.properties.find((p) => p.name === 'focusable')
|
||||
if (focusable && focusable.value.value === false) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function formatInteractiveRole(role: string): string {
|
||||
switch (role) {
|
||||
case 'textbox':
|
||||
case 'searchbox':
|
||||
return 'text input'
|
||||
case 'combobox':
|
||||
return 'combobox'
|
||||
case 'menuitem':
|
||||
case 'menuitemcheckbox':
|
||||
case 'menuitemradio':
|
||||
return 'menu item'
|
||||
case 'spinbutton':
|
||||
return 'number input'
|
||||
case 'treeitem':
|
||||
return 'tree item'
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
function formatLandmarkRole(role: string, name: string): string {
|
||||
if (name) {
|
||||
return `[${name}]`
|
||||
}
|
||||
switch (role) {
|
||||
case 'banner':
|
||||
return '[Header]'
|
||||
case 'navigation':
|
||||
return '[Navigation]'
|
||||
case 'main':
|
||||
return '[Main Content]'
|
||||
case 'complementary':
|
||||
return '[Sidebar]'
|
||||
case 'contentinfo':
|
||||
return '[Footer]'
|
||||
case 'search':
|
||||
return '[Search]'
|
||||
default:
|
||||
return `[${role}]`
|
||||
}
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const s = ['th', 'st', 'nd', 'rd']
|
||||
const v = n % 100
|
||||
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`
|
||||
}
|
||||
|
||||
// Why: finds DOM elements that are visually interactive (cursor:pointer, onclick,
|
||||
// tabindex, contenteditable) but lack standard ARIA roles. These are common in
|
||||
// modern SPAs where styled <div>s act as buttons. Returns them as a JS array of
|
||||
// remote object references that we can resolve to backendNodeIds via CDP.
|
||||
async function findCursorInteractiveElements(
|
||||
sendCommand: CdpCommandSender,
|
||||
existingEntries: SnapshotEntry[]
|
||||
): Promise<SnapshotEntry[]> {
|
||||
const existingNodeIds = new Set(existingEntries.map((e) => e.backendDOMNodeId))
|
||||
const results: SnapshotEntry[] = []
|
||||
|
||||
try {
|
||||
// Single evaluate call that finds interactive elements and returns their info
|
||||
// along with a way to reference them by index
|
||||
const { result } = (await sendCommand('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const SKIP_ROLES = new Set(['button','link','textbox','checkbox','radio','tab',
|
||||
'menuitem','option','switch','slider','combobox','searchbox','spinbutton','treeitem',
|
||||
'menuitemcheckbox','menuitemradio']);
|
||||
const SKIP_TAGS = new Set(['input','button','select','textarea','a']);
|
||||
const seen = new Set();
|
||||
const found = [];
|
||||
const matchedElements = [];
|
||||
|
||||
function check(el) {
|
||||
if (seen.has(el)) return;
|
||||
seen.add(el);
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
const role = el.getAttribute('role');
|
||||
if (role && SKIP_ROLES.has(role)) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const text = (el.ariaLabel || el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 80);
|
||||
if (!text) return;
|
||||
found.push({ text, tag });
|
||||
matchedElements.push(el);
|
||||
if (found.length >= 50) return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[onclick], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]').forEach(el => {
|
||||
if (found.length < 50) check(el);
|
||||
});
|
||||
document.querySelectorAll('div, span, li, td, img, svg, label').forEach(el => {
|
||||
if (found.length >= 50) return;
|
||||
try {
|
||||
if (window.getComputedStyle(el).cursor === 'pointer') check(el);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
window.__orcaCursorInteractive = matchedElements;
|
||||
return JSON.stringify(found);
|
||||
})()`,
|
||||
returnByValue: true
|
||||
})) as { result: { value: string } }
|
||||
|
||||
const elements = JSON.parse(result.value) as { text: string; tag: string }[]
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
try {
|
||||
const { result: objResult } = (await sendCommand('Runtime.evaluate', {
|
||||
expression: `window.__orcaCursorInteractive[${i}]`
|
||||
})) as { result: { objectId?: string } }
|
||||
|
||||
if (!objResult.objectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { node } = (await sendCommand('DOM.describeNode', {
|
||||
objectId: objResult.objectId
|
||||
})) as { node: { backendNodeId: number } }
|
||||
|
||||
if (existingNodeIds.has(node.backendNodeId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
ref: '',
|
||||
role: 'clickable',
|
||||
name: elements[i].text,
|
||||
backendDOMNodeId: node.backendNodeId,
|
||||
depth: 0
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await sendCommand('Runtime.evaluate', {
|
||||
expression: 'delete window.__orcaCursorInteractive',
|
||||
returnByValue: true
|
||||
})
|
||||
} catch {
|
||||
// DOM query failed — not critical, just return empty
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
terminalFontSize: 14,
|
||||
terminalFontFamily: 'JetBrains Mono',
|
||||
terminalFontWeight: 500,
|
||||
terminalLineHeight: 1,
|
||||
terminalCursorStyle: 'block',
|
||||
terminalCursorBlink: false,
|
||||
terminalThemeDark: 'orca-dark',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
terminalFontSize: 14,
|
||||
terminalFontFamily: 'JetBrains Mono',
|
||||
terminalFontWeight: 500,
|
||||
terminalLineHeight: 1,
|
||||
terminalCursorStyle: 'block',
|
||||
terminalCursorBlink: false,
|
||||
terminalThemeDark: 'orca-dark',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export class DaemonClient {
|
|||
private streamSocket: Socket | null = null
|
||||
private connected = false
|
||||
private disconnectArmed = false
|
||||
// Why: after a disconnect + reconnect (daemon respawn), a stale 'close'
|
||||
// event from the old sockets can fire. Without a generation check, that
|
||||
// event would tear down the fresh connection. Each doConnect() increments
|
||||
// the generation; handleDisconnect ignores events from old generations.
|
||||
private connectionGeneration = 0
|
||||
// Why: multiple concurrent spawn() calls from simultaneous pane mounts
|
||||
// all call ensureConnected(). Without a lock, each starts a separate
|
||||
// connection attempt, overwriting sockets and triggering "Connection lost".
|
||||
|
|
@ -78,9 +83,10 @@ export class DaemonClient {
|
|||
|
||||
this.connected = true
|
||||
this.disconnectArmed = true
|
||||
this.connectionGeneration++
|
||||
|
||||
// Handle socket close
|
||||
const handleClose = () => this.handleDisconnect()
|
||||
const gen = this.connectionGeneration
|
||||
const handleClose = () => this.handleDisconnect(gen)
|
||||
this.controlSocket.on('close', handleClose)
|
||||
this.controlSocket.on('error', handleClose)
|
||||
this.streamSocket.on('close', handleClose)
|
||||
|
|
@ -270,8 +276,8 @@ export class DaemonClient {
|
|||
this.streamSocket.on('data', (chunk) => parser.feed(chunk.toString()))
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
if (!this.disconnectArmed) {
|
||||
private handleDisconnect(generation: number): void {
|
||||
if (!this.disconnectArmed || generation !== this.connectionGeneration) {
|
||||
return
|
||||
}
|
||||
this.disconnectArmed = false
|
||||
|
|
|
|||
|
|
@ -160,7 +160,16 @@ export async function initDaemonPtyProvider(): Promise<void> {
|
|||
const newAdapter = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath,
|
||||
historyPath: getHistoryDir()
|
||||
historyPath: getHistoryDir(),
|
||||
// Why: when the daemon process dies (e.g. killed by a signal, OOM, or
|
||||
// cascading from a force-quit of child processes), the adapter's
|
||||
// ensureConnected() detects the dead socket and calls this to fork a
|
||||
// replacement daemon before retrying the connection.
|
||||
respawn: async () => {
|
||||
console.warn('[daemon] Daemon process died — respawning')
|
||||
newSpawner.resetHandle()
|
||||
await newSpawner.ensureRunning()
|
||||
}
|
||||
})
|
||||
|
||||
spawner = newSpawner
|
||||
|
|
|
|||
|
|
@ -641,4 +641,98 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respawn on daemon death', () => {
|
||||
it('respawns the daemon and retries when the socket disappears', async () => {
|
||||
let respawnServer: DaemonServer | undefined
|
||||
const respawnFn = vi.fn(async () => {
|
||||
respawnServer = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await respawnServer.start()
|
||||
})
|
||||
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
|
||||
// First spawn succeeds normally
|
||||
const r1 = await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
expect(r1.id).toBeDefined()
|
||||
|
||||
// Kill the server to simulate daemon death
|
||||
await server.shutdown()
|
||||
|
||||
// Next spawn should detect the dead socket, call respawn, and succeed
|
||||
const r2 = await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
expect(r2.id).toBeDefined()
|
||||
expect(respawnFn).toHaveBeenCalledOnce()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
await respawnServer?.shutdown()
|
||||
})
|
||||
|
||||
it('propagates the error when no respawn callback is provided', async () => {
|
||||
const noRespawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
|
||||
|
||||
// First spawn succeeds
|
||||
await noRespawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
// Kill the server
|
||||
await server.shutdown()
|
||||
|
||||
// Next spawn should fail with the original socket error
|
||||
await expect(noRespawnAdapter.spawn({ cols: 80, rows: 24 })).rejects.toThrow()
|
||||
|
||||
noRespawnAdapter.dispose()
|
||||
})
|
||||
|
||||
it('coalesces concurrent respawns so only one daemon is forked', async () => {
|
||||
let respawnServer: DaemonServer | undefined
|
||||
const respawnFn = vi.fn(async () => {
|
||||
respawnServer = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await respawnServer.start()
|
||||
})
|
||||
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
|
||||
// First spawn connects
|
||||
await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
// Kill daemon
|
||||
await server.shutdown()
|
||||
|
||||
// Fire two spawns concurrently — both should succeed but only one respawn
|
||||
const [r1, r2] = await Promise.all([
|
||||
respawnAdapter.spawn({ cols: 80, rows: 24 }),
|
||||
respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
])
|
||||
expect(r1.id).toBeDefined()
|
||||
expect(r2.id).toBeDefined()
|
||||
expect(respawnFn).toHaveBeenCalledOnce()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
await respawnServer?.shutdown()
|
||||
})
|
||||
|
||||
it('propagates respawn failure to the caller', async () => {
|
||||
const respawnFn = vi.fn(async () => {
|
||||
throw new Error('Daemon entry file missing')
|
||||
})
|
||||
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
await server.shutdown()
|
||||
|
||||
await expect(respawnAdapter.spawn({ cols: 80, rows: 24 })).rejects.toThrow(
|
||||
'Daemon entry file missing'
|
||||
)
|
||||
|
||||
respawnAdapter.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export type DaemonPtyAdapterOptions = {
|
|||
/** Directory for disk-based terminal history. When set, the adapter writes
|
||||
* raw PTY output to disk for cold restore on daemon crash. */
|
||||
historyPath?: string
|
||||
/** Called when the daemon socket is unreachable (process died). Expected to
|
||||
* fork a fresh daemon so the next connection attempt can succeed. */
|
||||
respawn?: () => Promise<void>
|
||||
}
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
|
|
@ -32,6 +35,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
private client: DaemonClient
|
||||
private historyManager: HistoryManager | null
|
||||
private historyReader: HistoryReader | null
|
||||
private respawnFn: (() => Promise<void>) | null
|
||||
// Why: multiple pane mounts can call spawn() concurrently. If the daemon is
|
||||
// dead, all calls enter withDaemonRetry's catch block at once. Without a
|
||||
// lock, each would fork its own daemon process. This promise coalesces
|
||||
// concurrent respawns so only the first caller forks; the rest await it.
|
||||
private respawnPromise: Promise<void> | null = null
|
||||
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
|
||||
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
|
||||
private removeEventListener: (() => void) | null = null
|
||||
|
|
@ -54,6 +63,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
})
|
||||
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
|
||||
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
|
||||
this.respawnFn = opts.respawn ?? null
|
||||
}
|
||||
|
||||
getHistoryManager(): HistoryManager | null {
|
||||
|
|
@ -61,6 +71,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
return this.withDaemonRetry(() => this.doSpawn(opts))
|
||||
}
|
||||
|
||||
private async doSpawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
await this.ensureConnected()
|
||||
|
||||
const sessionId =
|
||||
|
|
@ -366,6 +380,37 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
this.setupEventRouting()
|
||||
}
|
||||
|
||||
// Why: when the daemon process dies, operations fail with ENOENT (socket
|
||||
// gone), ECONNREFUSED, or "Connection lost" (socket closed mid-request).
|
||||
// Rather than leaving all terminals permanently broken until app restart,
|
||||
// this wrapper detects daemon-death errors, tears down the stale client
|
||||
// state, forks a fresh daemon via respawnFn, reconnects, and retries the
|
||||
// operation once. If respawn itself fails, the error propagates normally.
|
||||
private async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
if (!this.respawnFn || !isDaemonGoneError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn().finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
return await fn()
|
||||
}
|
||||
}
|
||||
|
||||
private async doRespawn(): Promise<void> {
|
||||
console.warn('[daemon] Daemon died — respawning')
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
this.client.disconnect()
|
||||
await this.respawnFn!()
|
||||
}
|
||||
|
||||
private setupEventRouting(): void {
|
||||
if (this.removeEventListener) {
|
||||
return
|
||||
|
|
@ -402,3 +447,21 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is
|
||||
// unreachable (daemon died). Checking syscall avoids false positives from
|
||||
// token-file ENOENT (readFileSync), which has no syscall or syscall='open'.
|
||||
// "Connection lost" / "Not connected" mean the daemon died while we had an
|
||||
// active or stale connection. All indicate the daemon is gone and a respawn
|
||||
// should be attempted.
|
||||
function isDaemonGoneError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') {
|
||||
return true
|
||||
}
|
||||
const msg = err.message
|
||||
return msg === 'Connection lost' || msg === 'Not connected'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ export class DaemonSpawner {
|
|||
return { socketPath: this.socketPath, tokenPath: this.tokenPath }
|
||||
}
|
||||
|
||||
// Why: after the daemon process dies unexpectedly, the cached handle is
|
||||
// stale. Clearing it lets the next ensureRunning() fork a fresh daemon
|
||||
// instead of returning the dead socket path.
|
||||
resetHandle(): void {
|
||||
this.handle = null
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (!this.handle) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import { CodexAccountService } from './codex-accounts/service'
|
|||
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
||||
import { openCodeHookService } from './opencode/hook-service'
|
||||
import { StarNagService } from './star-nag/service'
|
||||
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
|
||||
import { browserManager } from './browser/browser-manager'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
|
||||
|
|
@ -158,9 +160,10 @@ app.whenReady().then(async () => {
|
|||
starNag = new StarNagService(store, stats)
|
||||
starNag.start()
|
||||
starNag.registerIpcHandlers()
|
||||
runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager))
|
||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||
registerAppMenu({
|
||||
onCheckForUpdates: () => checkForUpdatesFromMenu(),
|
||||
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
|
||||
onOpenSettings: () => {
|
||||
mainWindow?.webContents.send('ui:openSettings')
|
||||
},
|
||||
|
|
@ -265,6 +268,9 @@ app.on('will-quit', () => {
|
|||
openCodeHookService.stop()
|
||||
starNag?.stop()
|
||||
stats?.flush()
|
||||
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
|
||||
// holding ports and leaving stale session state on disk.
|
||||
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
|
||||
killAllPty()
|
||||
// Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app
|
||||
// quit) but the client connection must be closed so sockets are released.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ const {
|
|||
handleMock,
|
||||
registerGuestMock,
|
||||
unregisterGuestMock,
|
||||
getGuestWebContentsIdMock,
|
||||
getWorktreeIdForTabMock,
|
||||
openDevToolsMock,
|
||||
getDownloadPromptMock,
|
||||
acceptDownloadMock,
|
||||
|
|
@ -16,6 +18,8 @@ const {
|
|||
handleMock: vi.fn(),
|
||||
registerGuestMock: vi.fn(),
|
||||
unregisterGuestMock: vi.fn(),
|
||||
getGuestWebContentsIdMock: vi.fn(),
|
||||
getWorktreeIdForTabMock: vi.fn(),
|
||||
openDevToolsMock: vi.fn().mockResolvedValue(true),
|
||||
getDownloadPromptMock: vi.fn(),
|
||||
acceptDownloadMock: vi.fn(),
|
||||
|
|
@ -41,6 +45,8 @@ vi.mock('../browser/browser-manager', () => ({
|
|||
browserManager: {
|
||||
registerGuest: registerGuestMock,
|
||||
unregisterGuest: unregisterGuestMock,
|
||||
getGuestWebContentsId: getGuestWebContentsIdMock,
|
||||
getWorktreeIdForTab: getWorktreeIdForTabMock,
|
||||
openDevTools: openDevToolsMock,
|
||||
getDownloadPrompt: getDownloadPromptMock,
|
||||
acceptDownload: acceptDownloadMock,
|
||||
|
|
@ -48,7 +54,7 @@ vi.mock('../browser/browser-manager', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
import { registerBrowserHandlers, setAgentBrowserBridgeRef } from './browser'
|
||||
|
||||
describe('registerBrowserHandlers', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -56,6 +62,8 @@ describe('registerBrowserHandlers', () => {
|
|||
handleMock.mockReset()
|
||||
registerGuestMock.mockReset()
|
||||
unregisterGuestMock.mockReset()
|
||||
getGuestWebContentsIdMock.mockReset()
|
||||
getWorktreeIdForTabMock.mockReset()
|
||||
openDevToolsMock.mockReset()
|
||||
getDownloadPromptMock.mockReset()
|
||||
acceptDownloadMock.mockReset()
|
||||
|
|
@ -63,6 +71,7 @@ describe('registerBrowserHandlers', () => {
|
|||
showSaveDialogMock.mockReset()
|
||||
browserWindowFromWebContentsMock.mockReset()
|
||||
openDevToolsMock.mockResolvedValue(true)
|
||||
setAgentBrowserBridgeRef(null)
|
||||
})
|
||||
|
||||
it('rejects non-window callers', async () => {
|
||||
|
|
@ -118,4 +127,31 @@ describe('registerBrowserHandlers', () => {
|
|||
})
|
||||
expect(result).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('updates the bridge active tab for the owning worktree', async () => {
|
||||
const onTabChangedMock = vi.fn()
|
||||
getGuestWebContentsIdMock.mockReturnValue(4242)
|
||||
getWorktreeIdForTabMock.mockReturnValue('wt-browser')
|
||||
|
||||
setAgentBrowserBridgeRef({ onTabChanged: onTabChangedMock } as never)
|
||||
registerBrowserHandlers()
|
||||
|
||||
const activeTabChangedHandler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:activeTabChanged'
|
||||
)?.[1] as (event: { sender: Electron.WebContents }, args: { browserPageId: string }) => boolean
|
||||
|
||||
const result = activeTabChangedHandler(
|
||||
{
|
||||
sender: {
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as Electron.WebContents
|
||||
},
|
||||
{ browserPageId: 'page-1' }
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(onTabChangedMock).toHaveBeenCalledWith(4242, 'wt-browser')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import {
|
||||
pickCookieFile,
|
||||
|
|
@ -28,11 +29,37 @@ import type {
|
|||
} from '../../shared/types'
|
||||
|
||||
let trustedBrowserRendererWebContentsId: number | null = null
|
||||
let agentBrowserBridgeRef: AgentBrowserBridge | null = null
|
||||
|
||||
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
|
||||
// and calls registerGuest, so the tab has a webContentsId and is operable by
|
||||
// subsequent commands. This map holds one-shot resolvers keyed by browserPageId.
|
||||
const pendingTabRegistrations = new Map<string, () => void>()
|
||||
|
||||
export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise<void> {
|
||||
if (browserManager.getGuestWebContentsId(browserPageId) !== null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingTabRegistrations.delete(browserPageId)
|
||||
reject(new Error('Tab registration timed out'))
|
||||
}, timeoutMs)
|
||||
pendingTabRegistrations.set(browserPageId, () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void {
|
||||
trustedBrowserRendererWebContentsId = webContentsId
|
||||
}
|
||||
|
||||
export function setAgentBrowserBridgeRef(bridge: AgentBrowserBridge | null): void {
|
||||
agentBrowserBridgeRef = bridge
|
||||
}
|
||||
|
||||
function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean {
|
||||
if (sender.isDestroyed() || sender.getType() !== 'window') {
|
||||
return false
|
||||
|
|
@ -64,17 +91,39 @@ export function registerBrowserHandlers(): void {
|
|||
ipcMain.removeHandler('browser:cancelGrab')
|
||||
ipcMain.removeHandler('browser:captureSelectionScreenshot')
|
||||
ipcMain.removeHandler('browser:extractHoverPayload')
|
||||
ipcMain.removeHandler('browser:activeTabChanged')
|
||||
|
||||
ipcMain.handle(
|
||||
'browser:registerGuest',
|
||||
(event, args: { browserPageId: string; workspaceId: string; webContentsId: number }) => {
|
||||
(
|
||||
event,
|
||||
args: {
|
||||
browserPageId: string
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
webContentsId: number
|
||||
}
|
||||
) => {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
// Why: when Chromium swaps a guest's renderer process (navigation,
|
||||
// crash recovery), the renderer re-registers the same browserPageId
|
||||
// with a new webContentsId. The bridge must destroy the old session's
|
||||
// proxy (its webContents is gone) and let the next command recreate it.
|
||||
const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId)
|
||||
browserManager.registerGuest({
|
||||
...args,
|
||||
rendererWebContentsId: event.sender.id
|
||||
})
|
||||
if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) {
|
||||
agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId)
|
||||
}
|
||||
const pendingResolve = pendingTabRegistrations.get(args.browserPageId)
|
||||
if (pendingResolve) {
|
||||
pendingTabRegistrations.delete(args.browserPageId)
|
||||
pendingResolve()
|
||||
}
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -83,10 +132,39 @@ export function registerBrowserHandlers(): void {
|
|||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
// Why: notify bridge before unregistering so it can destroy the session
|
||||
// process and proxy. Must happen before unregisterGuest clears the mapping.
|
||||
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
|
||||
if (wcId !== null && agentBrowserBridgeRef) {
|
||||
agentBrowserBridgeRef.onTabClosed(wcId)
|
||||
}
|
||||
browserManager.unregisterGuest(args.browserPageId)
|
||||
return true
|
||||
})
|
||||
|
||||
// Why: keeps the bridge's active tab in sync with the renderer's UI state.
|
||||
// Without this, a user switching tabs in the UI would leave the agent operating
|
||||
// on the previous tab, which is confusing.
|
||||
ipcMain.handle('browser:activeTabChanged', (event, args: { browserPageId: string }) => {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
if (!agentBrowserBridgeRef) {
|
||||
return false
|
||||
}
|
||||
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
|
||||
if (wcId !== null) {
|
||||
// Why: renderer tab changes are scoped to a worktree. If we only update
|
||||
// the global active guest, later worktree-scoped commands can still
|
||||
// resolve to the previously active page inside that worktree.
|
||||
agentBrowserBridgeRef.onTabChanged(
|
||||
wcId,
|
||||
browserManager.getWorktreeIdForTab(args.browserPageId)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
|
|
|
|||
59
src/main/ipc/export.ts
Normal file
59
src/main/ipc/export.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf'
|
||||
|
||||
export type ExportHtmlToPdfArgs = {
|
||||
html: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type ExportHtmlToPdfResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; cancelled?: boolean; error?: string }
|
||||
|
||||
export function registerExportHandlers(): void {
|
||||
ipcMain.removeHandler('export:html-to-pdf')
|
||||
ipcMain.handle(
|
||||
'export:html-to-pdf',
|
||||
async (event, args: ExportHtmlToPdfArgs): Promise<ExportHtmlToPdfResult> => {
|
||||
const { html, title } = args
|
||||
if (!html.trim()) {
|
||||
return { success: false, error: 'No content to export' }
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfBuffer = await htmlToPdf(html)
|
||||
|
||||
// Why: sanitize to keep the suggested filename legal on every platform.
|
||||
// Windows forbids /\:*?"<>| in filenames; truncate to keep the OS save
|
||||
// dialog stable when titles are pathologically long.
|
||||
const sanitizedTitle = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100) || 'export'
|
||||
const defaultFilename = `${sanitizedTitle}.pdf`
|
||||
|
||||
const parent = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const dialogOptions = {
|
||||
defaultPath: defaultFilename,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }]
|
||||
}
|
||||
const { canceled, filePath } = parent
|
||||
? await dialog.showSaveDialog(parent, dialogOptions)
|
||||
: await dialog.showSaveDialog(dialogOptions)
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, cancelled: true }
|
||||
}
|
||||
|
||||
await writeFile(filePath, pdfBuffer)
|
||||
return { success: true, filePath }
|
||||
} catch (error) {
|
||||
if (error instanceof ExportTimeoutError) {
|
||||
return { success: false, error: 'Export timed out' }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to export PDF'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -20,9 +20,11 @@ const {
|
|||
registerUpdaterHandlersMock,
|
||||
registerRateLimitHandlersMock,
|
||||
registerBrowserHandlersMock,
|
||||
setAgentBrowserBridgeRefMock,
|
||||
setTrustedBrowserRendererWebContentsIdMock,
|
||||
registerFilesystemWatcherHandlersMock,
|
||||
registerAppHandlersMock
|
||||
registerAppHandlersMock,
|
||||
registerExportHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
|
|
@ -43,9 +45,11 @@ const {
|
|||
registerUpdaterHandlersMock: vi.fn(),
|
||||
registerRateLimitHandlersMock: vi.fn(),
|
||||
registerBrowserHandlersMock: vi.fn(),
|
||||
setAgentBrowserBridgeRefMock: vi.fn(),
|
||||
setTrustedBrowserRendererWebContentsIdMock: vi.fn(),
|
||||
registerFilesystemWatcherHandlersMock: vi.fn(),
|
||||
registerAppHandlersMock: vi.fn()
|
||||
registerAppHandlersMock: vi.fn(),
|
||||
registerExportHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./cli', () => ({
|
||||
|
|
@ -72,6 +76,10 @@ vi.mock('./feedback', () => ({
|
|||
registerFeedbackHandlers: registerFeedbackHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./export', () => ({
|
||||
registerExportHandlers: registerExportHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./stats', () => ({
|
||||
registerStatsHandlers: registerStatsHandlersMock
|
||||
}))
|
||||
|
|
@ -123,7 +131,8 @@ vi.mock('../window/attach-main-window-services', () => ({
|
|||
|
||||
vi.mock('./browser', () => ({
|
||||
registerBrowserHandlers: registerBrowserHandlersMock,
|
||||
setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock
|
||||
setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock,
|
||||
setAgentBrowserBridgeRef: setAgentBrowserBridgeRefMock
|
||||
}))
|
||||
|
||||
vi.mock('./app', () => ({
|
||||
|
|
@ -153,14 +162,16 @@ describe('registerCoreHandlers', () => {
|
|||
registerUpdaterHandlersMock.mockReset()
|
||||
registerRateLimitHandlersMock.mockReset()
|
||||
registerBrowserHandlersMock.mockReset()
|
||||
setAgentBrowserBridgeRefMock.mockReset()
|
||||
setTrustedBrowserRendererWebContentsIdMock.mockReset()
|
||||
registerFilesystemWatcherHandlersMock.mockReset()
|
||||
registerAppHandlersMock.mockReset()
|
||||
registerExportHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes the store through to handler registrars that need it', () => {
|
||||
const store = { marker: 'store' }
|
||||
const runtime = { marker: 'runtime' }
|
||||
const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null }
|
||||
const stats = { marker: 'stats' }
|
||||
const claudeUsage = { marker: 'claudeUsage' }
|
||||
const codexUsage = { marker: 'codexUsage' }
|
||||
|
|
@ -204,7 +215,7 @@ describe('registerCoreHandlers', () => {
|
|||
// The first test already called registerCoreHandlers, so the module-level
|
||||
// guard is now set. beforeEach reset all mocks, so call counts are 0.
|
||||
const store2 = { marker: 'store2' }
|
||||
const runtime2 = { marker: 'runtime2' }
|
||||
const runtime2 = { marker: 'runtime2', getAgentBrowserBridge: () => null }
|
||||
const stats2 = { marker: 'stats2' }
|
||||
const claudeUsage2 = { marker: 'claudeUsage2' }
|
||||
const codexUsage2 = { marker: 'codexUsage2' }
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import { registerClaudeUsageHandlers } from './claude-usage'
|
|||
import { registerCodexUsageHandlers } from './codex-usage'
|
||||
import { registerGitHubHandlers } from './github'
|
||||
import { registerFeedbackHandlers } from './feedback'
|
||||
import { registerExportHandlers } from './export'
|
||||
import { registerStatsHandlers } from './stats'
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
import { registerNotificationHandlers } from './notifications'
|
||||
import { setTrustedBrowserRendererWebContentsId } from './browser'
|
||||
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
|
||||
import { registerSessionHandlers } from './session'
|
||||
import { registerSettingsHandlers } from './settings'
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
|
|
@ -49,6 +50,7 @@ export function registerCoreHandlers(
|
|||
// if a channel is registered twice, so we guard to register only once and
|
||||
// just update the per-window web-contents ID on subsequent calls.
|
||||
setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId)
|
||||
setAgentBrowserBridgeRef(runtime.getAgentBrowserBridge())
|
||||
if (registered) {
|
||||
return
|
||||
}
|
||||
|
|
@ -63,6 +65,7 @@ export function registerCoreHandlers(
|
|||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerFeedbackHandlers()
|
||||
registerExportHandlers()
|
||||
registerStatsHandlers(stats)
|
||||
registerNotificationHandlers(store)
|
||||
registerSettingsHandlers(store)
|
||||
|
|
|
|||
|
|
@ -169,7 +169,11 @@ export function mergeWorktree(
|
|||
isUnread: meta?.isUnread ?? false,
|
||||
isPinned: meta?.isPinned ?? false,
|
||||
sortOrder: meta?.sortOrder ?? 0,
|
||||
lastActivityAt: meta?.lastActivityAt ?? 0
|
||||
lastActivityAt: meta?.lastActivityAt ?? 0,
|
||||
// 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.
|
||||
diffComments: meta?.diffComments
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
98
src/main/lib/html-to-pdf.ts
Normal file
98
src/main/lib/html-to-pdf.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { app, BrowserWindow } from 'electron'
|
||||
import { writeFile, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export class ExportTimeoutError extends Error {
|
||||
constructor(message = 'Export timed out') {
|
||||
super(message)
|
||||
this.name = 'ExportTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
const EXPORT_TIMEOUT_MS = 60_000
|
||||
|
||||
// Why: injected into the hidden export window so printToPDF does not fire while
|
||||
// <img> elements are still fetching. printToPDF renders whatever is painted at
|
||||
// the moment it runs; without this gate, remote images and Mermaid SVGs loaded
|
||||
// via <img> can be missing from the output.
|
||||
const WAIT_FOR_IMAGES_SCRIPT = `
|
||||
new Promise((resolve) => {
|
||||
const imgs = Array.from(document.images || [])
|
||||
if (imgs.length === 0) { resolve(); return }
|
||||
let remaining = imgs.length
|
||||
const done = () => { remaining -= 1; if (remaining <= 0) resolve() }
|
||||
imgs.forEach((img) => {
|
||||
if (img.complete) { done(); return }
|
||||
img.addEventListener('load', done, { once: true })
|
||||
img.addEventListener('error', done, { once: true })
|
||||
})
|
||||
})
|
||||
`
|
||||
|
||||
export async function htmlToPdf(html: string): Promise<Buffer> {
|
||||
const tempDir = app.getPath('temp')
|
||||
const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`)
|
||||
await writeFile(tempPath, html, 'utf-8')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Why: image-wait needs to run a short script inside the export page, and
|
||||
// the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid)
|
||||
// that need JS to paint correctly. The window stays sandboxed and
|
||||
// isolated so this is safe.
|
||||
javascript: true
|
||||
}
|
||||
})
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
try {
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
win.webContents.once('did-finish-load', () => resolve())
|
||||
win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => {
|
||||
reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`))
|
||||
})
|
||||
})
|
||||
|
||||
await win.loadFile(tempPath)
|
||||
await loadPromise
|
||||
|
||||
const renderAndPrint = (async (): Promise<Buffer> => {
|
||||
await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true)
|
||||
return win.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
margins: {
|
||||
top: 0.75,
|
||||
bottom: 0.75,
|
||||
left: 0.75,
|
||||
right: 0.75
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
return await Promise.race([renderAndPrint, timeoutPromise])
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy()
|
||||
}
|
||||
try {
|
||||
await unlink(tempPath)
|
||||
} catch {
|
||||
// Why: best-effort cleanup — losing the temp file should not surface
|
||||
// as a user-facing export failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,36 @@ describe('registerAppMenu', () => {
|
|||
expect(reloadMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes prereleases when Check for Updates is clicked with cmd+shift', () => {
|
||||
const options = buildMenuOptions()
|
||||
registerAppMenu(options)
|
||||
|
||||
const template = buildFromTemplateMock.mock.calls[0][0] as Electron.MenuItemConstructorOptions[]
|
||||
const appMenu = template.find((item) => item.label === 'Orca')
|
||||
const submenu = appMenu?.submenu as Electron.MenuItemConstructorOptions[]
|
||||
const item = submenu.find((entry) => entry.label === 'Check for Updates...')
|
||||
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{ ctrlKey: true, shiftKey: true } as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
|
||||
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
|
||||
|
||||
expect(options.onCheckForUpdates.mock.calls).toEqual([
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: false }],
|
||||
[{ includePrerelease: false }]
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the worktree palette shortcut as a display-only menu hint', () => {
|
||||
registerAppMenu(buildMenuOptions())
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from 'electron'
|
|||
|
||||
type RegisterAppMenuOptions = {
|
||||
onOpenSettings: () => void
|
||||
onCheckForUpdates: () => void
|
||||
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
|
|
@ -38,7 +38,19 @@ export function registerAppMenu({
|
|||
{ role: 'about' },
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => onCheckForUpdates()
|
||||
// Why: holding Cmd+Shift (or Ctrl+Shift on win/linux) while clicking
|
||||
// opts this check into the release-candidate channel. The event
|
||||
// carries the modifier keys down from the native menu — we only act
|
||||
// on the mouse chord, not accelerator-triggered invocations (there
|
||||
// is no accelerator on this item, so triggeredByAccelerator should
|
||||
// always be false here, but guarding makes the intent explicit).
|
||||
click: (_menuItem, _window, event) => {
|
||||
const includePrerelease =
|
||||
!event.triggeredByAccelerator &&
|
||||
(event.metaKey === true || event.ctrlKey === true) &&
|
||||
event.shiftKey === true
|
||||
onCheckForUpdates({ includePrerelease })
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
|
|
@ -55,6 +67,26 @@ export function registerAppMenu({
|
|||
{ role: 'quit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Export as PDF...',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => {
|
||||
// Why: fire a one-way event into the focused renderer. The renderer
|
||||
// owns the knowledge of whether a markdown surface is active and
|
||||
// what DOM to extract — when no markdown surface is active this is
|
||||
// a silent no-op on that side (see design doc §4 "Renderer UI
|
||||
// trigger"). Keeping this as a send (not an invoke) avoids main
|
||||
// needing to reason about surface state. Using
|
||||
// BrowserWindow.getFocusedWindow() rather than the menu's
|
||||
// focusedWindow param avoids the BaseWindow typing gap.
|
||||
BrowserWindow.getFocusedWindow()?.webContents.send('export:requestPdf')
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
|
|
|
|||
|
|
@ -764,4 +764,158 @@ describe('OrcaRuntimeService', () => {
|
|||
}
|
||||
])
|
||||
})
|
||||
|
||||
describe('browser page targeting', () => {
|
||||
it('passes explicit page ids through without resolving the current worktree', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const runtime = createRuntime()
|
||||
const snapshotMock = vi.fn().mockResolvedValue({
|
||||
browserPageId: 'page-1',
|
||||
snapshot: 'tree',
|
||||
refs: [],
|
||||
url: 'https://example.com',
|
||||
title: 'Example'
|
||||
})
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
snapshot: snapshotMock
|
||||
} as never)
|
||||
|
||||
const result = await runtime.browserSnapshot({ page: 'page-1' })
|
||||
|
||||
expect(result.browserPageId).toBe('page-1')
|
||||
expect(snapshotMock).toHaveBeenCalledWith(undefined, 'page-1')
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves explicit worktree selectors when page ids are also provided', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
const runtime = createRuntime()
|
||||
const snapshotMock = vi.fn().mockResolvedValue({
|
||||
browserPageId: 'page-1',
|
||||
snapshot: 'tree',
|
||||
refs: [],
|
||||
url: 'https://example.com',
|
||||
title: 'Example'
|
||||
})
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
snapshot: snapshotMock,
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
||||
} as never)
|
||||
|
||||
await runtime.browserSnapshot({
|
||||
worktree: 'branch:feature/foo',
|
||||
page: 'page-1'
|
||||
})
|
||||
|
||||
expect(snapshotMock).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'page-1')
|
||||
})
|
||||
|
||||
it('routes tab switch and capture start by explicit page id', async () => {
|
||||
const runtime = createRuntime()
|
||||
const tabSwitchMock = vi.fn().mockResolvedValue({
|
||||
switched: 2,
|
||||
browserPageId: 'page-2'
|
||||
})
|
||||
const captureStartMock = vi.fn().mockResolvedValue({
|
||||
capturing: true
|
||||
})
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
tabSwitch: tabSwitchMock,
|
||||
captureStart: captureStartMock
|
||||
} as never)
|
||||
|
||||
await expect(runtime.browserTabSwitch({ page: 'page-2' })).resolves.toEqual({
|
||||
switched: 2,
|
||||
browserPageId: 'page-2'
|
||||
})
|
||||
await expect(runtime.browserCaptureStart({ page: 'page-2' })).resolves.toEqual({
|
||||
capturing: true
|
||||
})
|
||||
expect(tabSwitchMock).toHaveBeenCalledWith(undefined, undefined, 'page-2')
|
||||
expect(captureStartMock).toHaveBeenCalledWith(undefined, 'page-2')
|
||||
})
|
||||
|
||||
it('does not silently drop invalid explicit worktree selectors for page-targeted commands', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
const runtime = createRuntime()
|
||||
const snapshotMock = vi.fn()
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
snapshot: snapshotMock,
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.browserSnapshot({
|
||||
worktree: 'path:/tmp/missing-worktree',
|
||||
page: 'page-1'
|
||||
})
|
||||
).rejects.toThrow('selector_not_found')
|
||||
expect(snapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not silently drop invalid explicit worktree selectors for non-page browser commands', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
const runtime = createRuntime()
|
||||
const tabListMock = vi.fn()
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
tabList: tabListMock
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.browserTabList({
|
||||
worktree: 'path:/tmp/missing-worktree'
|
||||
})
|
||||
).rejects.toThrow('selector_not_found')
|
||||
expect(tabListMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects closing an unknown page id instead of treating it as success', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
const runtime = createRuntime()
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.browserTabClose({
|
||||
page: 'missing-page'
|
||||
})
|
||||
).rejects.toThrow('Browser page missing-page was not found')
|
||||
})
|
||||
|
||||
it('rejects closing a page outside the explicitly scoped worktree', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
...MOCK_GIT_WORKTREES,
|
||||
{
|
||||
path: '/tmp/worktree-b',
|
||||
head: 'def',
|
||||
branch: 'feature/bar',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
const getRegisteredTabsMock = vi.fn((worktreeId?: string) =>
|
||||
worktreeId === `${TEST_REPO_ID}::/tmp/worktree-b` ? new Map() : new Map([['page-1', 1]])
|
||||
)
|
||||
|
||||
runtime.setAgentBrowserBridge({
|
||||
getRegisteredTabs: getRegisteredTabsMock
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.browserTabClose({
|
||||
page: 'page-1',
|
||||
worktree: 'path:/tmp/worktree-b'
|
||||
})
|
||||
).rejects.toThrow('Browser page page-1 was not found in this worktree')
|
||||
expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -48,12 +48,14 @@ const {
|
|||
autoUpdaterMock.downloadUpdate.mockReset()
|
||||
autoUpdaterMock.quitAndInstall.mockReset()
|
||||
autoUpdaterMock.setFeedURL.mockClear()
|
||||
autoUpdaterMock.allowPrerelease = false
|
||||
delete (autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
allowPrerelease: false,
|
||||
on,
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
|
|
@ -203,6 +205,47 @@ describe('updater', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => {
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const mainWindow = { webContents: { send: vi.fn() } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
// Why: pass a recent timestamp so the startup background check is
|
||||
// deferred. We want to observe the state of the updater *before* any
|
||||
// RC-mode call, not race with the startup check.
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
|
||||
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
|
||||
|
||||
checkForUpdatesFromMenu({ includePrerelease: true })
|
||||
|
||||
expect(autoUpdaterMock.allowPrerelease).toBe(true)
|
||||
const newCalls = autoUpdaterMock.setFeedURL.mock.calls.slice(setupFeedUrlCalls)
|
||||
expect(newCalls).toEqual([[{ provider: 'github', owner: 'stablyai', repo: 'orca' }]])
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second RC-mode invocation should not re-set the feed URL.
|
||||
checkForUpdatesFromMenu({ includePrerelease: true })
|
||||
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
|
||||
})
|
||||
|
||||
it('leaves the feed URL alone for a normal user-initiated check', async () => {
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const mainWindow = { webContents: { send: vi.fn() } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
const initialFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
|
||||
|
||||
checkForUpdatesFromMenu()
|
||||
checkForUpdatesFromMenu({ includePrerelease: false })
|
||||
|
||||
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(initialFeedUrlCalls)
|
||||
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
|
||||
})
|
||||
|
||||
it('defers quitAndInstall through the shared main-process entrypoint', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ let currentStatus: UpdateStatus = { state: 'idle' }
|
|||
let userInitiatedCheck = false
|
||||
let onBeforeQuitCleanup: (() => void) | null = null
|
||||
let autoUpdaterInitialized = false
|
||||
// Why: Cmd+Shift-clicking "Check for Updates" opts the user into the RC
|
||||
// release channel for the rest of this process. We switch to the GitHub
|
||||
// provider with allowPrerelease=true so both the check AND any follow-up
|
||||
// download resolve against the same (possibly prerelease) release manifest.
|
||||
// Resetting only after the check would leave a downloaded RC pointing at a
|
||||
// feed URL that no longer advertises it. See design comment in
|
||||
// enableIncludePrerelease.
|
||||
let includePrereleaseActive = false
|
||||
let availableVersion: string | null = null
|
||||
let availableReleaseUrl: string | null = null
|
||||
let pendingCheckFailureKey: string | null = null
|
||||
|
|
@ -267,13 +275,36 @@ export function checkForUpdates(): void {
|
|||
runBackgroundUpdateCheck()
|
||||
}
|
||||
|
||||
function enableIncludePrerelease(): void {
|
||||
if (includePrereleaseActive) {
|
||||
return
|
||||
}
|
||||
// Why: the default feed points at GitHub's /releases/latest/download/
|
||||
// manifest, which is scoped to the most recent non-prerelease release.
|
||||
// Switch to the native github provider with allowPrerelease so latest.yml
|
||||
// is sourced from the newest release on the repo regardless of the
|
||||
// prerelease flag. Staying on this feed for the rest of the process
|
||||
// keeps the download manifest consistent with the check result.
|
||||
autoUpdater.allowPrerelease = true
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'github',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
includePrereleaseActive = true
|
||||
}
|
||||
|
||||
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
|
||||
export function checkForUpdatesFromMenu(): void {
|
||||
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
|
||||
if (!app.isPackaged || is.dev) {
|
||||
sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.includePrerelease) {
|
||||
enableIncludePrerelease()
|
||||
}
|
||||
|
||||
userInitiatedCheck = true
|
||||
// Why: a manual check is independent of any active nudge campaign. Reset the
|
||||
// nudge marker so the resulting status is not decorated with activeNudgeId,
|
||||
|
|
|
|||
|
|
@ -231,7 +231,9 @@ export function registerUpdaterHandlers(_store: Store): void {
|
|||
|
||||
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
|
||||
ipcMain.handle('updater:getVersion', () => app.getVersion())
|
||||
ipcMain.handle('updater:check', () => checkForUpdatesFromMenu())
|
||||
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) =>
|
||||
checkForUpdatesFromMenu(options)
|
||||
)
|
||||
ipcMain.handle('updater:download', () => downloadUpdate())
|
||||
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
|
||||
ipcMain.handle('updater:dismissNudge', () => dismissNudge())
|
||||
|
|
|
|||
36
src/preload/api-types.d.ts
vendored
36
src/preload/api-types.d.ts
vendored
|
|
@ -89,6 +89,7 @@ export type BrowserApi = {
|
|||
registerGuest: (args: {
|
||||
browserPageId: string
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
webContentsId: number
|
||||
}) => Promise<void>
|
||||
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
|
||||
|
|
@ -107,6 +108,10 @@ export type BrowserApi = {
|
|||
onContextMenuDismissed: (
|
||||
callback: (event: BrowserContextMenuDismissedEvent) => void
|
||||
) => () => void
|
||||
onNavigationUpdate: (
|
||||
callback: (event: { browserPageId: string; url: string; title: string }) => void
|
||||
) => () => void
|
||||
onActivateView: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onOpenLinkInOrcaTab: (
|
||||
callback: (event: { browserPageId: string; url: string }) => void
|
||||
) => () => void
|
||||
|
|
@ -140,6 +145,7 @@ export type BrowserApi = {
|
|||
browserProfile?: string
|
||||
}) => Promise<BrowserCookieImportResult>
|
||||
sessionClearDefaultCookies: () => Promise<boolean>
|
||||
notifyActiveTabChanged: (args: { browserPageId: string }) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type DetectedBrowserProfileInfo = {
|
||||
|
|
@ -171,6 +177,15 @@ export type PreflightApi = {
|
|||
refreshAgents: () => Promise<RefreshAgentsResult>
|
||||
}
|
||||
|
||||
export type ExportApi = {
|
||||
htmlToPdf: (args: {
|
||||
html: string
|
||||
title: string
|
||||
}) => Promise<
|
||||
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
|
||||
>
|
||||
}
|
||||
|
||||
export type StatsApi = {
|
||||
getSummary: () => Promise<StatsSummary>
|
||||
}
|
||||
|
|
@ -254,7 +269,11 @@ export type PreloadApi = {
|
|||
}
|
||||
repos: {
|
||||
list: () => Promise<Repo[]>
|
||||
add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise<Repo>
|
||||
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
|
||||
add: (args: {
|
||||
path: string
|
||||
kind?: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
remove: (args: { repoId: string }) => Promise<void>
|
||||
update: (args: {
|
||||
repoId: string
|
||||
|
|
@ -266,12 +285,13 @@ export type PreloadApi = {
|
|||
pickDirectory: () => Promise<string | null>
|
||||
clone: (args: { url: string; destination: string }) => Promise<Repo>
|
||||
cloneAbort: () => Promise<void>
|
||||
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
|
||||
addRemote: (args: {
|
||||
connectionId: string
|
||||
remotePath: string
|
||||
displayName?: string
|
||||
kind?: 'git' | 'folder'
|
||||
}) => Promise<Repo>
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
getBaseRefDefault: (args: { repoId: string }) => Promise<string>
|
||||
|
|
@ -330,6 +350,7 @@ export type PreloadApi = {
|
|||
githubEmail: string | null
|
||||
}) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }>
|
||||
}
|
||||
export: ExportApi
|
||||
gh: {
|
||||
viewer: () => Promise<GitHubViewer | null>
|
||||
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
|
||||
|
|
@ -463,7 +484,7 @@ export type PreloadApi = {
|
|||
updater: {
|
||||
getVersion: () => Promise<string>
|
||||
getStatus: () => Promise<UpdateStatus>
|
||||
check: () => Promise<void>
|
||||
check: (options?: { includePrerelease?: boolean }) => Promise<void>
|
||||
download: () => Promise<void>
|
||||
quitAndInstall: () => Promise<void>
|
||||
dismissNudge: () => Promise<void>
|
||||
|
|
@ -594,6 +615,14 @@ export type PreloadApi = {
|
|||
onOpenQuickOpen: (callback: () => void) => () => void
|
||||
onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void
|
||||
onNewBrowserTab: (callback: () => void) => () => void
|
||||
onRequestTabCreate: (
|
||||
callback: (data: { requestId: string; url: string; worktreeId?: string }) => void
|
||||
) => () => void
|
||||
replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void
|
||||
onRequestTabClose: (
|
||||
callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void
|
||||
) => () => void
|
||||
replyTabClose: (reply: { requestId: string; error?: string }) => void
|
||||
onNewTerminalTab: (callback: () => void) => () => void
|
||||
onFocusBrowserAddressBar: (callback: () => void) => () => void
|
||||
onFindInBrowserPage: (callback: () => void) => () => void
|
||||
|
|
@ -602,6 +631,7 @@ export type PreloadApi = {
|
|||
onCloseActiveTab: (callback: () => void) => () => void
|
||||
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
|
||||
onToggleStatusBar: (callback: () => void) => () => void
|
||||
onExportPdfRequested: (callback: () => void) => () => void
|
||||
onActivateWorktree: (
|
||||
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
|
||||
) => () => void
|
||||
|
|
|
|||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
|
|
@ -104,11 +104,6 @@ type GhApi = {
|
|||
baseSha: string
|
||||
}) => Promise<GitHubPRFileContents>
|
||||
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
|
||||
createIssue: (args: {
|
||||
repoPath: string
|
||||
title: string
|
||||
body: string
|
||||
}) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
|
||||
listWorkItems: (args: {
|
||||
repoPath: string
|
||||
limit?: number
|
||||
|
|
|
|||
|
|
@ -336,6 +336,15 @@ const api = {
|
|||
ipcRenderer.invoke('feedback:submit', args)
|
||||
},
|
||||
|
||||
export: {
|
||||
htmlToPdf: (args: {
|
||||
html: string
|
||||
title: string
|
||||
}): Promise<
|
||||
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
|
||||
> => ipcRenderer.invoke('export:html-to-pdf', args)
|
||||
},
|
||||
|
||||
gh: {
|
||||
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
|
||||
|
||||
|
|
@ -500,6 +509,7 @@ const api = {
|
|||
registerGuest: (args: {
|
||||
browserPageId: string
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
webContentsId: number
|
||||
}): Promise<void> => ipcRenderer.invoke('browser:registerGuest', args),
|
||||
|
||||
|
|
@ -657,6 +667,24 @@ const api = {
|
|||
return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener)
|
||||
},
|
||||
|
||||
onNavigationUpdate: (
|
||||
callback: (event: { browserPageId: string; url: string; title: string }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { browserPageId: string; url: string; title: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('browser:navigation-update', listener)
|
||||
return () => ipcRenderer.removeListener('browser:navigation-update', listener)
|
||||
},
|
||||
|
||||
onActivateView: (callback: (data: { worktreeId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
|
||||
callback(data)
|
||||
ipcRenderer.on('browser:activateView', listener)
|
||||
return () => ipcRenderer.removeListener('browser:activateView', listener)
|
||||
},
|
||||
|
||||
onOpenLinkInOrcaTab: (
|
||||
callback: (event: { browserPageId: string; url: string }) => void
|
||||
): (() => void) => {
|
||||
|
|
@ -748,7 +776,10 @@ const api = {
|
|||
> => ipcRenderer.invoke('browser:session:importFromBrowser', args),
|
||||
|
||||
sessionClearDefaultCookies: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser:session:clearDefaultCookies')
|
||||
ipcRenderer.invoke('browser:session:clearDefaultCookies'),
|
||||
|
||||
notifyActiveTabChanged: (args: { browserPageId: string }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('browser:activeTabChanged', args)
|
||||
},
|
||||
|
||||
hooks: {
|
||||
|
|
@ -795,7 +826,8 @@ const api = {
|
|||
updater: {
|
||||
getStatus: (): Promise<unknown> => ipcRenderer.invoke('updater:getStatus'),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('updater:getVersion'),
|
||||
check: (): Promise<void> => ipcRenderer.invoke('updater:check'),
|
||||
check: (options?: { includePrerelease?: boolean }): Promise<void> =>
|
||||
ipcRenderer.invoke('updater:check', options),
|
||||
download: (): Promise<void> => ipcRenderer.invoke('updater:download'),
|
||||
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
|
||||
quitAndInstall: async (): Promise<void> => {
|
||||
|
|
@ -1050,6 +1082,36 @@ const api = {
|
|||
ipcRenderer.on('ui:newBrowserTab', listener)
|
||||
return () => ipcRenderer.removeListener('ui:newBrowserTab', listener)
|
||||
},
|
||||
onRequestTabCreate: (
|
||||
callback: (data: { requestId: string; url: string; worktreeId?: string }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { requestId: string; url: string; worktreeId?: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('browser:requestTabCreate', listener)
|
||||
return () => ipcRenderer.removeListener('browser:requestTabCreate', listener)
|
||||
},
|
||||
replyTabCreate: (reply: {
|
||||
requestId: string
|
||||
browserPageId?: string
|
||||
error?: string
|
||||
}): void => {
|
||||
ipcRenderer.send('browser:tabCreateReply', reply)
|
||||
},
|
||||
onRequestTabClose: (
|
||||
callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { requestId: string; tabId: string | null; worktreeId?: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('browser:requestTabClose', listener)
|
||||
return () => ipcRenderer.removeListener('browser:requestTabClose', listener)
|
||||
},
|
||||
replyTabClose: (reply: { requestId: string; error?: string }): void => {
|
||||
ipcRenderer.send('browser:tabCloseReply', reply)
|
||||
},
|
||||
onNewTerminalTab: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:newTerminalTab', listener)
|
||||
|
|
@ -1090,6 +1152,11 @@ const api = {
|
|||
ipcRenderer.on('ui:toggleStatusBar', listener)
|
||||
return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener)
|
||||
},
|
||||
onExportPdfRequested: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('export:requestPdf', listener)
|
||||
return () => ipcRenderer.removeListener('export:requestPdf', listener)
|
||||
},
|
||||
onActivateWorktree: (
|
||||
callback: (data: {
|
||||
repoId: string
|
||||
|
|
|
|||
|
|
@ -885,3 +885,158 @@
|
|||
.animate-update-card-exit {
|
||||
animation: update-card-exit 150ms ease-in both;
|
||||
}
|
||||
|
||||
/* ── Diff comment decorations ────────────────────────────────────── */
|
||||
|
||||
/* Why: the "+" button is appended into Monaco's editor DOM node by
|
||||
useDiffCommentDecorator. Keep it visually subtle until hovered so it does
|
||||
not distract from the diff itself. Left position overlaps the glyph margin
|
||||
so the affordance reads as "add comment on this line". */
|
||||
.orca-diff-comment-add-btn {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
color 100ms ease,
|
||||
background-color 100ms ease;
|
||||
}
|
||||
|
||||
.orca-diff-comment-add-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--background));
|
||||
}
|
||||
|
||||
.orca-diff-comment-add-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-inline {
|
||||
width: 100%;
|
||||
/* Why: match the popover's horizontal inset so the saved card lines up with
|
||||
the new-comment popover that preceded it. Both anchor at `left: 56px` and
|
||||
cap at 420px wide. */
|
||||
padding: 4px 24px 6px 56px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.orca-diff-comment-inline > .orca-diff-comment-card {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-card {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--muted) 40%, var(--background));
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.orca-diff-comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orca-diff-comment-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orca-diff-comment-delete:hover {
|
||||
color: var(--destructive);
|
||||
border-color: color-mix(in srgb, var(--destructive) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--destructive) 10%, transparent);
|
||||
}
|
||||
|
||||
.orca-diff-comment-body {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Popover for entering a new comment. Positioned absolutely within the
|
||||
section container so it tracks the clicked line via `top` style. Anchored
|
||||
near the content (past the gutter) rather than the far right, so it reads
|
||||
as attached to the line it comments on instead of floating in empty space. */
|
||||
.orca-diff-comment-popover {
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
right: 24px;
|
||||
max-width: 420px;
|
||||
z-index: 1000;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--popover, var(--background));
|
||||
color: var(--popover-foreground, var(--foreground));
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
max-height: 240px;
|
||||
resize: none;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea:focus {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border));
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -999,6 +999,7 @@ function BrowserPagePane({
|
|||
void window.api.browser.registerGuest({
|
||||
browserPageId: browserTab.id,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
webContentsId
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { Trash } from 'lucide-react'
|
||||
|
||||
// Why: the saved-comment card lives inside a Monaco view zone's DOM node.
|
||||
// useDiffCommentDecorator creates a React root per zone and renders this
|
||||
// component into it so we can use normal lucide icons and JSX instead of
|
||||
// hand-built DOM + inline SVG strings.
|
||||
|
||||
type Props = {
|
||||
lineNumber: number
|
||||
body: string
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function DiffCommentCard({ lineNumber, body, onDelete }: Props): React.JSX.Element {
|
||||
return (
|
||||
<div className="orca-diff-comment-card">
|
||||
<div className="orca-diff-comment-header">
|
||||
<span className="orca-diff-comment-meta">Comment · line {lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-delete"
|
||||
title="Delete comment"
|
||||
aria-label="Delete comment"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="orca-diff-comment-body">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
src/renderer/src/components/diff-comments/DiffCommentPopover.tsx
Normal file
150
src/renderer/src/components/diff-comments/DiffCommentPopover.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Why: rendered as a DOM sibling overlay inside the editor container rather
|
||||
// than as a Monaco content widget because it owns a React textarea with
|
||||
// auto-resize behaviour. Positioning mirrors what useDiffCommentDecorator does
|
||||
// for the "+" button so scroll updates from the parent keep the popover
|
||||
// aligned with its anchor line.
|
||||
|
||||
type Props = {
|
||||
lineNumber: number
|
||||
top: number
|
||||
onCancel: () => void
|
||||
onSubmit: (body: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function DiffCommentPopover({
|
||||
lineNumber,
|
||||
top,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}: Props): React.JSX.Element {
|
||||
const [body, setBody] = useState('')
|
||||
// Why: `submitting` prevents duplicate comment rows when the user
|
||||
// double-clicks the Comment button or hits Cmd/Ctrl+Enter twice before the
|
||||
// IPC round-trip resolves. Iteration 1 made submission async and keeps the
|
||||
// popover open on failure (to preserve the draft); that widened the window
|
||||
// between the first click and `setPopover(null)` during which a second
|
||||
// trigger would call `addDiffComment` again and produce a second row with a
|
||||
// fresh id/createdAt. Tracked in React state (not a ref) so the button can
|
||||
// reflect the in-flight status to the user.
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null)
|
||||
// Why: stash onCancel in a ref so the document mousedown listener below can
|
||||
// read the freshest callback without listing `onCancel` in its dependency
|
||||
// array. Parents (DiffSectionItem, DiffViewer) pass a new arrow function on
|
||||
// every render and the popover re-renders frequently (scroll tracking updates
|
||||
// `top`, font zoom, etc.), which would otherwise tear down and re-attach the
|
||||
// document listener on every parent render. Mirrors the pattern in
|
||||
// useDiffCommentDecorator.tsx.
|
||||
const onCancelRef = useRef(onCancel)
|
||||
onCancelRef.current = onCancel
|
||||
// Why: stable id per-instance so multiple popovers (should they ever coexist)
|
||||
// don't collide on aria-labelledby references. Screen readers announce the
|
||||
// "Line N" label as the dialog's accessible name.
|
||||
const labelId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Why: Monaco's editor area does not bubble a synthetic React click up to
|
||||
// the popover's onClick. Without a document-level mousedown listener, the
|
||||
// popover has no way to detect clicks outside its own bounds. We keep the
|
||||
// `onMouseDown={ev.stopPropagation()}` on the popover root so that this
|
||||
// listener sees outside-clicks only.
|
||||
useEffect(() => {
|
||||
const onDocumentMouseDown = (ev: MouseEvent): void => {
|
||||
if (!popoverRef.current) {
|
||||
return
|
||||
}
|
||||
if (popoverRef.current.contains(ev.target as Node)) {
|
||||
return
|
||||
}
|
||||
// Why: read the latest onCancel from the ref rather than closing over it
|
||||
// so the listener does not need to be re-registered on every parent
|
||||
// render (see onCancelRef comment above).
|
||||
onCancelRef.current()
|
||||
}
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const autoResize = (el: HTMLTextAreaElement): void => {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
|
||||
}
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (submitting) {
|
||||
return
|
||||
}
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(trimmed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="orca-diff-comment-popover"
|
||||
style={{ top: `${top}px` }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelId}
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<div id={labelId} className="orca-diff-comment-popover-label">
|
||||
Line {lineNumber}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
placeholder="Add comment for the AI"
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value)
|
||||
autoResize(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
// Why: guard against a second Cmd/Ctrl+Enter while an earlier
|
||||
// submit is still awaiting IPC — otherwise it would enqueue a
|
||||
// duplicate addDiffComment call.
|
||||
if (submitting) {
|
||||
return
|
||||
}
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="orca-diff-comment-popover-footer">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={submitting || body.trim().length === 0}>
|
||||
{submitting ? 'Saving…' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import type { editor as monacoEditor, IDisposable } from 'monaco-editor'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import { DiffCommentCard } from './DiffCommentCard'
|
||||
|
||||
// Why: Monaco glyph-margin *decorations* don't expose click events in a way
|
||||
// that lets us show a polished popover anchored to a line. So instead we own a
|
||||
// single absolutely-positioned "+" button inside the editor DOM node, and we
|
||||
// move it to follow the mouse-hovered line. Clicking calls the consumer which
|
||||
// opens a React popover. This keeps all interactive UI as React/DOM rather
|
||||
// than Monaco decorations, and we get pixel-accurate positioning via Monaco's
|
||||
// getTopForLineNumber.
|
||||
|
||||
type DecoratorArgs = {
|
||||
editor: monacoEditor.ICodeEditor | null
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
comments: DiffComment[]
|
||||
onAddCommentClick: (args: { lineNumber: number; top: number }) => void
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}
|
||||
|
||||
type ZoneEntry = {
|
||||
zoneId: string
|
||||
domNode: HTMLElement
|
||||
root: Root
|
||||
lastBody: string
|
||||
}
|
||||
|
||||
export function useDiffCommentDecorator({
|
||||
editor,
|
||||
filePath,
|
||||
worktreeId,
|
||||
comments,
|
||||
onAddCommentClick,
|
||||
onDeleteComment
|
||||
}: DecoratorArgs): void {
|
||||
const hoverLineRef = useRef<number | null>(null)
|
||||
// Why: one React root per view zone. Body updates re-render into the
|
||||
// existing root, so Monaco's zone DOM stays in place and only the card
|
||||
// contents update — matching the diff-based pass that replaced the previous
|
||||
// hand-built DOM implementation.
|
||||
const zonesRef = useRef<Map<string, ZoneEntry>>(new Map())
|
||||
const disposablesRef = useRef<IDisposable[]>([])
|
||||
// Why: stash the consumer callbacks in refs so the decorator effect's
|
||||
// cleanup does not run on every parent render. The parent passes inline
|
||||
// arrow functions; without this, each render would tear down and re-attach
|
||||
// the "+" button and all view zones, producing visible flicker.
|
||||
const onAddCommentClickRef = useRef(onAddCommentClick)
|
||||
const onDeleteCommentRef = useRef(onDeleteComment)
|
||||
onAddCommentClickRef.current = onAddCommentClick
|
||||
onDeleteCommentRef.current = onDeleteComment
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const editorDomNode = editor.getDomNode()
|
||||
if (!editorDomNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const plus = document.createElement('button')
|
||||
plus.type = 'button'
|
||||
plus.className = 'orca-diff-comment-add-btn'
|
||||
plus.title = 'Add comment for the AI'
|
||||
plus.setAttribute('aria-label', 'Add comment for the AI')
|
||||
plus.innerHTML =
|
||||
'<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>'
|
||||
plus.style.display = 'none'
|
||||
editorDomNode.appendChild(plus)
|
||||
|
||||
const getLineHeight = (): number => {
|
||||
const h = editor.getOption(monaco.editor.EditorOption.lineHeight)
|
||||
return typeof h === 'number' && h > 0 ? h : 19
|
||||
}
|
||||
|
||||
// Why: cache last-applied style values so positionAtLine skips redundant
|
||||
// DOM writes during mousemove. Monaco's onMouseMove fires at high
|
||||
// frequency, and every style assignment to an element currently under the
|
||||
// cursor can retrigger hover state and cause flicker.
|
||||
let lastTop: number | null = null
|
||||
let lastDisplay: string | null = null
|
||||
|
||||
const setDisplay = (value: string): void => {
|
||||
if (lastDisplay === value) {
|
||||
return
|
||||
}
|
||||
plus.style.display = value
|
||||
lastDisplay = value
|
||||
}
|
||||
|
||||
// Why: keep the button a fixed 18px square (height set in CSS) and
|
||||
// vertically center it within the hovered line's box. Previously the
|
||||
// height tracked the line height, producing a rectangle on editors with
|
||||
// taller line-heights. Centering relative to lineHeight keeps the button
|
||||
// sitting neatly on whatever line the cursor is on.
|
||||
const BUTTON_SIZE = 18
|
||||
const positionAtLine = (lineNumber: number): void => {
|
||||
const lineTop = editor.getTopForLineNumber(lineNumber) - editor.getScrollTop()
|
||||
const top = Math.round(lineTop + (getLineHeight() - BUTTON_SIZE) / 2)
|
||||
if (top !== lastTop) {
|
||||
plus.style.top = `${top}px`
|
||||
lastTop = top
|
||||
}
|
||||
setDisplay('flex')
|
||||
}
|
||||
|
||||
const handleClick = (ev: MouseEvent): void => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
const ln = hoverLineRef.current
|
||||
if (ln == null) {
|
||||
return
|
||||
}
|
||||
const top = editor.getTopForLineNumber(ln) - editor.getScrollTop()
|
||||
onAddCommentClickRef.current({ lineNumber: ln, top })
|
||||
}
|
||||
plus.addEventListener('mousedown', (ev) => ev.stopPropagation())
|
||||
plus.addEventListener('click', handleClick)
|
||||
|
||||
const onMouseMove = editor.onMouseMove((e) => {
|
||||
// Why: Monaco reports null position when the cursor is over overlay DOM
|
||||
// that sits inside the editor — including our own "+" button. Hiding on
|
||||
// null would create a flicker loop: cursor enters button → null → hide
|
||||
// → cursor is now over line text → show → repeat. Keep the button
|
||||
// visible at its last line while the cursor is on it. The onMouseLeave
|
||||
// handler still hides it when the cursor leaves the editor entirely.
|
||||
const srcEvent = e.event?.browserEvent as MouseEvent | undefined
|
||||
if (srcEvent && plus.contains(srcEvent.target as Node)) {
|
||||
return
|
||||
}
|
||||
const ln = e.target.position?.lineNumber ?? null
|
||||
if (ln == null) {
|
||||
setDisplay('none')
|
||||
return
|
||||
}
|
||||
hoverLineRef.current = ln
|
||||
positionAtLine(ln)
|
||||
})
|
||||
// Why: only hide the button on mouse-leave; keep hoverLineRef so that a
|
||||
// click which lands on the button (possible during the brief window after
|
||||
// Monaco's content area reports leave but before the button element does)
|
||||
// still resolves to the last-hovered line instead of silently dropping.
|
||||
const onMouseLeave = editor.onMouseLeave(() => {
|
||||
setDisplay('none')
|
||||
})
|
||||
const onScroll = editor.onDidScrollChange(() => {
|
||||
if (hoverLineRef.current != null) {
|
||||
positionAtLine(hoverLineRef.current)
|
||||
}
|
||||
})
|
||||
|
||||
disposablesRef.current = [onMouseMove, onMouseLeave, onScroll]
|
||||
|
||||
return () => {
|
||||
for (const d of disposablesRef.current) {
|
||||
d.dispose()
|
||||
}
|
||||
disposablesRef.current = []
|
||||
plus.removeEventListener('click', handleClick)
|
||||
plus.remove()
|
||||
// Why: when the editor is swapped or torn down, its view zones go with
|
||||
// it. Unmount the React roots and clear tracking so a subsequent editor
|
||||
// mount starts from a known-empty state rather than trying to remove
|
||||
// stale zone ids from a dead editor. The diff effect below deliberately
|
||||
// has no cleanup so comment-only changes don't cause a full zone
|
||||
// rebuild; this cleanup is the single place we reset zone tracking.
|
||||
for (const entry of zonesRef.current.values()) {
|
||||
entry.root.unmount()
|
||||
}
|
||||
zonesRef.current.clear()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const relevant = comments.filter((c) => c.filePath === filePath && c.worktreeId === worktreeId)
|
||||
const relevantMap = new Map(relevant.map((c) => [c.id, c] as const))
|
||||
|
||||
const zones = zonesRef.current
|
||||
// Why: unmounting a React root inside Monaco's changeViewZones callback
|
||||
// triggers synchronous DOM mutations that Monaco isn't expecting mid-flush
|
||||
// and can race with its zone bookkeeping. Collect roots to unmount, run
|
||||
// the Monaco batch, then unmount afterwards.
|
||||
const rootsToUnmount: Root[] = []
|
||||
|
||||
editor.changeViewZones((accessor) => {
|
||||
// Why: remove only the zones whose comments are gone. Rebuilding all
|
||||
// zones on every change caused flicker and dropped focus/selection in
|
||||
// adjacent UI; a diff-based pass keeps the untouched cards stable.
|
||||
for (const [commentId, entry] of zones) {
|
||||
if (!relevantMap.has(commentId)) {
|
||||
accessor.removeZone(entry.zoneId)
|
||||
rootsToUnmount.push(entry.root)
|
||||
zones.delete(commentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Add zones for newly-added comments.
|
||||
for (const c of relevant) {
|
||||
if (zones.has(c.id)) {
|
||||
continue
|
||||
}
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'orca-diff-comment-inline'
|
||||
// Why: swallow mousedown on the whole zone so the editor does not
|
||||
// steal focus (or start a selection drag) when the user interacts
|
||||
// with anything inside the card. Delete still fires because click is
|
||||
// attached directly on the button.
|
||||
dom.addEventListener('mousedown', (ev) => ev.stopPropagation())
|
||||
|
||||
const root = createRoot(dom)
|
||||
root.render(
|
||||
<DiffCommentCard
|
||||
lineNumber={c.lineNumber}
|
||||
body={c.body}
|
||||
onDelete={() => onDeleteCommentRef.current(c.id)}
|
||||
/>
|
||||
)
|
||||
|
||||
// Why: estimate height from line count so the zone is close to the
|
||||
// right size on first paint. Monaco sets heightInPx authoritatively at
|
||||
// insertion and does not re-measure the DOM node, so a fixed 72 clipped
|
||||
// multi-line bodies. The per-line estimate handles typical review
|
||||
// notes without needing a post-attach measurement pass.
|
||||
const lineCount = c.body.split('\n').length
|
||||
const heightInPx = Math.max(56, 28 + lineCount * 18)
|
||||
|
||||
// Why: suppressMouseDown: false so clicks inside the zone (Delete
|
||||
// button) reach our DOM listeners. With true, Monaco intercepts the
|
||||
// mousedown and routes it to the editor, so the Delete button never
|
||||
// fires. The delete/body mousedown listeners stopPropagation so the
|
||||
// editor still doesn't steal focus on interaction.
|
||||
const zoneId = accessor.addZone({
|
||||
afterLineNumber: c.lineNumber,
|
||||
heightInPx,
|
||||
domNode: dom,
|
||||
suppressMouseDown: false
|
||||
})
|
||||
zones.set(c.id, { zoneId, domNode: dom, root, lastBody: c.body })
|
||||
}
|
||||
|
||||
// Patch existing zones whose body text changed in place — re-render the
|
||||
// same root with new props instead of removing/re-adding the zone.
|
||||
for (const c of relevant) {
|
||||
const entry = zones.get(c.id)
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
if (entry.lastBody === c.body) {
|
||||
continue
|
||||
}
|
||||
entry.root.render(
|
||||
<DiffCommentCard
|
||||
lineNumber={c.lineNumber}
|
||||
body={c.body}
|
||||
onDelete={() => onDeleteCommentRef.current(c.id)}
|
||||
/>
|
||||
)
|
||||
entry.lastBody = c.body
|
||||
}
|
||||
})
|
||||
|
||||
// Why: deferred unmount so Monaco has finished its zone batch before we
|
||||
// tear down the React trees that were inside those zones.
|
||||
if (rootsToUnmount.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
for (const root of rootsToUnmount) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: intentionally no cleanup. React would run cleanup BEFORE the next
|
||||
// effect body on every `comments` identity change, wiping all zones and
|
||||
// forcing a full rebuild — exactly the flicker this diff-based pass is
|
||||
// meant to avoid. Zone teardown lives in the editor-scoped effect above,
|
||||
// which only fires when the editor itself is replaced/unmounted.
|
||||
}, [editor, filePath, worktreeId, comments])
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { lazy, useMemo, type MutableRefObject } from 'react'
|
||||
import React, { lazy, useEffect, useMemo, useState, type MutableRefObject } from 'react'
|
||||
import { LazySection } from './LazySection'
|
||||
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
|
|
@ -7,7 +7,10 @@ import { joinPath } from '@/lib/path'
|
|||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { useAppStore } from '@/store'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import type { GitDiffResult } from '../../../../shared/types'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import type { DiffComment, GitDiffResult } from '../../../../shared/types'
|
||||
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
|
|
@ -107,6 +110,19 @@ export function DiffSectionItem({
|
|||
}): React.JSX.Element {
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
const addDiffComment = useAppStore((s) => s.addDiffComment)
|
||||
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
|
||||
// Why: subscribe to the raw comments array on the worktree (reference-
|
||||
// stable across unrelated store updates) and filter by filePath inside a
|
||||
// memo. Selecting a fresh `.filter(...)` result would invalidate on every
|
||||
// store change and cause needless re-renders of this section.
|
||||
const allDiffComments = useAppStore(
|
||||
(s): DiffComment[] | undefined => findWorktreeById(s.worktreesByRepo, worktreeId)?.diffComments
|
||||
)
|
||||
const diffComments = useMemo(
|
||||
() => (allDiffComments ?? []).filter((c) => c.filePath === section.path),
|
||||
[allDiffComments, section.path]
|
||||
)
|
||||
const language = detectLanguage(section.path)
|
||||
const isEditable = section.area === 'unstaged'
|
||||
const editorFontSize = computeEditorFontSize(
|
||||
|
|
@ -114,6 +130,61 @@ export function DiffSectionItem({
|
|||
editorFontZoomLevel
|
||||
)
|
||||
|
||||
const [modifiedEditor, setModifiedEditor] = useState<monacoEditor.ICodeEditor | null>(null)
|
||||
const [popover, setPopover] = useState<{ lineNumber: number; top: number } | null>(null)
|
||||
|
||||
useDiffCommentDecorator({
|
||||
editor: modifiedEditor,
|
||||
filePath: section.path,
|
||||
worktreeId,
|
||||
comments: diffComments,
|
||||
onAddCommentClick: ({ lineNumber, top }) => setPopover({ lineNumber, top }),
|
||||
onDeleteComment: (id) => void deleteDiffComment(worktreeId, id)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!modifiedEditor || !popover) {
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const top =
|
||||
modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop()
|
||||
setPopover((prev) => (prev ? { ...prev, top } : prev))
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
const contentSub = modifiedEditor.onDidContentSizeChange(update)
|
||||
return () => {
|
||||
scrollSub.dispose()
|
||||
contentSub.dispose()
|
||||
}
|
||||
// Why: depend on popover.lineNumber (not the whole popover object) so the
|
||||
// effect doesn't re-subscribe on every top update it dispatches. The guard
|
||||
// on `popover` above handles the popover-closed case.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modifiedEditor, popover?.lineNumber])
|
||||
|
||||
const handleSubmitComment = async (body: string): Promise<void> => {
|
||||
if (!popover) {
|
||||
return
|
||||
}
|
||||
// Why: await persistence before closing the popover. If addDiffComment
|
||||
// resolves to null, the store rolled back the optimistic insert; keeping
|
||||
// the popover open preserves the user's draft so they can retry instead
|
||||
// of silently losing their text.
|
||||
const result = await addDiffComment({
|
||||
worktreeId,
|
||||
filePath: section.path,
|
||||
lineNumber: popover.lineNumber,
|
||||
body,
|
||||
side: 'modified'
|
||||
})
|
||||
if (result) {
|
||||
setPopover(null)
|
||||
} else {
|
||||
console.error('Failed to add diff comment — draft preserved')
|
||||
}
|
||||
}
|
||||
|
||||
const lineStats = useMemo(
|
||||
() =>
|
||||
section.loading
|
||||
|
|
@ -135,7 +206,7 @@ export function DiffSectionItem({
|
|||
}
|
||||
|
||||
const handleMount: DiffOnMount = (editor, monaco) => {
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
const modified = editor.getModifiedEditor()
|
||||
|
||||
const updateHeight = (): void => {
|
||||
const contentHeight = editor.getModifiedEditor().getContentHeight()
|
||||
|
|
@ -146,19 +217,30 @@ export function DiffSectionItem({
|
|||
return { ...prev, [index]: contentHeight }
|
||||
})
|
||||
}
|
||||
modifiedEditor.onDidContentSizeChange(updateHeight)
|
||||
modified.onDidContentSizeChange(updateHeight)
|
||||
updateHeight()
|
||||
|
||||
setModifiedEditor(modified)
|
||||
// Why: Monaco disposes inner editors when the DiffEditor container is
|
||||
// unmounted (e.g. section collapse, tab change). Clearing the state
|
||||
// prevents decorator effects and scroll subscriptions from invoking
|
||||
// methods on a disposed editor instance, and avoids `popover` pointing
|
||||
// at a line in an editor that no longer exists.
|
||||
modified.onDidDispose(() => {
|
||||
setModifiedEditor(null)
|
||||
setPopover(null)
|
||||
})
|
||||
|
||||
if (!isEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
modifiedEditorsRef.current.set(index, modifiedEditor)
|
||||
modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
|
||||
modifiedEditorsRef.current.set(index, modified)
|
||||
modified.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
|
||||
handleSectionSaveRef.current(index)
|
||||
)
|
||||
modifiedEditor.onDidChangeModelContent(() => {
|
||||
const current = modifiedEditor.getValue()
|
||||
modified.onDidChangeModelContent(() => {
|
||||
const current = modified.getValue()
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => (i === index ? { ...s, dirty: current !== s.modifiedContent } : s))
|
||||
)
|
||||
|
|
@ -233,6 +315,7 @@ export function DiffSectionItem({
|
|||
|
||||
{!section.collapsed && (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: sectionHeight
|
||||
? sectionHeight + 19
|
||||
|
|
@ -247,6 +330,18 @@ export function DiffSectionItem({
|
|||
)
|
||||
}}
|
||||
>
|
||||
{popover && (
|
||||
// Why: key by lineNumber so the popover remounts when the anchor
|
||||
// line changes, resetting the internal draft body and textarea
|
||||
// focus per anchor line instead of leaking state across lines.
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
top={popover.top}
|
||||
onCancel={() => setPopover(null)}
|
||||
onSubmit={handleSubmitComment}
|
||||
/>
|
||||
)}
|
||||
{section.loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
|
|
@ -6,6 +6,10 @@ import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache'
|
|||
import '@/lib/monaco-setup'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
|
||||
type DiffViewerProps = {
|
||||
modelKey: string
|
||||
|
|
@ -16,6 +20,10 @@ type DiffViewerProps = {
|
|||
relativePath: string
|
||||
sideBySide: boolean
|
||||
editable?: boolean
|
||||
// Why: optional because DiffViewer is also used by GitHubItemDrawer for PR
|
||||
// review, where there is no local worktree to attach comments to. When
|
||||
// omitted, the per-line comment decorator is skipped.
|
||||
worktreeId?: string
|
||||
onContentChange?: (content: string) => void
|
||||
onSave?: (content: string) => void
|
||||
}
|
||||
|
|
@ -29,11 +37,24 @@ export default function DiffViewer({
|
|||
relativePath,
|
||||
sideBySide,
|
||||
editable,
|
||||
worktreeId,
|
||||
onContentChange,
|
||||
onSave
|
||||
}: DiffViewerProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
const addDiffComment = useAppStore((s) => s.addDiffComment)
|
||||
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
|
||||
// Why: subscribe to the raw comments array on the worktree so selector
|
||||
// identity only changes when diffComments actually changes on this worktree.
|
||||
// Filtering by relativePath happens in a memo below.
|
||||
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
|
||||
worktreeId ? findWorktreeById(s.worktreesByRepo, worktreeId)?.diffComments : undefined
|
||||
)
|
||||
const diffComments = useMemo(
|
||||
() => (allDiffComments ?? []).filter((c) => c.filePath === relativePath),
|
||||
[allDiffComments, relativePath]
|
||||
)
|
||||
const editorFontSize = computeEditorFontSize(
|
||||
settings?.terminalFontSize ?? 13,
|
||||
editorFontZoomLevel
|
||||
|
|
@ -43,6 +64,66 @@ export default function DiffViewer({
|
|||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null)
|
||||
const [modifiedEditor, setModifiedEditor] = useState<editor.ICodeEditor | null>(null)
|
||||
const [popover, setPopover] = useState<{ lineNumber: number; top: number } | null>(null)
|
||||
|
||||
// Why: gate the decorator on having a worktreeId. DiffViewer is reused by
|
||||
// GitHubItemDrawer (PR review) where there is no local worktree to own the
|
||||
// comment. Pass a nulled editor so the hook no-ops rather than calling it
|
||||
// conditionally, which would violate the rules of hooks.
|
||||
useDiffCommentDecorator({
|
||||
editor: worktreeId ? modifiedEditor : null,
|
||||
filePath: relativePath,
|
||||
worktreeId: worktreeId ?? '',
|
||||
comments: diffComments,
|
||||
onAddCommentClick: ({ lineNumber, top }) => setPopover({ lineNumber, top }),
|
||||
onDeleteComment: (id) => {
|
||||
if (worktreeId) {
|
||||
void deleteDiffComment(worktreeId, id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!modifiedEditor || !popover) {
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const top =
|
||||
modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop()
|
||||
setPopover((prev) => (prev ? { ...prev, top } : prev))
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
const contentSub = modifiedEditor.onDidContentSizeChange(update)
|
||||
return () => {
|
||||
scrollSub.dispose()
|
||||
contentSub.dispose()
|
||||
}
|
||||
// Why: depend on popover.lineNumber (not the whole popover object) so the
|
||||
// effect doesn't re-subscribe on every top update it dispatches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modifiedEditor, popover?.lineNumber])
|
||||
|
||||
const handleSubmitComment = async (body: string): Promise<void> => {
|
||||
if (!popover || !worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: await persistence before closing — if addDiffComment resolves null
|
||||
// (store rolled back after IPC failure), keep the popover open so the user
|
||||
// can retry instead of silently losing their draft.
|
||||
const result = await addDiffComment({
|
||||
worktreeId,
|
||||
filePath: relativePath,
|
||||
lineNumber: popover.lineNumber,
|
||||
body,
|
||||
side: 'modified'
|
||||
})
|
||||
if (result) {
|
||||
setPopover(null)
|
||||
} else {
|
||||
console.error('Failed to add diff comment — draft preserved')
|
||||
}
|
||||
}
|
||||
|
||||
// Keep refs to latest callbacks so the mounted editor always calls current versions
|
||||
const onSaveRef = useRef(onSave)
|
||||
|
|
@ -64,6 +145,7 @@ export default function DiffViewer({
|
|||
|
||||
setupCopy(originalEditor, monaco, filePath, propsRef)
|
||||
setupCopy(modifiedEditor, monaco, filePath, propsRef)
|
||||
setModifiedEditor(modifiedEditor)
|
||||
|
||||
// Why: restoring the full diff view state matches VS Code more closely
|
||||
// than replaying scrollTop alone, and avoids divergent cursor/selection
|
||||
|
|
@ -109,7 +191,16 @@ export default function DiffViewer({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{popover && worktreeId && (
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
top={popover.top}
|
||||
onCancel={() => setPopover(null)}
|
||||
onSubmit={handleSubmitComment}
|
||||
/>
|
||||
)}
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ export function EditorContent({
|
|||
relativePath={activeFile.relativePath}
|
||||
sideBySide={sideBySide}
|
||||
editable={isEditable}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
onContentChange={isEditable ? handleContentChange : undefined}
|
||||
onSave={isEditable ? handleSave : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ across multiple components. Autosave now lives in a smaller headless controller
|
|||
so hidden editor UI no longer participates in shutdown. */
|
||||
import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { Columns2, Copy, ExternalLink, FileText, Rows2 } from 'lucide-react'
|
||||
import { Columns2, Copy, ExternalLink, FileText, MoreHorizontal, Rows2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type EditorPathMutationTarget
|
||||
} from './editor-autosave'
|
||||
import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
|
||||
import { exportActiveMarkdownToPdf } from './export-active-markdown'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
|
@ -68,6 +69,34 @@ type DiffContent = GitDiffResult
|
|||
const inFlightFileReads = new Map<string, Promise<FileContent>>()
|
||||
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
|
||||
|
||||
// Why: the "File → Export as PDF..." menu IPC fans out to every EditorPanel
|
||||
// instance, and split-pane layouts mount N panels concurrently. Without a
|
||||
// guard, a single menu click would spawn N concurrent exports — each racing
|
||||
// its own save dialog, toast, and printToPDF — producing duplicate output
|
||||
// files and confusing UX. This module-level ref-counted singleton installs
|
||||
// exactly one IPC subscription the first time any panel mounts, and tears
|
||||
// it down only when the last panel unmounts. A simple "first mounter wins"
|
||||
// counter would go dead if the first-mounting panel unmounted while others
|
||||
// were still mounted — survivors never re-subscribed and the menu silently
|
||||
// stopped working. The singleton pattern avoids that handoff bug entirely.
|
||||
let exportPdfListenerOwners = 0
|
||||
let exportPdfListenerUnsubscribe: (() => void) | null = null
|
||||
function acquireExportPdfListener(): () => void {
|
||||
exportPdfListenerOwners += 1
|
||||
if (exportPdfListenerOwners === 1) {
|
||||
exportPdfListenerUnsubscribe = window.api.ui.onExportPdfRequested(() => {
|
||||
void exportActiveMarkdownToPdf()
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
exportPdfListenerOwners -= 1
|
||||
if (exportPdfListenerOwners === 0 && exportPdfListenerUnsubscribe) {
|
||||
exportPdfListenerUnsubscribe()
|
||||
exportPdfListenerUnsubscribe = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
|
||||
return `${connectionId ?? ''}::${filePath}`
|
||||
}
|
||||
|
|
@ -151,6 +180,18 @@ function EditorPanelInner({
|
|||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
|
||||
// Why: the system "File → Export as PDF..." menu item sends a one-way IPC
|
||||
// event that reaches whichever renderer has focus. The EditorPanel is the
|
||||
// natural owner of the active markdown surface, so the listener lives here
|
||||
// and delegates to the shared export helper. Both entry points (menu and
|
||||
// overflow button) funnel through exportActiveMarkdownToPdf so toasts and
|
||||
// no-op gating stay consistent.
|
||||
// Why (guard): split-pane layouts mount multiple EditorPanelInner instances.
|
||||
// We ref-count via `acquireExportPdfListener` so exactly one IPC subscription
|
||||
// exists regardless of how many panels are mounted — and it survives panel
|
||||
// churn as long as at least one panel is still mounted.
|
||||
useEffect(() => acquireExportPdfListener(), [])
|
||||
|
||||
// Why: keepCurrentModel / keepCurrent*Model retain Monaco models after unmount
|
||||
// so undo history survives tab switches. When a tab is *closed*, the user has
|
||||
// signalled they're done with the file — dispose the models to reclaim memory
|
||||
|
|
@ -804,6 +845,37 @@ function EditorPanelInner({
|
|||
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
|
||||
/>
|
||||
)}
|
||||
{hasViewModeToggle && isMarkdown && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
// Why: the item is disabled (not hidden) only in source/Monaco
|
||||
// mode, which has no document DOM to export. We intentionally
|
||||
// don't poll the DOM (canExportActiveMarkdown) at render time:
|
||||
// the Radix content renders in a Portal and the lookup can
|
||||
// race with the active surface's paint, producing a stuck
|
||||
// disabled state. exportActiveMarkdownToPdf is a safe no-op
|
||||
// when no subtree is found.
|
||||
disabled={mdViewMode === 'source'}
|
||||
onSelect={() => {
|
||||
void exportActiveMarkdownToPdf()
|
||||
}}
|
||||
>
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={loadingFallback}>
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ export default function RichMarkdownEditor({
|
|||
query={searchQuery}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
|
||||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto scrollbar-editor">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
{linkBubble ? (
|
||||
|
|
|
|||
39
src/renderer/src/components/editor/export-active-markdown.ts
Normal file
39
src/renderer/src/components/editor/export-active-markdown.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { toast } from 'sonner'
|
||||
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
|
||||
|
||||
/**
|
||||
* Export the currently-active markdown document to PDF via the main-process
|
||||
* IPC bridge. Silent no-op when no markdown surface is active — the menu
|
||||
* item and overflow action can both share this entry point.
|
||||
*/
|
||||
export async function exportActiveMarkdownToPdf(): Promise<void> {
|
||||
const payload = getActiveMarkdownExportPayload()
|
||||
if (!payload) {
|
||||
// Why: design doc §5 — menu-triggered export with no markdown surface is
|
||||
// a silent no-op. The overflow-menu item is disabled in that case so we
|
||||
// only reach this branch for stray menu shortcuts.
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = toast.loading('Exporting PDF...')
|
||||
try {
|
||||
const result = await window.api.export.htmlToPdf({
|
||||
html: payload.html,
|
||||
title: payload.title
|
||||
})
|
||||
if (result.success) {
|
||||
toast.success(`Exported to ${result.filePath}`, { id: toastId })
|
||||
return
|
||||
}
|
||||
if (result.cancelled) {
|
||||
// Why: user pressed Cancel in the save dialog — clear the loading toast
|
||||
// without surfacing an error.
|
||||
toast.dismiss(toastId)
|
||||
return
|
||||
}
|
||||
toast.error(result.error ?? 'Failed to export PDF', { id: toastId })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to export PDF'
|
||||
toast.error(message, { id: toastId })
|
||||
}
|
||||
}
|
||||
143
src/renderer/src/components/editor/export-css.ts
Normal file
143
src/renderer/src/components/editor/export-css.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Why: this stylesheet targets the *exported* PDF document, not the live Orca
|
||||
// pane. In-app CSS assumes sticky UI chrome, hover affordances, and app-shell
|
||||
// spacing that would look wrong when flattened to paper. Keeping export CSS
|
||||
// separate also means a future UI refactor can move live classes without
|
||||
// silently breaking PDF output.
|
||||
export const EXPORT_CSS = `
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color: #1f2328;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.orca-export-root {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.orca-export-root h1,
|
||||
.orca-export-root h2,
|
||||
.orca-export-root h3,
|
||||
.orca-export-root h4,
|
||||
.orca-export-root h5,
|
||||
.orca-export-root h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.orca-export-root h1 { font-size: 1.9em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
|
||||
.orca-export-root h2 { font-size: 1.5em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
|
||||
.orca-export-root h3 { font-size: 1.25em; }
|
||||
.orca-export-root h4 { font-size: 1em; }
|
||||
|
||||
.orca-export-root p,
|
||||
.orca-export-root blockquote,
|
||||
.orca-export-root ul,
|
||||
.orca-export-root ol,
|
||||
.orca-export-root pre,
|
||||
.orca-export-root table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.orca-export-root a {
|
||||
color: #0969da;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.orca-export-root blockquote {
|
||||
padding: 0 1em;
|
||||
color: #57606a;
|
||||
border-left: 0.25em solid #d0d7de;
|
||||
}
|
||||
|
||||
.orca-export-root code,
|
||||
.orca-export-root pre {
|
||||
font-family: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.orca-export-root code {
|
||||
background: #f6f8fa;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.orca-export-root pre {
|
||||
background: #f6f8fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.orca-export-root pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.orca-export-root table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.orca-export-root th,
|
||||
.orca-export-root td {
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.orca-export-root th { background: #f6f8fa; }
|
||||
|
||||
.orca-export-root img,
|
||||
.orca-export-root svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.orca-export-root ul,
|
||||
.orca-export-root ol { padding-left: 2em; }
|
||||
|
||||
.orca-export-root li { margin: 0.25em 0; }
|
||||
|
||||
.orca-export-root input[type="checkbox"] {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
|
||||
.orca-export-root hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #d0d7de;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* Why: the export subtree selection already excludes the big chrome (toolbar,
|
||||
search bar, etc.), but in-document affordances like the code-copy button
|
||||
can still leak. Hide the well-known offenders as a belt-and-suspenders
|
||||
defense on top of DOM scrubbing. */
|
||||
.code-block-copy-btn,
|
||||
.markdown-preview-search,
|
||||
.rich-markdown-toolbar,
|
||||
[data-orca-export-hide="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper { position: static !important; }
|
||||
|
||||
@media print {
|
||||
pre, code, table, img, svg { page-break-inside: avoid; }
|
||||
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
|
||||
}
|
||||
`
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { buildMarkdownExportHtml } from './markdown-export-html'
|
||||
|
||||
export type MarkdownExportPayload = {
|
||||
title: string
|
||||
html: string
|
||||
}
|
||||
|
||||
// Why: the export subtree is the smallest DOM that represents the rendered
|
||||
// document. Preview mode uses `.markdown-body` (the .markdown-preview wrapper
|
||||
// also contains the search bar chrome), and rich mode uses `.ProseMirror`
|
||||
// (the surrounding .rich-markdown-editor-shell contains the toolbar, search
|
||||
// bar, link bubble, and slash menu as siblings).
|
||||
const DOCUMENT_SUBTREE_SELECTOR = '.ProseMirror, .markdown-body'
|
||||
|
||||
// Why: even after picking the smallest subtree, a few in-document UI leaks
|
||||
// can remain. The design doc lists these by name and treats the cloned-scrub
|
||||
// pass as a belt-and-suspenders defense so PDF output never shows copy
|
||||
// buttons, per-block search highlights, or other transient affordances.
|
||||
const UI_ONLY_SELECTORS = [
|
||||
'.code-block-copy-btn',
|
||||
'.markdown-preview-search',
|
||||
'[class*="rich-markdown-search"]',
|
||||
'[data-orca-export-hide="true"]'
|
||||
]
|
||||
|
||||
function basenameWithoutExt(filePath: string): string {
|
||||
const base = filePath.split(/[\\/]/).pop() ?? filePath
|
||||
const dot = base.lastIndexOf('.')
|
||||
return dot > 0 ? base.slice(0, dot) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the active markdown document DOM subtree. v1 uses a scoped query
|
||||
* over the whole document: there is only one active markdown surface at a
|
||||
* time, and both preview and rich modes paint a uniquely-classed container.
|
||||
* If multi-pane split view ever makes multiple surfaces visible at once,
|
||||
* this contract must be revisited (see design doc §4).
|
||||
*/
|
||||
function findActiveDocumentSubtree(): Element | null {
|
||||
return document.querySelector(DOCUMENT_SUBTREE_SELECTOR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a clean, self-contained HTML export payload from the active
|
||||
* markdown surface. Returns null when no markdown document is active or the
|
||||
* surface is in a mode (Monaco source) that does not render a document DOM.
|
||||
*/
|
||||
export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeTabType !== 'editor') {
|
||||
return null
|
||||
}
|
||||
const activeFile = state.openFiles.find((f) => f.id === state.activeFileId)
|
||||
if (!activeFile || activeFile.mode !== 'edit') {
|
||||
return null
|
||||
}
|
||||
const language = detectLanguage(activeFile.filePath)
|
||||
if (language !== 'markdown') {
|
||||
return null
|
||||
}
|
||||
|
||||
const subtree = findActiveDocumentSubtree()
|
||||
if (!subtree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clone = subtree.cloneNode(true) as Element
|
||||
for (const selector of UI_ONLY_SELECTORS) {
|
||||
for (const node of clone.querySelectorAll(selector)) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const renderedHtml = clone.innerHTML.trim()
|
||||
if (!renderedHtml) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath)
|
||||
const html = buildMarkdownExportHtml({ title, renderedHtml })
|
||||
return { title, html }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMarkdownExportHtml } from './markdown-export-html'
|
||||
|
||||
describe('buildMarkdownExportHtml', () => {
|
||||
it('wraps rendered html in a complete standalone document', () => {
|
||||
const html = buildMarkdownExportHtml({
|
||||
title: 'Hello',
|
||||
renderedHtml: '<h1>Hello</h1><p>world</p>'
|
||||
})
|
||||
expect(html.startsWith('<!DOCTYPE html>')).toBe(true)
|
||||
expect(html).toContain('<meta charset="utf-8"')
|
||||
expect(html).toContain('<title>Hello</title>')
|
||||
expect(html).toContain('<h1>Hello</h1><p>world</p>')
|
||||
expect(html).toContain('class="orca-export-root"')
|
||||
expect(html).toContain('<style>')
|
||||
})
|
||||
|
||||
it('escapes HTML-unsafe characters in the title', () => {
|
||||
const html = buildMarkdownExportHtml({
|
||||
title: '<script>alert(1)</script>',
|
||||
renderedHtml: '<p>x</p>'
|
||||
})
|
||||
expect(html).toContain('<title><script>alert(1)</script></title>')
|
||||
expect(html).not.toContain('<title><script>')
|
||||
})
|
||||
|
||||
it('falls back to "Untitled" when the title is empty', () => {
|
||||
const html = buildMarkdownExportHtml({ title: '', renderedHtml: '<p>x</p>' })
|
||||
expect(html).toContain('<title>Untitled</title>')
|
||||
})
|
||||
})
|
||||
52
src/renderer/src/components/editor/markdown-export-html.ts
Normal file
52
src/renderer/src/components/editor/markdown-export-html.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { EXPORT_CSS } from './export-css'
|
||||
|
||||
type BuildMarkdownExportHtmlArgs = {
|
||||
title: string
|
||||
renderedHtml: string
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a rendered markdown fragment in a standalone HTML document suitable
|
||||
* for Electron `webContents.printToPDF()`.
|
||||
*
|
||||
* The result is intentionally self-contained (inline CSS, no external links
|
||||
* except whatever the rendered fragment already references) so that loading
|
||||
* it from a temp file produces a stable paint regardless of the caller's
|
||||
* working directory.
|
||||
*/
|
||||
export function buildMarkdownExportHtml(args: BuildMarkdownExportHtmlArgs): string {
|
||||
const title = escapeHtml(args.title || 'Untitled')
|
||||
// Why (CSP): the generated HTML is loaded in an Electron BrowserWindow with
|
||||
// `javascript: true` (required for printToPDF layout). Without a CSP, any
|
||||
// <script> tag that leaked into the cloned rendered subtree — e.g. from a
|
||||
// malicious markdown paste or a compromised upstream renderer — would
|
||||
// execute with renderer privileges during the export. Forbidding script-src
|
||||
// entirely (no 'default-src' fallback to scripts) closes this hole while
|
||||
// still allowing inline styles (for the <style> block and element-level
|
||||
// style attributes the renderer emits), images from common schemes, and
|
||||
// data/https fonts.
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https: http: file:; style-src 'unsafe-inline'; font-src data: https:;" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>${EXPORT_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="orca-export-root">
|
||||
${args.renderedHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -12,9 +12,13 @@ import {
|
|||
FilePlus,
|
||||
FileQuestion,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
MessageSquare,
|
||||
Trash,
|
||||
TriangleAlert,
|
||||
CircleCheck,
|
||||
Search,
|
||||
|
|
@ -43,6 +47,7 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
|
||||
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
|
||||
import {
|
||||
notifyEditorExternalFileChange,
|
||||
requestEditorSaveQuiesce
|
||||
|
|
@ -50,6 +55,7 @@ import {
|
|||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { PullRequestIcon } from './checks-helpers'
|
||||
import type {
|
||||
DiffComment,
|
||||
GitBranchChangeEntry,
|
||||
GitBranchCompareSummary,
|
||||
GitConflictKind,
|
||||
|
|
@ -119,6 +125,50 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
|
||||
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
|
||||
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
|
||||
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
|
||||
// Why: pass activeWorktreeId directly (even when null/undefined) so the
|
||||
// slice's getDiffComments returns its stable EMPTY_COMMENTS sentinel. An
|
||||
// inline `[]` fallback would allocate a new array each store update, break
|
||||
// Zustand's Object.is equality, and cause this component plus the
|
||||
// diffCommentCountByPath memo to churn on every unrelated store change.
|
||||
const diffCommentsForActive = useAppStore((s) => s.getDiffComments(activeWorktreeId))
|
||||
const diffCommentCount = diffCommentsForActive.length
|
||||
// Why: per-file counts are fed into each UncommittedEntryRow so a comment
|
||||
// badge can appear next to the status letter. Compute once per render so
|
||||
// rows don't each re-filter the full list.
|
||||
const diffCommentCountByPath = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const c of diffCommentsForActive) {
|
||||
map.set(c.filePath, (map.get(c.filePath) ?? 0) + 1)
|
||||
}
|
||||
return map
|
||||
}, [diffCommentsForActive])
|
||||
const [diffCommentsExpanded, setDiffCommentsExpanded] = useState(false)
|
||||
const [diffCommentsCopied, setDiffCommentsCopied] = useState(false)
|
||||
|
||||
const handleCopyDiffComments = useCallback(async (): Promise<void> => {
|
||||
if (diffCommentsForActive.length === 0) {
|
||||
return
|
||||
}
|
||||
const text = formatDiffComments(diffCommentsForActive)
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(text)
|
||||
setDiffCommentsCopied(true)
|
||||
} catch {
|
||||
// Why: swallow — clipboard write can fail when the window isn't focused.
|
||||
// No dedicated error surface is warranted for a best-effort copy action.
|
||||
}
|
||||
}, [diffCommentsForActive])
|
||||
|
||||
// Why: auto-dismiss the "copied" indicator so the button returns to its
|
||||
// default icon after a brief confirmation window.
|
||||
useEffect(() => {
|
||||
if (!diffCommentsCopied) {
|
||||
return
|
||||
}
|
||||
const handle = window.setTimeout(() => setDiffCommentsCopied(false), 1500)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [diffCommentsCopied])
|
||||
|
||||
const [scope, setScope] = useState<SourceControlScope>('all')
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set())
|
||||
|
|
@ -645,6 +695,67 @@ function SourceControlInner(): React.JSX.Element {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Why: Diff-comments live on the worktree and apply across every diff
|
||||
view the user opens. The header row expands inline to show per-file
|
||||
comment previews plus a Copy-all action so the user can hand the
|
||||
set off to whichever tool they want without leaving the sidebar. */}
|
||||
{activeWorktreeId && worktreePath && (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-1 pl-3 pr-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setDiffCommentsExpanded((prev) => !prev)}
|
||||
aria-expanded={diffCommentsExpanded}
|
||||
title={diffCommentsExpanded ? 'Collapse comments' : 'Expand comments'}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 shrink-0 transition-transform',
|
||||
!diffCommentsExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
<MessageSquare className="size-3.5 shrink-0" />
|
||||
<span>Comments</span>
|
||||
{diffCommentCount > 0 && (
|
||||
<span className="text-[11px] leading-none text-muted-foreground tabular-nums">
|
||||
{diffCommentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{diffCommentCount > 0 && (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => void handleCopyDiffComments()}
|
||||
aria-label="Copy all comments to clipboard"
|
||||
>
|
||||
{diffCommentsCopied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Copy all comments
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{diffCommentsExpanded && (
|
||||
<DiffCommentsInlineList
|
||||
comments={diffCommentsForActive}
|
||||
onDelete={(id) => void deleteDiffComment(activeWorktreeId, id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter input for searching changed files across all sections */}
|
||||
<div className="flex items-center gap-1.5 border-b border-border px-3 py-1.5">
|
||||
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -796,6 +907,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscard={handleDiscard}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
@ -849,6 +961,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
worktreePath={worktreePath}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
onOpen={() => openCommittedDiff(entry)}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1052,6 +1165,108 @@ function SectionHeader({
|
|||
)
|
||||
}
|
||||
|
||||
function DiffCommentsInlineList({
|
||||
comments,
|
||||
onDelete
|
||||
}: {
|
||||
comments: DiffComment[]
|
||||
onDelete: (commentId: string) => void
|
||||
}): React.JSX.Element {
|
||||
// Why: group by filePath so the inline list mirrors the structure in the
|
||||
// Comments tab — a compact section per file with line-number prefixes.
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, DiffComment[]>()
|
||||
for (const c of comments) {
|
||||
const list = map.get(c.filePath) ?? []
|
||||
list.push(c)
|
||||
map.set(c.filePath, list)
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.lineNumber - b.lineNumber)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [comments])
|
||||
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
// Why: auto-dismiss the per-row "copied" indicator so the button returns to
|
||||
// its default icon after a brief confirmation window. Matches the top-level
|
||||
// Copy button's behavior.
|
||||
useEffect(() => {
|
||||
if (!copiedId) {
|
||||
return
|
||||
}
|
||||
const handle = window.setTimeout(() => setCopiedId(null), 1500)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [copiedId])
|
||||
|
||||
const handleCopyOne = useCallback(async (c: DiffComment): Promise<void> => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(formatDiffComment(c))
|
||||
setCopiedId(c.id)
|
||||
} catch {
|
||||
// Why: swallow — clipboard write can fail when the window isn't focused.
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (comments.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-2 text-[11px] text-muted-foreground">
|
||||
Hover over a line in the diff view and click the + to add a comment.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/20">
|
||||
{groups.map(([filePath, list]) => (
|
||||
<div key={filePath} className="px-3 py-1.5">
|
||||
<div className="truncate text-[10px] font-medium text-muted-foreground">{filePath}</div>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{list.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="group flex items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent/40"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] leading-none tabular-nums text-muted-foreground">
|
||||
L{c.lineNumber}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words text-[11px] leading-snug text-foreground">
|
||||
{c.body}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
void handleCopyOne(c)
|
||||
}}
|
||||
title="Copy comment"
|
||||
aria-label={`Copy comment on line ${c.lineNumber}`}
|
||||
>
|
||||
{copiedId === c.id ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
onDelete(c.id)
|
||||
}}
|
||||
title="Delete comment"
|
||||
aria-label={`Delete comment on line ${c.lineNumber}`}
|
||||
>
|
||||
<Trash className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictSummaryCard({
|
||||
conflictOperation,
|
||||
unresolvedCount,
|
||||
|
|
@ -1143,7 +1358,8 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
onOpen,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard
|
||||
onDiscard,
|
||||
commentCount
|
||||
}: {
|
||||
entryKey: string
|
||||
entry: GitStatusEntry
|
||||
|
|
@ -1157,6 +1373,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
onStage: (filePath: string) => Promise<void>
|
||||
onUnstage: (filePath: string) => Promise<void>
|
||||
onDiscard: (filePath: string) => Promise<void>
|
||||
commentCount: number
|
||||
}): React.JSX.Element {
|
||||
const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion
|
||||
const fileName = basename(entry.path)
|
||||
|
|
@ -1229,6 +1446,18 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
|||
<div className="truncate text-[11px] text-muted-foreground">{conflictLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
{commentCount > 0 && (
|
||||
// Why: show a small comment marker on any row that has diff comments
|
||||
// so the user can tell at a glance which files have review notes
|
||||
// attached, without opening the Comments tab.
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground"
|
||||
title={`${commentCount} comment${commentCount === 1 ? '' : 's'}`}
|
||||
>
|
||||
<MessageSquare className="size-3" />
|
||||
<span className="tabular-nums">{commentCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.conflictStatus ? (
|
||||
<ConflictBadge entry={entry} />
|
||||
) : (
|
||||
|
|
@ -1317,13 +1546,15 @@ function BranchEntryRow({
|
|||
currentWorktreeId,
|
||||
worktreePath,
|
||||
onRevealInExplorer,
|
||||
onOpen
|
||||
onOpen,
|
||||
commentCount
|
||||
}: {
|
||||
entry: GitBranchChangeEntry
|
||||
currentWorktreeId: string
|
||||
worktreePath: string
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpen: () => void
|
||||
commentCount: number
|
||||
}): React.JSX.Element {
|
||||
const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion
|
||||
const fileName = basename(entry.path)
|
||||
|
|
@ -1351,6 +1582,15 @@ function BranchEntryRow({
|
|||
<span className="text-foreground">{fileName}</span>
|
||||
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
|
||||
</span>
|
||||
{commentCount > 0 && (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground"
|
||||
title={`${commentCount} comment${commentCount === 1 ? '' : 's'}`}
|
||||
>
|
||||
<MessageSquare className="size-3" />
|
||||
<span className="tabular-nums">{commentCount}</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="w-4 shrink-0 text-center text-[10px] font-bold"
|
||||
style={{ color: STATUS_COLORS[entry.status] }}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ export { EXPERIMENTAL_PANE_SEARCH_ENTRIES }
|
|||
type ExperimentalPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
/** Hidden-experimental group is only rendered once the user has unlocked
|
||||
* it via Cmd+Shift-clicking the Experimental sidebar entry. */
|
||||
hiddenExperimentalUnlocked?: boolean
|
||||
}
|
||||
|
||||
export function ExperimentalPane({
|
||||
settings,
|
||||
updateSettings
|
||||
updateSettings,
|
||||
hiddenExperimentalUnlocked = false
|
||||
}: ExperimentalPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
// Why: "daemon enabled at startup" is the effective runtime state, read
|
||||
|
|
@ -134,6 +138,44 @@ export function ExperimentalPane({
|
|||
) : null}
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// affordance without another round of styling decisions.
|
||||
function HiddenExperimentalGroup(): React.JSX.Element {
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-orange-500/40 bg-orange-500/5 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-sm font-semibold text-orange-500 dark:text-orange-300">
|
||||
Hidden experimental
|
||||
</h4>
|
||||
<p className="text-xs text-orange-500/80 dark:text-orange-300/80">
|
||||
Unlisted toggles for internal testing. Nothing here is supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-orange-500/30 bg-orange-500/10 px-3 py-2.5">
|
||||
<div className="min-w-0 shrink space-y-0.5">
|
||||
<Label className="text-orange-600 dark:text-orange-300">Placeholder toggle</Label>
|
||||
<p className="text-xs text-orange-600/80 dark:text-orange-300/80">
|
||||
Does nothing today. Reserved as the first slot for hidden experimental options.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Placeholder toggle"
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-not-allowed items-center rounded-full border border-orange-500/40 bg-orange-500/20 opacity-70"
|
||||
disabled
|
||||
>
|
||||
<span className="inline-block h-3.5 w-3.5 translate-x-0.5 transform rounded-full bg-orange-200 shadow-sm dark:bg-orange-100" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -768,7 +768,14 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.api.updater.check()}
|
||||
// Why: Cmd+Shift-click (Ctrl+Shift on win/linux) opts this check
|
||||
// into the release-candidate channel. Keep the affordance hidden
|
||||
// — it's a power-user shortcut, not a discoverable toggle.
|
||||
onClick={(event) =>
|
||||
window.api.updater.check({
|
||||
includePrerelease: (event.metaKey || event.ctrlKey) && event.shiftKey
|
||||
})
|
||||
}
|
||||
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
|
||||
className="gap-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -137,6 +137,11 @@ function Settings(): React.JSX.Element {
|
|||
getFallbackTerminalFonts()
|
||||
)
|
||||
const [activeSectionId, setActiveSectionId] = useState('general')
|
||||
// Why: the hidden-experimental group is an unlock — Cmd+Shift-clicking the
|
||||
// Experimental sidebar entry reveals it for the remainder of the session.
|
||||
// Not persisted on purpose: it's a power-user affordance we don't want to
|
||||
// leak through into a normal reopen of Settings.
|
||||
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
|
||||
const contentScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalFontsLoadedRef = useRef(false)
|
||||
const pendingNavSectionRef = useRef<string | null>(null)
|
||||
|
|
@ -343,7 +348,7 @@ function Settings(): React.JSX.Element {
|
|||
{
|
||||
id: 'experimental',
|
||||
title: 'Experimental',
|
||||
description: 'Features that are still being stabilized. Enable at your own risk.',
|
||||
description: 'New features that are still taking shape. Give them a try.',
|
||||
icon: FlaskConical,
|
||||
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES
|
||||
},
|
||||
|
|
@ -464,11 +469,30 @@ function Settings(): React.JSX.Element {
|
|||
}
|
||||
}, [visibleNavSections])
|
||||
|
||||
const scrollToSection = useCallback((sectionId: string) => {
|
||||
scrollSectionIntoView(sectionId, contentScrollRef.current)
|
||||
flashSectionHighlight(sectionId)
|
||||
setActiveSectionId(sectionId)
|
||||
}, [])
|
||||
const scrollToSection = useCallback(
|
||||
(
|
||||
sectionId: string,
|
||||
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
|
||||
) => {
|
||||
// Why: Cmd+Shift-clicking (Ctrl+Shift on win/linux) the Experimental
|
||||
// sidebar entry unlocks a hidden power-user group. Keep this scoped to
|
||||
// the Experimental row so normal shortcut combos on other rows don't
|
||||
// accidentally flip state. The unlock persists for the life of the
|
||||
// Settings view (resets when Settings is reopened).
|
||||
if (
|
||||
sectionId === 'experimental' &&
|
||||
modifiers &&
|
||||
(modifiers.metaKey || modifiers.ctrlKey) &&
|
||||
modifiers.shiftKey
|
||||
) {
|
||||
setHiddenExperimentalUnlocked((previous) => !previous)
|
||||
}
|
||||
scrollSectionIntoView(sectionId, contentScrollRef.current)
|
||||
flashSectionHighlight(sectionId)
|
||||
setActiveSectionId(sectionId)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
|
|
@ -617,10 +641,14 @@ function Settings(): React.JSX.Element {
|
|||
<SettingsSection
|
||||
id="experimental"
|
||||
title="Experimental"
|
||||
description="Features that are still being stabilized. Enable at your own risk."
|
||||
description="New features that are still taking shape. Give them a try."
|
||||
searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES}
|
||||
>
|
||||
<ExperimentalPane settings={settings} updateSettings={updateSettings} />
|
||||
<ExperimentalPane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
hiddenExperimentalUnlocked={hiddenExperimentalUnlocked}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{repos.map((repo) => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ type SettingsSidebarProps = {
|
|||
searchQuery: string
|
||||
onBack: () => void
|
||||
onSearchChange: (query: string) => void
|
||||
onSelectSection: (sectionId: string) => void
|
||||
onSelectSection: (
|
||||
sectionId: string,
|
||||
modifiers: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
|
||||
) => void
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
|
|
@ -71,7 +74,14 @@ export function SettingsSidebar({
|
|||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
onClick={(event) =>
|
||||
onSelectSection(section.id, {
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent font-medium text-accent-foreground'
|
||||
|
|
@ -103,7 +113,14 @@ export function SettingsSidebar({
|
|||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
onClick={(event) =>
|
||||
onSelectSection(section.id, {
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent font-medium text-accent-foreground'
|
||||
|
|
|
|||
|
|
@ -171,6 +171,28 @@ export function TerminalPane({
|
|||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title="Line Height"
|
||||
description="Controls the terminal line height multiplier."
|
||||
keywords={['terminal', 'typography', 'line height', 'spacing']}
|
||||
>
|
||||
<NumberField
|
||||
label="Line Height"
|
||||
description="Controls the terminal line height multiplier."
|
||||
value={settings.terminalLineHeight}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
suffix="1 to 3"
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalLineHeight: clampNumber(value, 1, 3)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TERMINAL_CURSOR_SEARCH_ENTRIES) ? (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ export const TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
title: 'Font Weight',
|
||||
description: 'Controls the terminal text font weight.',
|
||||
keywords: ['terminal', 'typography', 'weight']
|
||||
},
|
||||
{
|
||||
title: 'Line Height',
|
||||
description: 'Controls the terminal line height multiplier.',
|
||||
keywords: ['terminal', 'typography', 'line height', 'spacing']
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function applyTerminalAppearance(
|
|||
pane.terminal.options.fontWeight = terminalFontWeights.fontWeight
|
||||
pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold
|
||||
pane.terminal.options.macOptionIsMeta = settings.terminalMacOptionAsAlt === 'true'
|
||||
pane.terminal.options.lineHeight = settings.terminalLineHeight
|
||||
try {
|
||||
const state = captureScrollState(pane.terminal)
|
||||
pane.fitAddon.fit()
|
||||
|
|
|
|||
|
|
@ -433,7 +433,8 @@ export function useTerminalPaneLifecycle({
|
|||
),
|
||||
cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar',
|
||||
cursorBlink: currentSettings?.terminalCursorBlink ?? true,
|
||||
macOptionIsMeta: currentSettings?.terminalMacOptionAsAlt === 'true'
|
||||
macOptionIsMeta: currentSettings?.terminalMacOptionAsAlt === 'true',
|
||||
lineHeight: currentSettings?.terminalLineHeight ?? 1
|
||||
}
|
||||
},
|
||||
onLinkClick: (event, url) => {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ describe('useIpcEvents updater integration', () => {
|
|||
onJumpToWorktreeIndex: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: () => () => {},
|
||||
replyTabClose: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
|
|
@ -171,7 +175,9 @@ describe('useIpcEvents updater integration', () => {
|
|||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {}
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
|
|
@ -314,6 +320,10 @@ describe('useIpcEvents updater integration', () => {
|
|||
onJumpToWorktreeIndex: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: () => () => {},
|
||||
replyTabClose: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
|
|
@ -330,7 +340,9 @@ describe('useIpcEvents updater integration', () => {
|
|||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {}
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
|
|
@ -372,6 +384,500 @@ describe('useIpcEvents updater integration', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents browser tab close routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('closes the active browser tab for the requested worktree when main does not provide a tab id', async () => {
|
||||
const closeBrowserTab = vi.fn()
|
||||
const closeBrowserPage = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const tabCloseListenerRef: {
|
||||
current:
|
||||
| ((data: { requestId: string; tabId: string | null; worktreeId?: string }) => void)
|
||||
| null
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeView: 'terminal',
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserTabPageState: vi.fn(),
|
||||
activeTabType: 'browser',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
settings: { terminalFontSize: 13 },
|
||||
activeBrowserTabId: 'workspace-global',
|
||||
activeBrowserTabIdByWorktree: {
|
||||
'wt-1': 'workspace-global',
|
||||
'wt-2': 'workspace-target'
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [{ id: 'workspace-global' }],
|
||||
'wt-2': [{ id: 'workspace-target' }]
|
||||
},
|
||||
browserPagesByWorkspace: {},
|
||||
closeBrowserTab,
|
||||
closeBrowserPage
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/ui-zoom', () => ({
|
||||
applyUIZoom: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({
|
||||
dispatchZoomLevelChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: { onChanged: () => () => {} },
|
||||
ui: {
|
||||
onOpenSettings: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: (
|
||||
listener: (data: {
|
||||
requestId: string
|
||||
tabId: string | null
|
||||
worktreeId?: string
|
||||
}) => void
|
||||
) => {
|
||||
tabCloseListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabClose,
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
expect(tabCloseListenerRef.current).toBeTypeOf('function')
|
||||
tabCloseListenerRef.current?.({
|
||||
requestId: 'req-1',
|
||||
tabId: null,
|
||||
worktreeId: 'wt-2'
|
||||
})
|
||||
|
||||
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-target')
|
||||
expect(closeBrowserPage).not.toHaveBeenCalled()
|
||||
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-1' })
|
||||
})
|
||||
|
||||
it('closes only the requested browser page when a workspace has multiple pages', async () => {
|
||||
const closeBrowserTab = vi.fn()
|
||||
const closeBrowserPage = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const tabCloseListenerRef: {
|
||||
current:
|
||||
| ((data: { requestId: string; tabId: string | null; worktreeId?: string }) => void)
|
||||
| null
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeView: 'terminal',
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserTabPageState: vi.fn(),
|
||||
activeTabType: 'browser',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
settings: { terminalFontSize: 13 },
|
||||
activeBrowserTabId: 'workspace-1',
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [{ id: 'workspace-1' }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{ id: 'page-1', workspaceId: 'workspace-1' },
|
||||
{ id: 'page-2', workspaceId: 'workspace-1' }
|
||||
]
|
||||
},
|
||||
closeBrowserTab,
|
||||
closeBrowserPage
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/ui-zoom', () => ({
|
||||
applyUIZoom: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({
|
||||
dispatchZoomLevelChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: { onChanged: () => () => {} },
|
||||
ui: {
|
||||
onOpenSettings: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: (
|
||||
listener: (data: {
|
||||
requestId: string
|
||||
tabId: string | null
|
||||
worktreeId?: string
|
||||
}) => void
|
||||
) => {
|
||||
tabCloseListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabClose,
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
tabCloseListenerRef.current?.({
|
||||
requestId: 'req-2',
|
||||
tabId: 'page-2'
|
||||
})
|
||||
|
||||
expect(closeBrowserPage).toHaveBeenCalledWith('page-2')
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(replyTabClose).toHaveBeenCalledWith({ requestId: 'req-2' })
|
||||
})
|
||||
|
||||
it('rejects explicit unknown browser page ids instead of reporting success', async () => {
|
||||
const closeBrowserTab = vi.fn()
|
||||
const closeBrowserPage = vi.fn()
|
||||
const replyTabClose = vi.fn()
|
||||
const tabCloseListenerRef: {
|
||||
current:
|
||||
| ((data: { requestId: string; tabId: string | null; worktreeId?: string }) => void)
|
||||
| null
|
||||
} = {
|
||||
current: null
|
||||
}
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeView: 'terminal',
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserTabPageState: vi.fn(),
|
||||
activeTabType: 'browser',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
settings: { terminalFontSize: 13 },
|
||||
activeBrowserTabId: 'workspace-1',
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [{ id: 'workspace-1' }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1' }]
|
||||
},
|
||||
closeBrowserTab,
|
||||
closeBrowserPage
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/ui-zoom', () => ({
|
||||
applyUIZoom: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({
|
||||
dispatchZoomLevelChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: { onChanged: () => () => {} },
|
||||
ui: {
|
||||
onOpenSettings: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: (
|
||||
listener: (data: {
|
||||
requestId: string
|
||||
tabId: string | null
|
||||
worktreeId?: string
|
||||
}) => void
|
||||
) => {
|
||||
tabCloseListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabClose,
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
tabCloseListenerRef.current?.({
|
||||
requestId: 'req-3',
|
||||
tabId: 'missing-page'
|
||||
})
|
||||
|
||||
expect(closeBrowserPage).not.toHaveBeenCalled()
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
expect(replyTabClose).toHaveBeenCalledWith({
|
||||
requestId: 'req-3',
|
||||
error: 'Browser tab missing-page not found'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents shortcut hint clearing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
|
@ -485,6 +991,10 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
|||
},
|
||||
onActivateWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
onRequestTabClose: () => () => {},
|
||||
replyTabClose: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
|
|
@ -501,7 +1011,9 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
|||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {}
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
|
|
|
|||
|
|
@ -156,6 +156,28 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
)
|
||||
|
||||
// Why: agent-browser drives navigation via CDP, bypassing Electron's webview
|
||||
// event system. The renderer's did-navigate listener never fires for those
|
||||
// navigations, so the Zustand store (address bar, tab title) stays stale.
|
||||
// This IPC pushes the live URL/title from main after goto/click/back/reload.
|
||||
unsubs.push(
|
||||
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
|
||||
const store = useAppStore.getState()
|
||||
store.setBrowserPageUrl(browserPageId, url)
|
||||
store.updateBrowserPageState(browserPageId, { title, loading: false })
|
||||
})
|
||||
)
|
||||
|
||||
// Why: browser webviews only start their guest process when the container
|
||||
// has display != none. After app restart, activeTabType defaults to 'terminal'
|
||||
// so persisted browser tabs never mount. The main process sends this IPC
|
||||
// before browser commands so the webview can start and registerGuest fires.
|
||||
unsubs.push(
|
||||
window.api.browser.onActivateView(() => {
|
||||
useAppStore.getState().setActiveTabType('browser')
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
|
||||
const store = useAppStore.getState()
|
||||
|
|
@ -187,6 +209,92 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
)
|
||||
|
||||
// Why: CLI-driven tab creation sends a request with a specific worktreeId and
|
||||
// url. The renderer creates the tab and replies with the workspace ID so the
|
||||
// main process can wait for registerGuest before returning to the CLI.
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabCreate((data) => {
|
||||
try {
|
||||
const store = useAppStore.getState()
|
||||
const worktreeId = data.worktreeId ?? store.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
window.api.ui.replyTabCreate({ requestId: data.requestId, error: 'No active worktree' })
|
||||
return
|
||||
}
|
||||
const workspace = store.createBrowserTab(worktreeId, data.url, { title: data.url })
|
||||
// Why: registerGuest fires with the page ID (not workspace ID) as
|
||||
// browserPageId. Return the page ID so waitForTabRegistration can
|
||||
// correlate correctly.
|
||||
const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? []
|
||||
const browserPageId = pages[0]?.id ?? workspace.id
|
||||
window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId })
|
||||
} catch (err) {
|
||||
window.api.ui.replyTabCreate({
|
||||
requestId: data.requestId,
|
||||
error: err instanceof Error ? err.message : 'Tab creation failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabClose((data) => {
|
||||
try {
|
||||
const store = useAppStore.getState()
|
||||
const explicitTargetId = data.tabId ?? null
|
||||
let tabToClose =
|
||||
explicitTargetId ??
|
||||
(data.worktreeId
|
||||
? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null)
|
||||
: store.activeBrowserTabId)
|
||||
if (!tabToClose) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: 'No active browser tab to close'
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: the bridge stores tabs keyed by browserPageId (which is the page
|
||||
// ID from registerGuest), but closeBrowserTab expects a workspace ID. If
|
||||
// tabToClose is a page ID, close only that page unless it is the
|
||||
// last page in its workspace. The CLI's `tab close --page` contract
|
||||
// targets one browser page, not the entire workspace tab.
|
||||
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
|
||||
.flat()
|
||||
.some((ws) => ws.id === tabToClose)
|
||||
if (!isWorkspaceId) {
|
||||
const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(
|
||||
([, pages]) => pages.some((p) => p.id === tabToClose)
|
||||
)
|
||||
if (owningWorkspace) {
|
||||
const [workspaceId, pages] = owningWorkspace
|
||||
if (pages.length <= 1) {
|
||||
store.closeBrowserTab(workspaceId)
|
||||
} else {
|
||||
store.closeBrowserPage(tabToClose)
|
||||
}
|
||||
window.api.ui.replyTabClose({ requestId: data.requestId })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (explicitTargetId) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: `Browser tab ${explicitTargetId} not found`
|
||||
})
|
||||
return
|
||||
}
|
||||
store.closeBrowserTab(tabToClose)
|
||||
window.api.ui.replyTabClose({ requestId: data.requestId })
|
||||
} catch (err) {
|
||||
window.api.ui.replyTabClose({
|
||||
requestId: data.requestId,
|
||||
error: err instanceof Error ? err.message : 'Tab close failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onNewTerminalTab(() => {
|
||||
const store = useAppStore.getState()
|
||||
|
|
|
|||
|
|
@ -245,6 +245,12 @@ describe('getAgentLabel', () => {
|
|||
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
|
||||
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
|
||||
})
|
||||
|
||||
it('labels GitHub Copilot CLI', () => {
|
||||
expect(getAgentLabel('copilot working')).toBe('GitHub Copilot')
|
||||
expect(getAgentLabel('copilot idle')).toBe('GitHub Copilot')
|
||||
expect(getAgentLabel('GitHub Copilot CLI')).toBe('GitHub Copilot')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAgentStatusTracker', () => {
|
||||
|
|
|
|||
65
src/renderer/src/lib/diff-comments-format.test.ts
Normal file
65
src/renderer/src/lib/diff-comments-format.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import type { DiffComment } from '../../../shared/types'
|
||||
import { formatDiffComment, formatDiffComments } from './diff-comments-format'
|
||||
|
||||
function makeComment(overrides: Partial<DiffComment> = {}): DiffComment {
|
||||
return {
|
||||
id: 'id-1',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: 'src/app.ts',
|
||||
lineNumber: 10,
|
||||
body: 'Needs validation',
|
||||
createdAt: 0,
|
||||
side: 'modified',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatDiffComment', () => {
|
||||
it('emits the fixed three-line structure', () => {
|
||||
const out = formatDiffComment(makeComment())
|
||||
expect(out).toBe(
|
||||
['File: src/app.ts', 'Line: 10', 'User comment: "Needs validation"'].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes embedded quotes in the body', () => {
|
||||
const out = formatDiffComment(makeComment({ body: 'why "this" path?' }))
|
||||
expect(out).toContain('User comment: "why \\"this\\" path?"')
|
||||
})
|
||||
|
||||
it('escapes backslashes before quotes so the body cannot break out of the literal', () => {
|
||||
const out = formatDiffComment(makeComment({ body: 'path\\to\\"thing"' }))
|
||||
expect(out).toContain('User comment: "path\\\\to\\\\\\"thing\\""')
|
||||
})
|
||||
|
||||
it('escapes newlines so the body cannot break out of the fixed 3-line structure', () => {
|
||||
const out = formatDiffComment(makeComment({ body: 'first\nsecond' }))
|
||||
expect(out).toContain('User comment: "first\\nsecond"')
|
||||
expect(out.split('\n')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDiffComments', () => {
|
||||
it('joins multiple comments with a blank line', () => {
|
||||
const out = formatDiffComments([
|
||||
makeComment({ id: 'a', lineNumber: 1, body: 'first' }),
|
||||
makeComment({ id: 'b', lineNumber: 2, body: 'second' })
|
||||
])
|
||||
expect(out).toBe(
|
||||
[
|
||||
'File: src/app.ts',
|
||||
'Line: 1',
|
||||
'User comment: "first"',
|
||||
'',
|
||||
'File: src/app.ts',
|
||||
'Line: 2',
|
||||
'User comment: "second"'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty input', () => {
|
||||
expect(formatDiffComments([])).toBe('')
|
||||
})
|
||||
})
|
||||
18
src/renderer/src/lib/diff-comments-format.ts
Normal file
18
src/renderer/src/lib/diff-comments-format.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { DiffComment } from '../../../shared/types'
|
||||
|
||||
// Why: the pasted format is the contract between this feature and whatever
|
||||
// agent consumes it. Keep it stable and deterministic — quote escaping matters
|
||||
// because the body is surfaced inside literal quotes. Escape backslashes
|
||||
// first so that `\"` in user input does not decay into an unescaped quote.
|
||||
export function formatDiffComment(c: DiffComment): string {
|
||||
const escaped = c.body
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
return [`File: ${c.filePath}`, `Line: ${c.lineNumber}`, `User comment: "${escaped}"`].join('\n')
|
||||
}
|
||||
|
||||
export function formatDiffComments(comments: DiffComment[]): string {
|
||||
return comments.map(formatDiffComment).join('\n\n')
|
||||
}
|
||||
|
|
@ -8,10 +8,14 @@ function extname(filePath: string): string {
|
|||
}
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
// Why: Monaco's built-in language registry maps .tsx/.cts/.mts onto the
|
||||
// 'typescript' language id and .jsx/.mjs/.cjs onto 'javascript' — there is
|
||||
// no separate 'typescriptreact'/'javascriptreact' id. Returning the base id
|
||||
// is what gives .tsx/.jsx files syntax highlighting in the editor.
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescriptreact',
|
||||
'.tsx': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascriptreact',
|
||||
'.jsx': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
'.cjs': 'javascript',
|
||||
'.json': 'json',
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ globalThis.MonacoEnvironment = {
|
|||
case 'razor':
|
||||
return new htmlWorker()
|
||||
case 'typescript':
|
||||
case 'typescriptreact':
|
||||
case 'javascript':
|
||||
case 'javascriptreact':
|
||||
return new tsWorker()
|
||||
default:
|
||||
return new editorWorker()
|
||||
|
|
@ -45,6 +43,21 @@ monacoTS.javascriptDefaults.setDiagnosticsOptions({
|
|||
diagnosticCodesToIgnore: [2307, 2792]
|
||||
})
|
||||
|
||||
// Why: .tsx/.jsx files share the base 'typescript'/'javascript' language ids
|
||||
// in Monaco's registry (there is no separate 'typescriptreact' id), so the
|
||||
// compiler options on those defaults apply to both. Without jsx enabled, the
|
||||
// worker raises TS17004 "Cannot use JSX unless the '--jsx' flag is provided"
|
||||
// on every JSX tag. Preserve mode is enough to allow parsing without forcing
|
||||
// an emit transform (we never emit — this is a read-only language service).
|
||||
monacoTS.typescriptDefaults.setCompilerOptions({
|
||||
...monacoTS.typescriptDefaults.getCompilerOptions(),
|
||||
jsx: monacoTS.JsxEmit.Preserve
|
||||
})
|
||||
monacoTS.javascriptDefaults.setCompilerOptions({
|
||||
...monacoTS.javascriptDefaults.getCompilerOptions(),
|
||||
jsx: monacoTS.JsxEmit.Preserve
|
||||
})
|
||||
|
||||
// Configure Monaco to use the locally bundled editor instead of CDN
|
||||
loader.config({ monaco })
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createCodexUsageSlice } from './slices/codex-usage'
|
|||
import { createBrowserSlice } from './slices/browser'
|
||||
import { createRateLimitSlice } from './slices/rate-limits'
|
||||
import { createSshSlice } from './slices/ssh'
|
||||
import { createDiffCommentsSlice } from './slices/diffComments'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
||||
export const useAppStore = create<AppState>()((...a) => ({
|
||||
|
|
@ -30,7 +31,8 @@ export const useAppStore = create<AppState>()((...a) => ({
|
|||
...createCodexUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a)
|
||||
...createSshSlice(...a),
|
||||
...createDiffCommentsSlice(...a)
|
||||
}))
|
||||
|
||||
export type { AppState } from './types'
|
||||
|
|
|
|||
|
|
@ -595,6 +595,17 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
}
|
||||
})
|
||||
|
||||
// Why: notify the CDP bridge which guest webContents is now active so
|
||||
// subsequent agent commands (snapshot, click, etc.) target the correct tab.
|
||||
// registerGuest uses page IDs (not workspace IDs), so we resolve the active
|
||||
// page within the workspace to find the correct browserPageId.
|
||||
const workspace = findWorkspace(get().browserTabsByWorktree, tabId)
|
||||
if (workspace?.activePageId && typeof window !== 'undefined' && window.api?.browser) {
|
||||
window.api.browser
|
||||
.notifyActiveTabChanged({ browserPageId: workspace.activePageId })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'browser' && entry.entityId === tabId)
|
||||
|
|
@ -796,6 +807,12 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
}
|
||||
})
|
||||
|
||||
// Why: switching the active page within a workspace changes which guest
|
||||
// webContents the CDP bridge should target for agent commands.
|
||||
if (typeof window !== 'undefined' && window.api?.browser) {
|
||||
window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {})
|
||||
}
|
||||
|
||||
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
|
|
|
|||
226
src/renderer/src/store/slices/diffComments.ts
Normal file
226
src/renderer/src/store/slices/diffComments.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { DiffComment, Worktree } from '../../../../shared/types'
|
||||
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
|
||||
|
||||
export type DiffCommentsSlice = {
|
||||
getDiffComments: (worktreeId: string | null | undefined) => DiffComment[]
|
||||
addDiffComment: (input: Omit<DiffComment, 'id' | 'createdAt'>) => Promise<DiffComment | null>
|
||||
deleteDiffComment: (worktreeId: string, commentId: string) => Promise<void>
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Why: return a stable reference when no comments exist so selectors don't
|
||||
// produce a fresh `[]` on every store update. A new array identity would
|
||||
// trigger re-renders in any consumer using referential equality.
|
||||
// Frozen + typed `readonly` so an accidental `list.push(...)` on the returned
|
||||
// value is both a runtime TypeError and a TypeScript compile error, preventing
|
||||
// the sentinel from being corrupted globally.
|
||||
const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([])
|
||||
|
||||
async function persist(worktreeId: string, diffComments: DiffComment[]): Promise<void> {
|
||||
await window.api.worktrees.updateMeta({
|
||||
worktreeId,
|
||||
updates: { diffComments }
|
||||
})
|
||||
}
|
||||
|
||||
// Why: IPC writes from `persist` are not ordered with respect to each other.
|
||||
// If two mutations (e.g. rapid add then delete, or two adds) are in flight
|
||||
// concurrently, their `updateMeta` resolutions can arrive out of call order,
|
||||
// letting an older snapshot overwrite a newer one on disk. We serialize per
|
||||
// worktree so only one write runs at a time. We also defer reading the
|
||||
// snapshot until the queued work actually starts — at dequeue time we pull
|
||||
// the LATEST `diffComments` from the store — which collapses a burst of N
|
||||
// mutations into at most 2 in-flight writes per worktree (1 running + 1
|
||||
// queued) and guarantees the last disk write reflects the newest state.
|
||||
const persistQueueByWorktree: Map<string, Promise<void>> = new Map()
|
||||
|
||||
// Why: chain each new write onto the prior promise for this worktree so
|
||||
// writes land in call order. We use `.then(..., ..)` with both handlers so a
|
||||
// failing previous write doesn't break the chain — we still proceed with the
|
||||
// next write. The queued work reads the latest list from the store via
|
||||
// `get()` at dequeue time (not via a captured parameter) so it writes the
|
||||
// most recent snapshot rather than a stale one from when it was enqueued.
|
||||
// The returned promise resolves/rejects when THIS specific write commits so
|
||||
// callers can preserve their optimistic-update + rollback flow.
|
||||
function enqueuePersist(worktreeId: string, get: () => AppState): Promise<void> {
|
||||
const prior = persistQueueByWorktree.get(worktreeId) ?? Promise.resolve()
|
||||
const run = async (): Promise<void> => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const repoList = get().worktreesByRepo[repoId]
|
||||
const target = repoList?.find((w) => w.id === worktreeId)
|
||||
const latest = target?.diffComments ?? []
|
||||
await persist(worktreeId, latest)
|
||||
}
|
||||
const next = prior.then(run, run)
|
||||
persistQueueByWorktree.set(worktreeId, next)
|
||||
// Why: once this write settles, clear the queue entry only if no later
|
||||
// write has been chained on top. Otherwise the map should keep pointing at
|
||||
// the latest tail so subsequent enqueues chain onto the real in-flight
|
||||
// tail, not a stale resolved promise. Use `then(cleanup, cleanup)` (not
|
||||
// `finally`) so a rejection on `next` is fully consumed by this branch —
|
||||
// otherwise the `.finally()` chain propagates the rejection as an
|
||||
// unhandledRejection even though the caller `await`s `next` in its own
|
||||
// try/catch.
|
||||
const cleanup = (): void => {
|
||||
if (persistQueueByWorktree.get(worktreeId) === next) {
|
||||
persistQueueByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
next.then(cleanup, cleanup)
|
||||
return next
|
||||
}
|
||||
|
||||
// Why: derive the next comment list from the latest store snapshot inside
|
||||
// the `set` updater so two concurrent writes (rapid add+delete, or a
|
||||
// delete-while-add-in-flight) can't clobber each other via a stale closure.
|
||||
function mutateComments(
|
||||
set: Parameters<StateCreator<AppState, [], [], DiffCommentsSlice>>[0],
|
||||
worktreeId: string,
|
||||
mutate: (existing: DiffComment[]) => DiffComment[] | null
|
||||
): { previous: DiffComment[] | undefined; next: DiffComment[] } | null {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
let previous: DiffComment[] | undefined
|
||||
let next: DiffComment[] | null = null
|
||||
set((s) => {
|
||||
const repoList = s.worktreesByRepo[repoId]
|
||||
if (!repoList) {
|
||||
return {}
|
||||
}
|
||||
const target = repoList.find((w) => w.id === worktreeId)
|
||||
if (!target) {
|
||||
return {}
|
||||
}
|
||||
previous = target.diffComments
|
||||
const computed = mutate(previous ?? [])
|
||||
if (computed === null) {
|
||||
return {}
|
||||
}
|
||||
next = computed
|
||||
const nextList: Worktree[] = repoList.map((w) =>
|
||||
w.id === worktreeId ? { ...w, diffComments: computed } : w
|
||||
)
|
||||
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
|
||||
})
|
||||
if (next === null) {
|
||||
return null
|
||||
}
|
||||
return { previous, next }
|
||||
}
|
||||
|
||||
// Why: if the IPC write fails, the optimistic renderer state drifts from
|
||||
// disk. Roll back so what the user sees always matches what will survive a
|
||||
// reload.
|
||||
//
|
||||
// Identity guard: we only revert when the current diffComments array is
|
||||
// strictly identical (===) to the `next` array this mutation produced. If
|
||||
// another mutation has already landed (e.g. Add B succeeded while Add A was
|
||||
// still in flight), it will have replaced the array with a different
|
||||
// identity. In that case we must leave the newer state alone — rolling back
|
||||
// to our stale `previous` would erase B along with the failed A.
|
||||
function rollback(
|
||||
set: Parameters<StateCreator<AppState, [], [], DiffCommentsSlice>>[0],
|
||||
worktreeId: string,
|
||||
previous: DiffComment[] | undefined,
|
||||
expectedCurrent: DiffComment[]
|
||||
): void {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
set((s) => {
|
||||
const repoList = s.worktreesByRepo[repoId]
|
||||
if (!repoList) {
|
||||
return {}
|
||||
}
|
||||
const target = repoList.find((w) => w.id === worktreeId)
|
||||
// Why: if the worktree was removed between the optimistic mutation and
|
||||
// this rollback, there is nothing to restore. Bail out before remapping
|
||||
// `repoList` so we don't allocate a new outer-array identity and trigger
|
||||
// spurious subscriber notifications.
|
||||
if (!target) {
|
||||
return {}
|
||||
}
|
||||
// Why: only roll back if no other mutation landed since this one. If a
|
||||
// later write already replaced the comments array with a different
|
||||
// identity, our stale `previous` would erase that newer state.
|
||||
if (target.diffComments !== expectedCurrent) {
|
||||
return {}
|
||||
}
|
||||
const nextList: Worktree[] = repoList.map((w) =>
|
||||
w.id === worktreeId ? { ...w, diffComments: previous } : w
|
||||
)
|
||||
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
|
||||
})
|
||||
}
|
||||
|
||||
export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffCommentsSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
getDiffComments: (worktreeId) => {
|
||||
// Why: accept null/undefined so callers with an optional active worktree
|
||||
// can pass it through without allocating a fresh `[]` fallback each
|
||||
// render, which would defeat the `EMPTY_COMMENTS` sentinel's referential
|
||||
// stability and trigger spurious re-renders in useAppStore selectors.
|
||||
if (!worktreeId) {
|
||||
return EMPTY_COMMENTS as DiffComment[]
|
||||
}
|
||||
const worktree = findWorktreeById(get().worktreesByRepo, worktreeId)
|
||||
if (!worktree?.diffComments) {
|
||||
// Why: cast the frozen sentinel to the mutable `DiffComment[]` return
|
||||
// type. The array is frozen at runtime so accidental mutation throws;
|
||||
// the cast only hides the `readonly` marker from consumers that never
|
||||
// mutate the list in practice.
|
||||
return EMPTY_COMMENTS as DiffComment[]
|
||||
}
|
||||
return worktree.diffComments
|
||||
},
|
||||
|
||||
addDiffComment: async (input) => {
|
||||
const comment: DiffComment = {
|
||||
...input,
|
||||
id: generateId(),
|
||||
createdAt: Date.now()
|
||||
}
|
||||
const result = mutateComments(set, input.worktreeId, (existing) => [...existing, comment])
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// Why: enqueue through the per-worktree queue so concurrent mutations
|
||||
// cannot land on disk out of call order. The queued write reads the
|
||||
// latest store snapshot at dequeue time, so it will reflect any newer
|
||||
// mutation that landed after this one was enqueued.
|
||||
await enqueuePersist(input.worktreeId, get)
|
||||
return comment
|
||||
} catch (err) {
|
||||
console.error('Failed to persist diff comments:', err)
|
||||
// Why: rollback's identity guard will no-op if a later mutation has
|
||||
// already replaced the in-memory list, so losing a successful newer
|
||||
// write is not possible here even though we queued in order.
|
||||
rollback(set, input.worktreeId, result.previous, result.next)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
deleteDiffComment: async (worktreeId, commentId) => {
|
||||
const result = mutateComments(set, worktreeId, (existing) => {
|
||||
const next = existing.filter((c) => c.id !== commentId)
|
||||
return next.length === existing.length ? null : next
|
||||
})
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: enqueue through the per-worktree queue so concurrent mutations
|
||||
// cannot land on disk out of call order. See enqueuePersist for the
|
||||
// ordering invariant.
|
||||
await enqueuePersist(worktreeId, get)
|
||||
} catch (err) {
|
||||
console.error('Failed to persist diff comments:', err)
|
||||
rollback(set, worktreeId, result.previous, result.next)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -98,6 +98,7 @@ import { createCodexUsageSlice } from './codex-usage'
|
|||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
import { createDiffCommentsSlice } from './diffComments'
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
|
|
@ -114,7 +115,8 @@ function createTestStore() {
|
|||
...createCodexUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a)
|
||||
...createSshSlice(...a),
|
||||
...createDiffCommentsSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { createCodexUsageSlice } from './codex-usage'
|
|||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
import { createDiffCommentsSlice } from './diffComments'
|
||||
|
||||
export const TEST_REPO = {
|
||||
id: 'repo1',
|
||||
|
|
@ -46,7 +47,8 @@ export function createTestStore() {
|
|||
...createCodexUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a)
|
||||
...createSshSlice(...a),
|
||||
...createDiffCommentsSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ import { createCodexUsageSlice } from './codex-usage'
|
|||
import { createBrowserSlice } from './browser'
|
||||
import { createRateLimitSlice } from './rate-limits'
|
||||
import { createSshSlice } from './ssh'
|
||||
import { createDiffCommentsSlice } from './diffComments'
|
||||
|
||||
const WT = 'repo1::/tmp/feature'
|
||||
|
||||
|
|
@ -111,7 +112,8 @@ function createTestStore() {
|
|||
...createCodexUsageSlice(...a),
|
||||
...createBrowserSlice(...a),
|
||||
...createRateLimitSlice(...a),
|
||||
...createSshSlice(...a)
|
||||
...createSshSlice(...a),
|
||||
...createDiffCommentsSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { CodexUsageSlice } from './slices/codex-usage'
|
|||
import type { BrowserSlice } from './slices/browser'
|
||||
import type { RateLimitSlice } from './slices/rate-limits'
|
||||
import type { SshSlice } from './slices/ssh'
|
||||
import type { DiffCommentsSlice } from './slices/diffComments'
|
||||
|
||||
export type AppState = RepoSlice &
|
||||
WorktreeSlice &
|
||||
|
|
@ -26,4 +27,5 @@ export type AppState = RepoSlice &
|
|||
CodexUsageSlice &
|
||||
BrowserSlice &
|
||||
RateLimitSlice &
|
||||
SshSlice
|
||||
SshSlice &
|
||||
DiffCommentsSlice
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
|
|||
const GEMINI_IDLE = '\u25C7' // ◇
|
||||
const GEMINI_PERMISSION = '\u270B' // ✋
|
||||
|
||||
export const AGENT_NAMES = ['claude', 'codex', 'gemini', 'opencode', 'aider']
|
||||
export const AGENT_NAMES = ['claude', 'codex', 'copilot', 'gemini', 'opencode', 'aider']
|
||||
const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format)
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
||||
|
|
@ -255,6 +255,9 @@ export function getAgentLabel(title: string): string | null {
|
|||
if (lower.includes('codex')) {
|
||||
return 'Codex'
|
||||
}
|
||||
if (lower.includes('copilot')) {
|
||||
return 'GitHub Copilot'
|
||||
}
|
||||
if (lower.includes('opencode')) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
terminalFontSize: 14,
|
||||
terminalFontFamily: defaultTerminalFontFamily(),
|
||||
terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT,
|
||||
terminalLineHeight: 1,
|
||||
terminalCursorStyle: 'bar',
|
||||
terminalCursorBlink: true,
|
||||
terminalThemeDark: 'Ghostty Default Style Dark',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */
|
||||
import type { TerminalPaneLayoutNode } from './types'
|
||||
import type { GitWorktreeInfo, Repo } from './types'
|
||||
|
||||
|
|
@ -152,3 +153,245 @@ export type RuntimeWorktreeListResult = {
|
|||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// ── Browser automation types ──
|
||||
|
||||
export type BrowserSnapshotRef = {
|
||||
ref: string
|
||||
role: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BrowserSnapshotResult = {
|
||||
browserPageId: string
|
||||
snapshot: string
|
||||
refs: BrowserSnapshotRef[]
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type BrowserClickResult = {
|
||||
clicked: string
|
||||
}
|
||||
|
||||
export type BrowserGotoResult = {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type BrowserFillResult = {
|
||||
filled: string
|
||||
}
|
||||
|
||||
export type BrowserTypeResult = {
|
||||
typed: boolean
|
||||
}
|
||||
|
||||
export type BrowserSelectResult = {
|
||||
selected: string
|
||||
}
|
||||
|
||||
export type BrowserScrollResult = {
|
||||
scrolled: 'up' | 'down'
|
||||
}
|
||||
|
||||
export type BrowserBackResult = {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type BrowserReloadResult = {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type BrowserScreenshotResult = {
|
||||
data: string
|
||||
format: 'png' | 'jpeg'
|
||||
}
|
||||
|
||||
export type BrowserEvalResult = {
|
||||
result: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
export type BrowserTabInfo = {
|
||||
browserPageId: string
|
||||
index: number
|
||||
url: string
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type BrowserTabListResult = {
|
||||
tabs: BrowserTabInfo[]
|
||||
}
|
||||
|
||||
export type BrowserTabSwitchResult = {
|
||||
switched: number
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
export type BrowserHoverResult = {
|
||||
hovered: string
|
||||
}
|
||||
|
||||
export type BrowserDragResult = {
|
||||
dragged: { from: string; to: string }
|
||||
}
|
||||
|
||||
export type BrowserUploadResult = {
|
||||
uploaded: number
|
||||
}
|
||||
|
||||
export type BrowserWaitResult = {
|
||||
waited: boolean
|
||||
}
|
||||
|
||||
export type BrowserCheckResult = {
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
export type BrowserFocusResult = {
|
||||
focused: string
|
||||
}
|
||||
|
||||
export type BrowserClearResult = {
|
||||
cleared: string
|
||||
}
|
||||
|
||||
export type BrowserSelectAllResult = {
|
||||
selected: string
|
||||
}
|
||||
|
||||
export type BrowserKeypressResult = {
|
||||
pressed: string
|
||||
}
|
||||
|
||||
export type BrowserPdfResult = {
|
||||
data: string
|
||||
}
|
||||
|
||||
// ── Cookie management types ──
|
||||
|
||||
export type BrowserCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
expires: number
|
||||
httpOnly: boolean
|
||||
secure: boolean
|
||||
sameSite: string
|
||||
}
|
||||
|
||||
export type BrowserCookieGetResult = {
|
||||
cookies: BrowserCookie[]
|
||||
}
|
||||
|
||||
export type BrowserCookieSetResult = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type BrowserCookieDeleteResult = {
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
// ── Viewport emulation types ──
|
||||
|
||||
export type BrowserViewportResult = {
|
||||
width: number
|
||||
height: number
|
||||
deviceScaleFactor: number
|
||||
mobile: boolean
|
||||
}
|
||||
|
||||
// ── Geolocation types ──
|
||||
|
||||
export type BrowserGeolocationResult = {
|
||||
latitude: number
|
||||
longitude: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
// ── Request interception types ──
|
||||
|
||||
export type BrowserInterceptedRequest = {
|
||||
id: string
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
resourceType: string
|
||||
}
|
||||
|
||||
export type BrowserInterceptEnableResult = {
|
||||
enabled: boolean
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
export type BrowserInterceptDisableResult = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
// ── Console/network capture types ──
|
||||
|
||||
export type BrowserConsoleEntry = {
|
||||
level: string
|
||||
text: string
|
||||
timestamp: number
|
||||
url?: string
|
||||
line?: number
|
||||
}
|
||||
|
||||
export type BrowserConsoleResult = {
|
||||
entries: BrowserConsoleEntry[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type BrowserNetworkEntry = {
|
||||
url: string
|
||||
method: string
|
||||
status: number
|
||||
mimeType: string
|
||||
size: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type BrowserNetworkLogResult = {
|
||||
entries: BrowserNetworkEntry[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type BrowserCaptureStartResult = {
|
||||
capturing: boolean
|
||||
}
|
||||
|
||||
export type BrowserCaptureStopResult = {
|
||||
stopped: boolean
|
||||
}
|
||||
|
||||
export type BrowserExecResult = {
|
||||
output: unknown
|
||||
}
|
||||
|
||||
export type BrowserTabCreateResult = {
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
export type BrowserTabCloseResult = {
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export type BrowserErrorCode =
|
||||
| 'browser_no_tab'
|
||||
| 'browser_tab_not_found'
|
||||
| 'browser_tab_closed'
|
||||
| 'browser_stale_ref'
|
||||
| 'browser_ref_not_found'
|
||||
| 'browser_navigation_failed'
|
||||
| 'browser_element_not_interactable'
|
||||
| 'browser_eval_error'
|
||||
| 'browser_cdp_error'
|
||||
| 'browser_debugger_detached'
|
||||
| 'browser_timeout'
|
||||
| 'browser_error'
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export type Worktree = {
|
|||
isPinned: boolean
|
||||
sortOrder: number
|
||||
lastActivityAt: number
|
||||
diffComments?: DiffComment[]
|
||||
} & GitWorktreeInfo
|
||||
|
||||
// ─── Worktree metadata (persisted user-authored fields only) ─────────
|
||||
|
|
@ -58,6 +59,23 @@ export type WorktreeMeta = {
|
|||
isPinned: boolean
|
||||
sortOrder: number
|
||||
lastActivityAt: number
|
||||
diffComments?: DiffComment[]
|
||||
}
|
||||
|
||||
// ─── Diff line comments ──────────────────────────────────────────────
|
||||
// Why: users leave review notes on specific lines of the modified side of
|
||||
// a diff so they can be handed back to an AI agent (pasted into a terminal
|
||||
// or used to bootstrap a new agent session). Stored on WorktreeMeta so the
|
||||
// existing persistence layer writes them to orca-data.json automatically.
|
||||
export type DiffComment = {
|
||||
id: string
|
||||
worktreeId: string
|
||||
filePath: string
|
||||
lineNumber: number
|
||||
body: string
|
||||
createdAt: number
|
||||
// Reserved for future "comments on the original side" — always 'modified' in v1.
|
||||
side: 'modified'
|
||||
}
|
||||
|
||||
// ─── Tab Group Layout ───────────────────────────────────────────────
|
||||
|
|
@ -575,6 +593,7 @@ export type GlobalSettings = {
|
|||
terminalFontSize: number
|
||||
terminalFontFamily: string
|
||||
terminalFontWeight: number
|
||||
terminalLineHeight: number
|
||||
terminalCursorStyle: 'bar' | 'block' | 'underline'
|
||||
terminalCursorBlink: boolean
|
||||
terminalThemeDark: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue