Compare commits

...

41 commits

Author SHA1 Message Date
github-actions[bot]
8c2554ab28 release: 1.3.8-rc.2 [rc-slot:2026-04-21-03] 2026-04-21 10:26:10 +00:00
Neil
d66d86645d
feat(settings): Cmd+Shift affordances for RC channel and hidden experimental (#887)
Cmd+Shift-click "Check for Updates" (menu or Settings > General) opts
into the RC release channel by switching the feed to the github provider
with allowPrerelease=true for the rest of the process.

Cmd+Shift-click the Experimental sidebar entry reveals a "Hidden
experimental" group with an orange-tinted header and a disabled
placeholder toggle — the slot for future unfinished/staff-only options.

Also reword the Experimental description to something less alarmist:
"New features that are still taking shape. Give them a try."
2026-04-21 00:07:02 -07:00
Drakontia
53f911ddc3
Support GitHub Copilot CLI agent detection (#866)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 23:44:37 -07:00
Jinwoo Hong
633204babd
fix(editor): apply dark scrollbar styling to rich markdown editor (#886) 2026-04-20 23:00:18 -07:00
Jinwoo Hong
55e935ff32
fix(browser): improve Cloudflare Turnstile compatibility (#885) 2026-04-20 22:36:30 -07:00
Jinjing
a8c3780bb9 1.3.8-rc.1 2026-04-20 21:59:55 -07:00
Brennan Benson
933d59f165
diff-comments: copy-only flow in Source Control (#884) 2026-04-20 21:50:31 -07:00
Kelvin Amoaba
bf21a028b9
feat: add terminal line height setting (#712)
Add a configurable line height multiplier (1-3) to terminal appearance
settings under Typography, persisted and applied to all terminal panes.
2026-04-20 21:23:43 -07:00
Jinwoo Hong
7a9bc4ef6d
feat: computer use via agent-browser CDP bridge (#856) 2026-04-20 20:56:14 -07:00
Jinwoo Hong
22ae50e0ae
fix(terminal): auto-respawn PTY daemon when it dies mid-session (#881) 2026-04-20 19:49:27 -07:00
Jinjing
3a58a829f9
feat(editor): export active markdown to PDF (#882)
Adds File > Export as PDF... menu item (Cmd+Shift+E) and an overflow
menu entry that renders the active markdown preview through a sandboxed
Electron BrowserWindow and writes it to disk via printToPDF.

- New main-side IPC handler (src/main/ipc/export.ts) and html-to-pdf
  helper that loads a CSP-locked HTML document in a sandboxed, context-
  isolated window with javascript enabled only for image-ready polling.
- Renderer helpers clone the rendered markdown subtree, inline all
  computed styles through a curated allowlist, and ship the resulting
  HTML fragment over IPC.
- Ref-counted listener registration so split-pane layouts install
  exactly one IPC subscription and survive panel churn.
2026-04-20 19:41:12 -07:00
Jinjing
8ea1f2ee33
docs(agents): tighten code-comment rule to short, why-only (#883) 2026-04-20 19:39:25 -07:00
Neil
3bbe9ed712
fix(editor): restore syntax highlighting for .tsx and .jsx files (#878) 2026-04-20 19:22:31 -07:00
Jinjing
ad9deee55b 1.3.8-rc.0 2026-04-20 19:08:31 -07:00
Jinjing
b3f99b5ae1
fix: preserve selection during external markdown edits and sidebar interactions (#880)
* fix: preserve selection during external markdown edits and prevent blocked worktree switching

* fix: correct TypeScript types for usePreserveSectionDuringExternalEdit and add missing dependency
2026-04-20 18:54:52 -07:00
Jinwoo Hong
ff16ab1565
feat(stats): add share button to usage panes (#874) 2026-04-20 18:54:16 -07:00
Jinwoo Hong
25971cd32d
fix(terminal): preserve scroll position during split pane drag resize (#865) 2026-04-20 18:53:20 -07:00
Jinjing
3a0fa521be
feat(editor): support clicking anchor links to jump to headings (#879)
Anchor-only links (#heading) now scroll to the matching heading in both
the markdown preview and rich editor. Preview uses rehype-slug to stamp
heading ids; the rich editor walks headings with the same stateful
GithubSlugger for parity (including duplicate-heading suffixes).
2026-04-20 18:35:28 -07:00
Brennan Benson
b3361569b4
fix(tabs): raise selected tab contrast so it stands out from hover (#877) 2026-04-20 17:30:07 -07:00
Jinjing
889dbb23cb
fix: Cmd+F search in rich markdown editor now scrolls to active match (#876)
Merge decoration update and selection into a single ProseMirror
transaction so scrollIntoView is not lost. After dispatch, manually
scroll the outer flex container using coordsAtPos since
tr.scrollIntoView cannot reach the non-overflowing editor wrapper.

Also change active match highlight from orange to blue for better
contrast.
2026-04-20 17:25:04 -07:00
Brennan Benson
288322cac4
feat(sidebar): rename Shutdown to Sleep with explanatory tooltip (#875) 2026-04-20 17:21:06 -07:00
Jinjing
a02fce9106 1.3.7 2026-04-20 17:20:44 -07:00
Brennan Benson
202f3d638f
test(e2e): stabilize terminal-shortcuts Cmd+W and Escape steps (#873) 2026-04-20 15:36:35 -07:00
github-actions[bot]
29a73353ce release: 1.3.7-rc.2 [rc-slot:2026-04-20-15] 2026-04-20 22:11:54 +00:00
Brennan Benson
57b0617e37
perf(terminal): remove 3-minute periodic scrollback save (#869) 2026-04-20 14:56:06 -07:00
Jinjing
249af27389 1.3.7-rc.1 2026-04-20 13:33:23 -07:00
Jinjing
6b3617586a
fix(editor): eliminate ~10s freeze when loading large markdown files (#870)
The rich-mode unsupported-content check ran canRoundTripRichMarkdown()
unconditionally — synchronously creating a throwaway TipTap Editor,
parsing the entire document, and serializing it back — all on the main
thread during React render. For a 120KB file this blocked for ~10s.

Redesign: run cheap regex checks first (reference links, footnotes,
HTML). If no unsupported syntax is detected, allow rich mode immediately
without any round-trip. The expensive round-trip is now only invoked
when HTML is detected and the file is under 50K chars.
2026-04-20 13:30:01 -07:00
Jinjing
a4bfeef8a3
fix: support Cmd+N and Esc on the tasks page (#868)
Move the Cmd+N handler above the new-workspace early-return so the
shortcut opens the composer modal from the tasks page. Add an
activeModal guard to the page-level Esc handler so it yields to the
composer modal's own Esc dismissal.
2026-04-20 12:49:45 -07:00
Jinjing
781bc9fd62
fix(quick-open): exclude sibling worktree files from Cmd+P results (#867)
When the active worktree is the repo root, linked worktrees are nested
subdirectories. rg --files listed files from every worktree instead of
just the active one. Pass sibling worktree paths as --glob exclusions.

Also wrap scroll-to-top in rAF so it runs after cmdk's scroll-into-view.
2026-04-20 12:27:08 -07:00
github-actions[bot]
f422174d1b release: 1.3.7-rc.0 [rc-slot:2026-04-20-03] 2026-04-20 10:30:07 +00:00
Neil
c6f6300bcf
feat(terminal): add "Copy on Select" clipboard setting (default off) (#862)
Adds an opt-in terminal setting that automatically copies the current
selection to the system clipboard as the user selects, mirroring X11 /
gnome-terminal behavior. xterm.js has no native option for this, so the
renderer hooks `onSelectionChange` per pane and writes via the existing
clipboard IPC. Defaults to false so existing users keep the explicit
Cmd/Ctrl+Shift+C copy flow.

Closes #860
2026-04-20 00:28:29 -07:00
Jinjing
c47b651f2d 1.3.6 2026-04-19 23:31:51 -07:00
Jinjing
3d00734f6e 1.3.6-rc.1 2026-04-19 22:10:23 -07:00
Jinjing
db96479a5d
fix(editor): prevent cursor jump to end when typing during autosave in markdown editor (#855)
Autosave writes to disk echo back as fs:changed events that were treated as
external edits, triggering a setContent reload mid-typing that reset the TipTap
selection to the document end (and could drop unsaved keystrokes). Stamp each
self-write in a registry so useEditorExternalWatch ignores its own echo, and
preserve selection when genuine external edits do arrive.
2026-04-19 22:05:52 -07:00
Jinwoo Hong
8e88fdae33
fix: preserve terminal scroll position when splitting panes (#817) 2026-04-19 20:35:37 -07:00
Brennan Benson
0ef3a65635
fix(terminal): Cmd+Left/Right jump to start/end of line (#851) 2026-04-19 20:24:17 -07:00
Jinwoo Hong
ac39899e90
fix(terminal): clear screen before daemon restore to prevent duplicated output (#853) 2026-04-19 19:56:30 -07:00
Jinwoo Hong
8dcb234d22
feat(browser): multi-profile session management with per-tab switching (#823) 2026-04-19 19:48:26 -07:00
Jinwoo Hong
096c1818f3
fix: improve assertManagedHomePath error for cross-environment accounts (#848) 2026-04-19 17:32:04 -07:00
Brennan Benson
6893923c73
feat: double-click tab to rename (inline edit) (#844)
Co-authored-by: heyramzi <ramzi@upsys-consulting.com>
2026-04-19 17:22:30 -07:00
Brennan Benson
08b0a3058a
test(e2e): cover worktree lifecycle and tab-close navigation (#846) 2026-04-19 15:50:19 -07:00
147 changed files with 21101 additions and 806 deletions

13
.gitignore vendored
View file

@ -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/

View file

@ -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.

View file

@ -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'],

View file

@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.6-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,12 +73,15 @@
"@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",
"dompurify": "^3.4.0",
"electron-updater": "^6.8.3",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.2",
"html-to-image": "^1.11.13",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"mermaid": "^11.14.0",
@ -87,6 +90,7 @@
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
@ -96,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"
},
@ -108,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",

View file

@ -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
@ -121,9 +124,15 @@ importers:
electron-updater:
specifier: ^6.8.3
version: 6.8.3
github-slugger:
specifier: ^2.0.0
version: 2.0.0
hosted-git-info:
specifier: ^9.0.2
version: 9.0.2
html-to-image:
specifier: ^1.11.13
version: 1.11.13
lowlight:
specifier: ^3.3.0
version: 3.3.0
@ -148,6 +157,9 @@ importers:
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
rehype-slug:
specifier: ^6.0.0
version: 6.0.0
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
@ -175,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
@ -190,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@3.25.76)
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))
@ -206,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
@ -2196,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
@ -2206,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
@ -2742,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==}
@ -2871,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:
@ -3946,6 +3971,9 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -4004,12 +4032,18 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@ -4035,6 +4069,9 @@ packages:
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
engines: {node: ^20.17.0 || >=22.9.0}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@ -5268,6 +5305,9 @@ packages:
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
@ -6023,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'}
@ -7873,27 +7925,27 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@stablyai/playwright-base@2.1.13(@playwright/test@1.59.1)(zod@3.25.76)':
'@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
p-retry: 4.6.2
pngjs: 7.0.0
optionalDependencies:
zod: 3.25.76
zod: 4.3.6
'@stablyai/playwright-test@2.1.13(@playwright/test@1.59.1)(zod@3.25.76)':
'@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@3.25.76)
'@stablyai/playwright-base': 2.1.13(@playwright/test@1.59.1)(zod@3.25.76)
'@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@3.25.76)':
'@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@3.25.76)
'@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6)
transitivePeerDependencies:
- zod
@ -8458,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
@ -8584,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
@ -9843,6 +9901,8 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
github-slugger@2.0.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -9920,6 +9980,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
@ -9944,6 +10008,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
@ -9969,6 +10037,8 @@ snapshots:
dependencies:
lru-cache: 11.2.7
html-to-image@1.11.13: {}
html-url-attributes@3.0.1: {}
http-cache-semantics@4.2.0: {}
@ -11513,6 +11583,14 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.1.0
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4
@ -12330,6 +12408,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.20.0: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1

View file

@ -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.

View file

@ -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
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -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')
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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']
});
}
})()`

View file

@ -46,6 +46,7 @@ import type {
BrowserSessionProfileSource
} from '../../shared/types'
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
// ---------------------------------------------------------------------------
// Browser detection
@ -1323,6 +1324,20 @@ export async function importCookiesFromBrowser(
const partitionName = targetPartition.replace('persist:', '')
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
// Why: Electron only creates the partition's Cookies SQLite file after the
// session has actually stored a cookie. For newly created profiles that have
// never been used by a webview, the file won't exist yet. Setting and
// removing a throwaway cookie forces Electron to initialize the database.
if (!existsSync(liveCookiesPath)) {
try {
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
await targetSession.cookies.remove('https://localhost', '__init')
await targetSession.cookies.flushStore()
} catch {
// ignore — the set/remove may fail but flushStore should still create the file
}
}
if (!existsSync(liveCookiesPath)) {
rmSync(tmpDir, { recursive: true, force: true })
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
@ -1564,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)}...`)
}

View file

@ -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 = {

View file

@ -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

View file

@ -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', () => {
@ -50,27 +51,36 @@ describe('BrowserSessionRegistry', () => {
it('creates an isolated profile with a unique partition', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Test Isolated')
expect(profile.scope).toBe('isolated')
expect(profile.partition).toMatch(/^persist:orca-browser-session-/)
expect(profile.partition).not.toBe(ORCA_BROWSER_PARTITION)
expect(profile.label).toBe('Test Isolated')
expect(profile.source).toBeNull()
expect(profile).not.toBeNull()
expect(profile!.scope).toBe('isolated')
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
expect(profile!.partition).not.toBe(ORCA_BROWSER_PARTITION)
expect(profile!.label).toBe('Test Isolated')
expect(profile!.source).toBeNull()
})
it('rejects creating a profile with scope default', () => {
const profile = browserSessionRegistry.createProfile('default', 'Sneaky')
expect(profile).toBeNull()
})
it('allows created profile partitions', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Allowed')
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true)
expect(profile).not.toBeNull()
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
})
it('creates an imported profile', () => {
const profile = browserSessionRegistry.createProfile('imported', 'My Import')
expect(profile.scope).toBe('imported')
expect(profile.partition).toMatch(/^persist:orca-browser-session-/)
expect(profile).not.toBeNull()
expect(profile!.scope).toBe('imported')
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
})
it('resolves partition for a known profile', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Resolve Test')
expect(browserSessionRegistry.resolvePartition(profile.id)).toBe(profile.partition)
expect(profile).not.toBeNull()
expect(browserSessionRegistry.resolvePartition(profile!.id)).toBe(profile!.partition)
})
it('resolves default partition for null/undefined profileId', () => {
@ -91,7 +101,8 @@ describe('BrowserSessionRegistry', () => {
it('updates profile source', () => {
const profile = browserSessionRegistry.createProfile('imported', 'Source Test')
const updated = browserSessionRegistry.updateProfileSource(profile.id, {
expect(profile).not.toBeNull()
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
browserFamily: 'edge',
importedAt: Date.now()
})
@ -101,11 +112,12 @@ describe('BrowserSessionRegistry', () => {
it('deletes a non-default profile', async () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test')
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true)
const deleted = await browserSessionRegistry.deleteProfile(profile.id)
expect(profile).not.toBeNull()
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
const deleted = await browserSessionRegistry.deleteProfile(profile!.id)
expect(deleted).toBe(true)
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(false)
expect(browserSessionRegistry.getProfile(profile.id)).toBeNull()
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false)
expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull()
})
it('refuses to delete the default profile', async () => {
@ -116,14 +128,14 @@ describe('BrowserSessionRegistry', () => {
it('hydrates profiles from persisted data', () => {
const fakeProfile = {
id: 'hydrate-test-id',
id: '00000000-0000-0000-0000-000000000001',
scope: 'imported' as const,
partition: 'persist:orca-browser-session-hydrate-test-id',
partition: 'persist:orca-browser-session-00000000-0000-0000-0000-000000000001',
label: 'Hydrated',
source: { browserFamily: 'manual' as const, importedAt: 1000 }
}
browserSessionRegistry.hydrateFromPersisted([fakeProfile])
expect(browserSessionRegistry.getProfile('hydrate-test-id')).not.toBeNull()
expect(browserSessionRegistry.getProfile('00000000-0000-0000-0000-000000000001')).not.toBeNull()
expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true)
})
@ -142,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://*/*'] },
@ -167,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]
@ -181,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()
})
@ -192,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]

View file

@ -1,15 +1,28 @@
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, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
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']
userAgent: string | null
pendingCookieDbPath: string | null
profiles: BrowserSessionProfile[]
}
// Why: the registry is the single source of truth for which Electron partitions
@ -43,10 +56,14 @@ class BrowserSessionRegistry {
return this.loadPersistedMeta().defaultSource
}
// Why: write-to-temp-then-rename is atomic on all supported platforms.
// A crash mid-write would only lose the temp file, not corrupt the live one.
private persistMeta(updates: Partial<BrowserSessionMeta>): void {
try {
const existing = this.loadPersistedMeta()
writeFileSync(this.metadataPath, JSON.stringify({ ...existing, ...updates }))
const tmpPath = `${this.metadataPath}.tmp`
writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates }))
renameSync(tmpPath, this.metadataPath)
} catch {
// best-effort
}
@ -59,6 +76,13 @@ class BrowserSessionRegistry {
})
}
// Why: non-default profiles are in-memory only unless explicitly persisted.
// Without this, created profiles vanish on app restart.
private persistProfiles(): void {
const nonDefault = [...this.profiles.values()].filter((p) => p.id !== 'default')
this.persistMeta({ profiles: nonDefault })
}
private loadPersistedMeta(): BrowserSessionMeta {
try {
const raw = readFileSync(this.metadataPath, 'utf-8')
@ -66,10 +90,11 @@ class BrowserSessionRegistry {
return {
defaultSource: data?.defaultSource ?? null,
userAgent: data?.userAgent ?? null,
pendingCookieDbPath: data?.pendingCookieDbPath ?? null
pendingCookieDbPath: data?.pendingCookieDbPath ?? null,
profiles: Array.isArray(data?.profiles) ? data.profiles : []
}
} catch {
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null }
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] }
}
}
@ -87,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')
@ -95,46 +131,9 @@ class BrowserSessionRegistry {
this.profiles.set('default', { ...current, source: meta.defaultSource })
}
}
}
// 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
if (meta.profiles.length > 0) {
this.hydrateFromPersisted(meta.profiles)
}
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
@ -222,13 +221,19 @@ class BrowserSessionRegistry {
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
}
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile {
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
// ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage
// for the shared partition).
if (scope === 'default') {
return null
}
const id = randomUUID()
// Why: partition names are deterministic from the profile id so main can
// reconstruct the allowlist on restart from persisted profile metadata
// without needing a separate partition→profile mapping.
const partition =
scope === 'default' ? ORCA_BROWSER_PARTITION : `persist:orca-browser-session-${id}`
const partition = `persist:orca-browser-session-${id}`
const profile: BrowserSessionProfile = {
id,
scope,
@ -237,9 +242,8 @@ class BrowserSessionRegistry {
source: null
}
this.profiles.set(id, profile)
if (partition !== ORCA_BROWSER_PARTITION) {
this.setupSessionPolicies(partition)
}
this.setupSessionPolicies(partition)
this.persistProfiles()
return profile
}
@ -255,6 +259,8 @@ class BrowserSessionRegistry {
this.profiles.set(profileId, updated)
if (profileId === 'default') {
this.persistSource(source)
} else {
this.persistProfiles()
}
return updated
}
@ -265,6 +271,7 @@ class BrowserSessionRegistry {
return false
}
this.profiles.delete(profileId)
this.persistProfiles()
// Why: clearing the partition's storage prevents orphaned cookies/cache from
// lingering after the user deletes an imported or isolated session profile.
@ -303,9 +310,23 @@ class BrowserSessionRegistry {
// Why: on startup, main must reconstruct the set of valid partitions from
// persisted session profiles so restored webviews are not denied by
// will-attach-webview before the renderer mounts them.
// Why: profiles are deserialized from a JSON file on disk. A corrupted or
// tampered file could inject an arbitrary partition into the allowlist that
// will-attach-webview trusts, so we validate the expected shape before
// registering anything.
private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/
hydrateFromPersisted(profiles: BrowserSessionProfile[]): void {
for (const profile of profiles) {
if (profile.id === 'default') {
if (profile.id === 'default' || profile.scope === 'default') {
continue
}
if (
typeof profile.id !== 'string' ||
typeof profile.partition !== 'string' ||
typeof profile.label !== 'string' ||
!BrowserSessionRegistry.PARTITION_RE.test(profile.partition)
) {
continue
}
this.profiles.set(profile.id, profile)
@ -328,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,
@ -340,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 })

View 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 })
})
}

View 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()
})
})

File diff suppressed because it is too large Load diff

View 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 }
})
})
})

View 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)
}
})
}

View 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()
})
})

View 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)
)
}
}

View 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"')
})
})

View 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
}

View file

@ -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',
@ -55,6 +56,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View file

@ -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',
@ -49,6 +50,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View file

@ -321,6 +321,20 @@ export class CodexAccountService {
const resolvedCandidate = resolve(candidatePath)
const resolvedRoot = resolve(rootPath)
// Why: in dev mode, userData points to orca-dev/ while production uses
// orca/. Accounts created by the packaged app store production paths in
// settings. A quick prefix check before realpathSync avoids noisy errors
// when dev instances encounter production-rooted managed home paths.
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
throw new Error(
`Managed Codex home is outside current storage root (expected under ${resolvedRoot}).`
)
}
if (!existsSync(resolvedCandidate)) {
throw new Error('Managed Codex home directory does not exist on disk.')
}
// realpath() requires the leaf to exist. For pre-login add flow we create
// the home directory first so the containment check still verifies the
// canonical on-disk target rather than trusting persisted text blindly.

View file

@ -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

View file

@ -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

View file

@ -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()
})
})
})

View file

@ -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'
}

View file

@ -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

View file

@ -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.

View file

@ -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')
})
})

View file

@ -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
View 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'
}
}
}
)
}

View file

@ -45,16 +45,36 @@ function shouldIncludeQuickOpenPath(path: string): boolean {
return true
}
export async function listQuickOpenFiles(rootPath: string, store: Store): Promise<string[]> {
export async function listQuickOpenFiles(
rootPath: string,
store: Store,
excludePaths?: string[]
): Promise<string[]> {
const authorizedRootPath = await resolveAuthorizedPath(rootPath, store)
// Why: when the main worktree sits at the repo root, linked worktrees are
// nested subdirectories. Without excluding them, rg/git lists files from
// every worktree instead of just the active one.
const excludeGlobs: string[] = []
if (excludePaths?.length) {
const normalizedRoot = `${authorizedRootPath.replace(/[\\/]+$/, '')}/`
for (const abs of excludePaths) {
const rel = abs.startsWith(normalizedRoot)
? abs.slice(normalizedRoot.length)
: relative(authorizedRootPath, abs).replace(/\\/g, '/')
if (rel && !rel.startsWith('..') && !rel.startsWith('/')) {
excludeGlobs.push(rel)
}
}
}
// Why: checking rg availability upfront avoids a race condition where
// spawn('rg') emits 'close' before 'error' on some platforms, causing
// the handler to resolve with empty results before the git fallback
// can run. The result is cached after the first check.
const rgAvailable = await checkRgAvailable(authorizedRootPath)
if (!rgAvailable) {
return listFilesWithGit(authorizedRootPath)
return listFilesWithGit(authorizedRootPath, excludeGlobs)
}
// Why: We try fast string slicing first (O(1) per file), but fall back to
@ -164,6 +184,7 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
// Why: On Windows, rg outputs '\'-separated paths. Forcing '/' via
// --path-separator avoids per-line backslash replacement in processLine.
const rgSepArgs = sep === '\\' ? ['--path-separator', '/'] : []
const rgExcludeArgs = excludeGlobs.flatMap((g) => ['--glob', `!${g}/**`])
await Promise.all([
runRg([
@ -174,6 +195,7 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
'!**/node_modules',
'--glob',
'!**/.git',
...rgExcludeArgs,
authorizedRootPath
]),
runRg([
@ -187,6 +209,7 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
'!**/node_modules',
'--glob',
'!**/.git',
...rgExcludeArgs,
authorizedRootPath
])
])
@ -202,8 +225,9 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
* surfaces .env* files that are typically gitignored but users frequently need in
* quick-open (mirrors the second rg call with --no-ignore-vcs).
*/
function listFilesWithGit(rootPath: string): Promise<string[]> {
function listFilesWithGit(rootPath: string, excludeGlobs: string[] = []): Promise<string[]> {
const files = new Set<string>()
const excludePrefixes = excludeGlobs.map((g) => `${g.replace(/\\/g, '/')}/`)
const runGitLsFiles = (args: string[]): Promise<void> => {
return new Promise((resolve) => {
@ -225,6 +249,9 @@ function listFilesWithGit(rootPath: string): Promise<string[]> {
if (!line) {
return
}
if (excludePrefixes.some((p) => line.startsWith(p))) {
return
}
if (shouldIncludeQuickOpenPath(line)) {
files.add(line)
}

View file

@ -425,7 +425,10 @@ export function registerFilesystemHandlers(store: Store): void {
// ─── List all files (for quick-open) ─────────────────────
ipcMain.handle(
'fs:listFiles',
async (_event, args: { rootPath: string; connectionId?: string }): Promise<string[]> => {
async (
_event,
args: { rootPath: string; connectionId?: string; excludePaths?: string[] }
): Promise<string[]> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
if (!provider) {
@ -433,7 +436,7 @@ export function registerFilesystemHandlers(store: Store): void {
}
return provider.listFiles(args.rootPath)
}
return listQuickOpenFiles(args.rootPath, store)
return listQuickOpenFiles(args.rootPath, store, args.excludePaths)
}
)

View file

@ -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' }

View file

@ -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)

View file

@ -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
}
}

View 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.
}
}
}

View file

@ -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())

View file

@ -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: [

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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())

View file

@ -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>
@ -489,7 +510,11 @@ export type PreloadApi = {
filePath: string
connectionId?: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
listFiles: (args: { rootPath: string; connectionId?: string }) => Promise<string[]>
listFiles: (args: {
rootPath: string
connectionId?: string
excludePaths?: string[]
}) => Promise<string[]>
search: (args: SearchOptions & { connectionId?: string }) => Promise<SearchResult>
importExternalPaths: (args: { sourcePaths: string[]; destDir: string }) => Promise<{
results: (
@ -590,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
@ -598,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

View file

@ -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

View file

@ -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> => {
@ -896,8 +928,11 @@ const api = {
connectionId?: string
}): Promise<{ size: number; isDirectory: boolean; mtime: number }> =>
ipcRenderer.invoke('fs:stat', args),
listFiles: (args: { rootPath: string; connectionId?: string }): Promise<string[]> =>
ipcRenderer.invoke('fs:listFiles', args),
listFiles: (args: {
rootPath: string
connectionId?: string
excludePaths?: string[]
}): Promise<string[]> => ipcRenderer.invoke('fs:listFiles', args),
search: (args: {
query: string
rootPath: string
@ -1047,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)
@ -1087,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

View file

@ -295,28 +295,17 @@ function App(): React.JSX.Element {
return () => window.removeEventListener('beforeunload', captureAndFlush)
}, [])
// Periodically capture terminal scrollback buffers and persist to disk.
// Why: the shutdown path captures buffers in beforeunload, but periodic
// saves provide a safety net so scrollback is available on restart even
// if an unexpected exit (crash, force-kill) bypasses normal shutdown.
useEffect(() => {
const PERIODIC_SAVE_INTERVAL_MS = 3 * 60_000
const timer = window.setInterval(() => {
if (!useAppStore.getState().workspaceSessionReady || shutdownBufferCaptures.size === 0) {
return
}
for (const capture of shutdownBufferCaptures) {
try {
capture()
} catch {
// Don't let one pane's failure block the rest.
}
}
const state = useAppStore.getState()
void window.api.session.set(buildWorkspaceSessionPayload(state))
}, PERIODIC_SAVE_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [])
// Why there is no periodic scrollback save: PR #461 added a 3-minute
// setInterval that re-serialized every mounted TerminalPane's scrollback
// so a crash wouldn't lose in-session output. With many panes of
// accumulated output, each tick blocked the renderer main thread for
// several seconds (serialize is synchronous and does a binary search on
// >512KB buffers), causing visible input lag across the whole app.
// The durable replacement is the out-of-process terminal daemon
// (PR #729), which preserves buffers across renderer crashes with no
// main-thread work. Non-daemon users lose in-session scrollback on an
// unexpected exit — an acceptable tradeoff vs. periodic UI stalls, and
// in line with how most terminal apps behave.
useEffect(() => {
if (!persistedUIReady) {
@ -516,6 +505,17 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+N — new workspace (opens the lightweight composer modal)
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (!repos.some((repo) => isGitRepoKind(repo))) {
return
}
dispatchClearModifierHints()
e.preventDefault()
actions.openModal('new-workspace-composer')
return
}
// Why: the new-workspace composer should not be able to reveal the right
// sidebar at all, because that surface is intentionally distraction-free.
if (activeView === 'new-workspace') {
@ -530,17 +530,6 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+N — new workspace (opens the lightweight composer modal)
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (!repos.some((repo) => isGitRepoKind(repo))) {
return
}
dispatchClearModifierHints()
e.preventDefault()
actions.openModal('new-workspace-composer')
return
}
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
dispatchClearModifierHints()

View file

@ -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;
}

View file

@ -121,7 +121,7 @@
}
.rich-markdown-search-match[data-active='true'] {
background: color-mix(in srgb, #fb923c 60%, transparent);
background: color-mix(in srgb, #38bdf8 50%, transparent);
}
.rich-markdown-editor {

View file

@ -142,6 +142,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const pageData = useAppStore((s) => s.newWorkspacePageData)
const closeNewWorkspacePage = useAppStore((s) => s.closeNewWorkspacePage)
const activeModal = useAppStore((s) => s.activeModal)
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const openModal = useAppStore((s) => s.openModal)
@ -443,11 +444,8 @@ export default function NewWorkspacePage(): React.JSX.Element {
}, [newIssueBody, newIssueSubmitting, newIssueTitle, selectedRepo])
useEffect(() => {
// Why: when the GitHub preview sheet is open, Radix's Dialog owns Esc —
// it closes the sheet on its own. Page-level capture would otherwise fire
// first and pop the tasks page while the user just meant to dismiss the
// preview.
if (drawerWorkItem || newIssueOpen) {
// Why: when a modal is open, let it own Esc dismissal.
if (drawerWorkItem || newIssueOpen || activeModal !== 'none') {
return
}
@ -481,7 +479,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [closeNewWorkspacePage, drawerWorkItem, newIssueOpen])
}, [activeModal, closeNewWorkspacePage, drawerWorkItem, newIssueOpen])
return (
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background text-foreground">

View file

@ -66,18 +66,24 @@ export default function QuickOpen(): React.JSX.Element | null {
const [loadError, setLoadError] = useState<string | null>(null)
const listRef = useRef<HTMLDivElement>(null)
// Find active worktree path
const worktreePath = useMemo(() => {
// Find active worktree path and sibling worktree paths to exclude
const { worktreePath, excludePaths } = useMemo(() => {
if (!activeWorktreeId) {
return null
return { worktreePath: null, excludePaths: [] as string[] }
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
// Why: when the active worktree is the repo root (isMainWorktree),
// linked worktrees are nested subdirectories. Without excluding them,
// file listing returns files from every worktree, not just this one.
const siblings = worktrees
.filter((w) => w.id !== activeWorktreeId && w.path.startsWith(`${wt.path}/`))
.map((w) => w.path)
return { worktreePath: wt.path, excludePaths: siblings }
}
}
return null
return { worktreePath: null, excludePaths: [] as string[] }
}, [activeWorktreeId, worktreesByRepo])
const connectionId = useMemo(
@ -106,7 +112,11 @@ export default function QuickOpen(): React.JSX.Element | null {
// Why: quick-open shares the active worktree path model with file explorer
// and search, so remote worktrees must include connectionId. Without this,
// Windows resolves Linux roots (e.g. /home/*) as local C:\home\* paths.
.listFiles({ rootPath: worktreePath, connectionId })
.listFiles({
rootPath: worktreePath,
connectionId,
excludePaths: excludePaths.length > 0 ? excludePaths : undefined
})
.then((result) => {
if (!cancelled) {
setFiles(result)
@ -129,7 +139,7 @@ export default function QuickOpen(): React.JSX.Element | null {
return () => {
cancelled = true
}
}, [visible, worktreePath, connectionId])
}, [visible, worktreePath, connectionId, excludePaths])
// Filter files by fuzzy match
const filtered = useMemo(() => {
@ -151,8 +161,12 @@ export default function QuickOpen(): React.JSX.Element | null {
// Why: when the query changes the first result becomes selected, but cmdk
// doesn't reset the list's scrollTop. Without this, a previously scrolled
// list leaves the new top result clipped behind the input border.
// rAF defers until after cmdk's own scroll-into-view pass, so our reset wins.
useEffect(() => {
listRef.current?.scrollTo(0, 0)
const id = requestAnimationFrame(() => {
listRef.current?.scrollTo(0, 0)
})
return () => cancelAnimationFrame(id)
}, [query, visible])
const handleSelect = useCallback(

View file

@ -11,11 +11,9 @@ import {
ExternalLink,
Globe,
Image,
Import,
Loader2,
OctagonX,
RefreshCw,
Settings,
SquareCode
} from 'lucide-react'
import { Button } from '@/components/ui/button'
@ -57,6 +55,7 @@ import { useGrabMode } from './useGrabMode'
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
import { isEditableKeyboardTarget } from './browser-keyboard'
import BrowserAddressBar from './BrowserAddressBar'
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
import BrowserFind from './BrowserFind'
import {
consumeBrowserFocusRequest,
@ -1000,6 +999,7 @@ function BrowserPagePane({
void window.api.browser.registerGuest({
browserPageId: browserTab.id,
workspaceId,
worktreeId,
webContentsId
})
}
@ -1207,12 +1207,16 @@ function BrowserPagePane({
}
}
// Why: this effect mounts and wires up webview event listeners once per tab
// identity. browserTab.url and webviewPartition are intentionally excluded:
// re-running on URL changes would detach/reattach the webview, cancelling
// in-progress navigations. Callbacks use refs so they always see current values.
// identity. browserTab.url is intentionally excluded: re-running on URL
// changes would detach/reattach the webview, cancelling in-progress
// navigations. Callbacks use refs so they always see current values.
// webviewPartition IS included: switching profiles changes the partition,
// which requires destroying and recreating the webview since Electron does
// not allow changing a webview's partition after creation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
browserTab.id,
webviewPartition,
workspaceId,
worktreeId,
createBrowserTab,
@ -1817,28 +1821,11 @@ function BrowserPagePane({
<ExternalLink className="size-4" />
</Button>
{sessionProfile && (
<div
className="flex h-6 items-center gap-1 rounded-md bg-accent/60 px-2 text-[10px] font-medium text-muted-foreground"
title={`Session: ${sessionProfile.label}${sessionProfile.source?.browserFamily ? ` (${sessionProfile.source.browserFamily})` : ''}`}
>
<Import className="size-3" />
{sessionProfile.label}
</div>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
title="Browser Settings"
onClick={() => {
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
useAppStore.getState().openSettingsPage()
}}
>
<Settings className="size-4" />
</Button>
<BrowserToolbarMenu
currentProfileId={sessionProfileId}
workspaceId={workspaceId}
onDestroyWebview={() => destroyPersistentWebview(browserTab.id)}
/>
</div>
{downloadState ? (
<div className="flex items-center gap-3 border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-foreground/90">

View file

@ -0,0 +1,305 @@
import { useState } from 'react'
import { Check, Ellipsis, Import, Plus, Settings } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
type BrowserToolbarMenuProps = {
currentProfileId: string | null
workspaceId: string
onDestroyWebview: () => void
}
export function BrowserToolbarMenu({
currentProfileId,
workspaceId,
onDestroyWebview
}: BrowserToolbarMenuProps): React.JSX.Element {
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const switchBrowserTabProfile = useAppStore((s) => s.switchBrowserTabProfile)
const createBrowserSessionProfile = useAppStore((s) => s.createBrowserSessionProfile)
const importCookiesFromBrowser = useAppStore((s) => s.importCookiesFromBrowser)
const importCookiesToProfile = useAppStore((s) => s.importCookiesToProfile)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
const [newProfileName, setNewProfileName] = useState('')
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState<string | null | undefined>(
undefined
)
const effectiveProfileId = currentProfileId ?? 'default'
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
// Why: Default profile always appears first in the list and cannot be deleted.
// Non-default profiles follow in their natural order.
const allProfiles = defaultProfile
? [defaultProfile, ...browserSessionProfiles.filter((p) => p.id !== 'default')]
: browserSessionProfiles
const handleSwitchProfile = (profileId: string | null): void => {
const targetId = profileId ?? 'default'
if (targetId === effectiveProfileId) {
return
}
setPendingSwitchProfileId(profileId)
}
const confirmSwitchProfile = (): void => {
if (pendingSwitchProfileId === undefined) {
return
}
const targetId = pendingSwitchProfileId ?? 'default'
// Why: Must destroy before store update. The webviewRegistry is keyed by
// workspace ID (stable across switches). Without explicit destroy, the mount
// effect would reclaim the old webview with the stale partition.
onDestroyWebview()
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId)
const profile = browserSessionProfiles.find((p) => p.id === targetId)
toast.success(`Switched to ${profile?.label ?? 'Default'} profile`)
setPendingSwitchProfileId(undefined)
}
const handleCreateProfile = async (): Promise<void> => {
const trimmed = newProfileName.trim()
if (!trimmed) {
return
}
setIsCreatingProfile(true)
try {
const profile = await createBrowserSessionProfile('isolated', trimmed)
if (!profile) {
toast.error('Failed to create profile.')
return
}
setNewProfileDialogOpen(false)
setNewProfileName('')
onDestroyWebview()
switchBrowserTabProfile(workspaceId, profile.id)
toast.success(`Created and switched to ${profile.label} profile`)
} finally {
setIsCreatingProfile(false)
}
}
const handleImportFromBrowser = async (
browserFamily: string,
browserProfile?: string
): Promise<void> => {
const result = await importCookiesFromBrowser(effectiveProfileId, browserFamily, browserProfile)
if (result.ok) {
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.`
)
} else {
toast.error(result.reason)
}
}
const handleImportFromFile = async (): Promise<void> => {
const result = await importCookiesToProfile(effectiveProfileId)
if (result.ok) {
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" title="Browser menu">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{allProfiles.map((profile) => {
const isActive = profile.id === effectiveProfileId
return (
<DropdownMenuItem
key={profile.id}
onSelect={() => handleSwitchProfile(profile.id === 'default' ? null : profile.id)}
>
<Check
className={`mr-2 size-3.5 shrink-0 ${isActive ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="truncate">{profile.label}</span>
{profile.source?.browserFamily && (
<span className="ml-auto pl-2 text-[10px] text-muted-foreground">
{BROWSER_FAMILY_LABELS[profile.source.browserFamily] ??
profile.source.browserFamily}
</span>
)}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setNewProfileDialogOpen(true)}>
<Plus className="mr-2 size-3.5" />
New Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={browserSessionImportState?.status === 'importing'}>
<Import className="mr-2 size-3.5" />
Import Cookies
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((profile) => (
<DropdownMenuItem
key={profile.directory}
onSelect={() =>
void handleImportFromBrowser(browser.family, profile.directory)
}
>
{profile.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
From {browser.label}
</DropdownMenuItem>
)
)}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
From File
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
useAppStore.getState().openSettingsPage()
}}
>
<Settings className="mr-2 size-3.5" />
Browser Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={pendingSwitchProfileId !== undefined}
onOpenChange={(open) => {
if (!open) {
setPendingSwitchProfileId(undefined)
}
}}
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">Switch Profile</DialogTitle>
<DialogDescription className="text-xs">
Switching profiles will reload this page. Any unsaved form data will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingSwitchProfileId(undefined)}
>
Cancel
</Button>
<Button size="sm" onClick={confirmSwitchProfile}>
Switch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={newProfileDialogOpen} onOpenChange={setNewProfileDialogOpen}>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault()
void handleCreateProfile()
}}
>
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name"
autoFocus
maxLength={50}
className="mb-4"
/>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewProfileDialogOpen(false)
setNewProfileName('')
}}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!newProfileName.trim() || isCreatingProfile}
>
{isCreatingProfile ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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])
}

View file

@ -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...

View file

@ -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}

View file

@ -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}
/>

View file

@ -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}>

View file

@ -3,6 +3,7 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import { extractFrontMatter } from './markdown-frontmatter'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import type { Components } from 'react-markdown'
@ -23,6 +24,7 @@ import {
isMarkdownPreviewFindShortcut,
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
type MarkdownPreviewProps = {
content: string
@ -63,7 +65,9 @@ export default function MarkdownPreview({
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const frontMatter = useMemo(() => extractFrontMatter(content), [content])
const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef)
const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent])
const frontMatterInner = useMemo(() => {
if (!frontMatter) {
return ''
@ -147,7 +151,7 @@ export default function MarkdownPreview({
// Why: content is included so the restore loop re-triggers when markdown
// content arrives or changes (e.g., async file load), since scrollHeight
// depends on rendered content and may not be large enough until then.
}, [scrollCacheKey, content])
}, [scrollCacheKey, renderedContent])
const moveToMatch = useCallback((direction: 1 | -1) => {
const matches = matchesRef.current
@ -204,7 +208,7 @@ export default function MarkdownPreview({
})
return () => clearMarkdownPreviewSearchHighlights(body)
}, [content, isSearchOpen, query])
}, [renderedContent, isSearchOpen, query])
useEffect(() => {
setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex)
@ -249,7 +253,27 @@ export default function MarkdownPreview({
const components: Components = {
a: ({ href, children, ...props }) => {
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
if (!href || href.startsWith('#')) {
if (!href) {
return
}
// Why: anchor links target headings within the same preview. rehype-slug
// adds matching id attributes to headings so querySelector can find them.
// No modifier key required — same-page scroll is non-destructive.
if (href.startsWith('#')) {
event.preventDefault()
// Why: anchors in markdown are often URL-encoded (e.g. `#%C3%A9-foo`)
// while rehype-slug produces unicode ids, so decode before matching.
let id = href.slice(1)
try {
id = decodeURIComponent(id)
} catch {
// Malformed %-escapes: fall back to the raw fragment.
}
const el = rootRef.current?.querySelector(`[id="${CSS.escape(id)}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return
}
@ -442,9 +466,9 @@ export default function MarkdownPreview({
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeHighlight]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
>
{content}
{renderedContent}
</Markdown>
</div>
</div>

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: this component co-locates the rich markdown editor surface, toolbar, search, and slash menu so tightly coupled editor state stays in one place. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
@ -29,6 +30,7 @@ import {
absolutePathToFileUri as toFileUrlForOsEscape,
resolveMarkdownLinkTarget
} from './markdown-internal-links'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
type RichMarkdownEditorProps = {
fileId: string
@ -166,50 +168,49 @@ export default function RichMarkdownEditor({
// OS default handler. Cmd/Ctrl+Shift-click is the OS escape hatch, kept
// symmetric with MarkdownPreview. Without a modifier the click falls
// through to TipTap's default cursor-positioning behavior.
handleClick: (_view, _pos, event) => {
// Why: ProseMirror fires handleClick before updating the selection, so
// ed.isActive('link') reads the *old* cursor position. We resolve the
// link mark directly at the clicked pos instead.
handleClick: (view, pos, event) => {
const ed = editorRef.current
if (!ed) {
const modKey = isMac ? event.metaKey : event.ctrlKey
if (!ed || !modKey) {
return false
}
const modKey = isMac ? event.metaKey : event.ctrlKey
if (modKey && ed.isActive('link')) {
const href = (ed.getAttributes('link').href as string) || ''
if (!href) {
return false
}
if (event.shiftKey) {
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return true
}
if (classified.kind === 'external') {
void window.api.shell.openUrl(classified.url)
return true
}
if (classified.kind === 'markdown') {
void window.api.shell.pathExists(classified.absolutePath).then((exists) => {
if (!exists) {
toast.error(`File not found: ${classified.relativePath}`)
return
}
void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath))
})
return true
}
if (classified.kind === 'file') {
void window.api.shell.openFileUri(classified.uri)
return true
}
return true
}
void activateMarkdownLink(href, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot
})
const linkMark = view.state.doc
.resolve(pos)
.marks()
.find((m) => m.type.name === 'link')
const href = linkMark ? (linkMark.attrs.href as string) || '' : ''
if (!href) {
return false
}
if (href.startsWith('#')) {
scrollToAnchorInEditor(rootRef.current, href.slice(1))
return true
}
return false
if (event.shiftKey) {
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return true
}
if (classified.kind === 'external') {
void window.api.shell.openUrl(classified.url)
} else if (classified.kind === 'markdown') {
void window.api.shell.pathExists(classified.absolutePath).then((exists) => {
if (!exists) {
toast.error(`File not found: ${classified.relativePath}`)
return
}
void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath))
})
} else if (classified.kind === 'file') {
void window.api.shell.openFileUri(classified.uri)
}
return true
}
void activateMarkdownLink(href, { sourceFilePath: filePath, worktreeId, worktreeRoot })
return true
}
},
onFocus: () => {
@ -365,7 +366,8 @@ export default function RichMarkdownEditor({
} = useRichMarkdownSearch({
editor,
isMac,
rootRef
rootRef,
scrollContainerRef
})
useEffect(() => {
openSearchRef.current = openSearch
@ -438,6 +440,17 @@ export default function RichMarkdownEditor({
// triggers a re-sync attempt instead of being short-circuited by the
// `content === lastCommittedMarkdownRef.current` guard above.
try {
// Why: TipTap's setContent collapses the selection to the end of the
// new document by default. When the editor is focused (user is
// actively typing), that reads as a spontaneous cursor jump to EOF.
// Snapshot the current selection bounds and restore them clamped to
// the new doc length after the content swap so the caret stays put
// for any genuinely external edit that lands during a typing session.
// The old doc's offsets are a best-effort heuristic — for a real
// external rewrite they won't map to the semantically equivalent
// position, but this is still strictly better than jumping to EOF.
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
contentType: 'markdown',
emitUpdate: false
@ -446,6 +459,18 @@ export default function RichMarkdownEditor({
// may re-introduce paragraphs with embedded `\n` characters.
normalizeSoftBreaks(editor)
lastCommittedMarkdownRef.current = content
if (hadFocus) {
// Why: setContent can blur the editor via ProseMirror's focus
// handling, so restoring selection alone would leave subsequent
// keystrokes going to the browser. Chain focus() after the
// selection restore to keep the typing session intact.
const docSize = editor.state.doc.content.size
editor
.chain()
.setTextSelection({ from: Math.min(prevFrom, docSize), to: Math.min(prevTo, docSize) })
.focus()
.run()
}
} catch (err) {
console.error('[RichMarkdownEditor] failed to apply external content update', err)
}
@ -480,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 ? (

View file

@ -6,6 +6,7 @@ import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-sa
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
import { attachEditorAutosaveController } from './editor-autosave-controller'
import { registerPendingEditorFlush } from './editor-pending-flush'
import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry'
type WindowStub = {
addEventListener: Window['addEventListener']
@ -61,6 +62,7 @@ describe('attachEditorAutosaveController', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
__clearSelfWriteRegistryForTests()
})
it('saves dirty files even when the visible EditorPanel is not mounted', async () => {
@ -241,4 +243,40 @@ describe('attachEditorAutosaveController', () => {
unregisterFlush()
}
})
it('clears the self-write stamp when a save fails before touching disk', async () => {
const writeFile = vi.fn().mockRejectedValue(new Error('disk full'))
const eventTarget = new EventTarget()
vi.stubGlobal('window', {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
api: {
fs: {
writeFile
}
}
} satisfies WindowStub)
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/file.md',
relativePath: 'file.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().setEditorDraft('/repo/file.md', 'edited')
store.getState().markFileDirty('/repo/file.md', true)
const cleanup = attachEditorAutosaveController(store)
try {
await expect(requestEditorFileSave({ fileId: '/repo/file.md' })).rejects.toThrow('disk full')
expect(hasRecentSelfWrite('/repo/file.md')).toBe(false)
} finally {
cleanup()
}
})
})

View file

@ -18,6 +18,7 @@ import {
type EditorSaveQuiesceDetail
} from './editor-autosave'
import { flushPendingEditorChange } from './editor-pending-flush'
import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry'
import {
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
type EditorSaveDirtyFilesDetail
@ -74,11 +75,25 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
const contentToSave = state.editorDrafts[file.id] ?? fallbackContent
const connectionId = getConnectionId(liveFile.worktreeId) ?? undefined
await window.api.fs.writeFile({
filePath: liveFile.filePath,
content: contentToSave,
connectionId
})
// Why: stamp before the write so the fs:changed event that our own
// write produces is ignored by useEditorExternalWatch instead of
// round-tripping back into a setContent that jumps the cursor to the
// end (and, under round-trip drift, can drop keystrokes typed in the
// debounce window). See editor-self-write-registry.
recordSelfWrite(liveFile.filePath)
try {
await window.api.fs.writeFile({
filePath: liveFile.filePath,
content: contentToSave,
connectionId
})
} catch (error) {
// Why: the self-write stamp is only valid if a disk write actually
// happened. Clearing it on failure keeps the external watcher from
// suppressing a real third-party update that lands during the TTL.
clearSelfWrite(liveFile.filePath)
throw error
}
if ((saveGeneration.get(file.id) ?? 0) !== queuedGeneration) {
return

View file

@ -0,0 +1,41 @@
import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths'
// Why: the editor's own save path writes to disk, which fans out as an
// fs:changed event back to useEditorExternalWatch a few ms later. Treating
// our own write as an "external" change schedules a setContent reload that
// resets the TipTap selection to the end of the document mid-typing — and,
// because the RichMarkdownEditor guards (lastCommittedMarkdownRef + current
// getMarkdown() round-trip) can drift by a trailing newline or soft-break,
// the reload can silently drop unsaved keystrokes as well. Stamping a path
// right before writeFile lets the watch hook ignore the echo event without
// touching the editor at all. Keyed by normalized absolute path, bounded by
// a short TTL so a genuinely external edit that lands after the window still
// gets picked up.
const SELF_WRITE_TTL_MS = 750
const stamps = new Map<string, number>()
export function recordSelfWrite(absolutePath: string): void {
stamps.set(normalizeAbsolutePath(absolutePath), Date.now() + SELF_WRITE_TTL_MS)
}
export function clearSelfWrite(absolutePath: string): void {
stamps.delete(normalizeAbsolutePath(absolutePath))
}
export function hasRecentSelfWrite(absolutePath: string): boolean {
const key = normalizeAbsolutePath(absolutePath)
const expiry = stamps.get(key)
if (expiry === undefined) {
return false
}
if (Date.now() > expiry) {
stamps.delete(key)
return false
}
return true
}
export function __clearSelfWriteRegistryForTests(): void {
stamps.clear()
}

View 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 })
}
}

View 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; }
}
`

View file

@ -0,0 +1,26 @@
import GithubSlugger from 'github-slugger'
// Why: rehype-slug generates heading ids using a stateful GithubSlugger that
// appends numeric suffixes to duplicate headings (foo, foo-1, foo-2). To keep
// the editor's anchor matching in parity with the preview, we must use the
// same stateful slugger — the stateless `slug()` helper would miss suffixes
// and silently land on the wrong heading.
export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string): void {
if (!root || !anchor) {
return
}
let decoded = anchor
try {
decoded = decodeURIComponent(anchor)
} catch {
// Malformed %-escapes: fall back to the raw fragment.
}
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
const slugger = new GithubSlugger()
for (const heading of headings) {
if (slugger.slug(heading.textContent ?? '') === decoded) {
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
}
}

View file

@ -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 }
}

View file

@ -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>&lt;script&gt;alert(1)&lt;/script&gt;</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>')
})
})

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* 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>`
}

View file

@ -1,4 +1,4 @@
import { canRoundTripRichMarkdown, getRichMarkdownRoundTripOutput } from './markdown-round-trip'
import { getRichMarkdownRoundTripOutput } from './markdown-round-trip'
import { extractFrontMatter } from './markdown-frontmatter'
export type MarkdownRichModeUnsupportedReason =
@ -43,31 +43,35 @@ export function getMarkdownRichModeUnsupportedMessage(content: string): string |
const contentWithoutCode = stripMarkdownCode(body)
if (canRoundTripRichMarkdown(body)) {
return null
}
// Why: HTML/JSX gets special treatment — if the round-trip output preserves
// the embedded markup, we allow rich mode even though the pattern matched.
// Looked up by reason (not index) so reordering the array won't break this.
// Why: run cheap regex checks first. If no unsupported syntax is detected,
// rich mode is safe — no need for the expensive round-trip check. The
// round-trip (which synchronously creates a throwaway TipTap editor, parses
// the full document, and serializes it back) is only needed as a second
// opinion when HTML is detected, to verify the HTML survives the round-trip
// before blocking the user from rich mode.
const htmlMatcher = UNSUPPORTED_PATTERNS.find((m) => m.reason === 'html-or-jsx')
if (htmlMatcher && htmlMatcher.pattern.test(contentWithoutCode)) {
const roundTripOutput = getRichMarkdownRoundTripOutput(body)
if (roundTripOutput && preservesEmbeddedHtml(contentWithoutCode, roundTripOutput)) {
return null
}
}
const hasHtml = htmlMatcher && htmlMatcher.pattern.test(contentWithoutCode)
for (const matcher of UNSUPPORTED_PATTERNS) {
if (matcher.reason === 'html-or-jsx') {
continue
}
if (matcher.pattern.test(contentWithoutCode)) {
return matcher.message
}
}
// Why: Tiptap rewrites some harmless markdown spellings such as autolinks or
// escaped angle brackets even when the rendered document stays equivalent.
// Preview mode should stay editable unless we have a specific syntax we know
// the editor will drop or reinterpret in a user-visible way.
if (hasHtml) {
// Why: the round-trip check creates a throwaway TipTap Editor synchronously
// on the main thread. For large files this blocks for seconds, so we skip it and conservatively block rich mode for HTML files
// above this threshold.
const roundTripOutput = body.length <= 50_000 ? getRichMarkdownRoundTripOutput(body) : null
if (roundTripOutput && preservesEmbeddedHtml(contentWithoutCode, roundTripOutput)) {
return null
}
return htmlMatcher!.message
}
return null
}

View file

@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react'
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { useAppStore } from '@/store'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
/**
* Extracts link-editing action handlers from the editor component to reduce
@ -100,6 +101,10 @@ export function useLinkBubble(
if (!linkBubble?.href) {
return
}
if (linkBubble.href.startsWith('#')) {
scrollToAnchorInEditor(rootRef.current, linkBubble.href.slice(1))
return
}
void activateMarkdownLink(linkBubble.href, {
sourceFilePath: linkContext.sourceFilePath,
worktreeId: linkContext.worktreeId,
@ -110,7 +115,8 @@ export function useLinkBubble(
linkBubble?.href,
linkContext.sourceFilePath,
linkContext.worktreeId,
linkContext.worktreeRoot
linkContext.worktreeRoot,
rootRef
])
const toggleLinkFromToolbar = useCallback(() => {

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react'
// Why: when the .md file is being modified externally (e.g. the AI is
// streaming writes), each external-change event replaces the `content`
// prop, which makes react-markdown replace the rendered text nodes. Any
// in-progress browser selection inside the preview collapses mid-drag
// because its anchor/focus nodes are detached. This hook holds back content
// updates while the user has an active selection inside the preview body
// and applies the latest pending content once the selection is released.
export function usePreserveSectionDuringExternalEdit(
content: string,
bodyRef: React.RefObject<HTMLDivElement | null>
): string {
const [renderedContent, setRenderedContent] = useState(content)
const pendingContentRef = useRef(content)
pendingContentRef.current = content
useEffect(() => {
if (content === renderedContent) {
return
}
const body = bodyRef.current
const hasSelectionInsideBody = (): boolean => {
if (!body) {
return false
}
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
return false
}
const anchor = selection.anchorNode
const focus = selection.focusNode
return (
(anchor instanceof Node && body.contains(anchor)) ||
(focus instanceof Node && body.contains(focus))
)
}
if (!hasSelectionInsideBody()) {
setRenderedContent(content)
return
}
// Why: cap the deferral so a forgotten selection (user walked away with
// text highlighted) can't freeze the preview indefinitely while the file
// keeps changing on disk. After the cap elapses we apply the pending
// content even if the selection is still held — the user loses a
// highlight they'd abandoned anyway, which is preferable to stale content.
const MAX_DEFER_MS = 3000
const deadline = performance.now() + MAX_DEFER_MS
let frameId = 0
const waitForSelectionRelease = (): void => {
if (performance.now() >= deadline || !hasSelectionInsideBody()) {
setRenderedContent(pendingContentRef.current)
return
}
frameId = window.requestAnimationFrame(waitForSelectionRelease)
}
frameId = window.requestAnimationFrame(waitForSelectionRelease)
return () => window.cancelAnimationFrame(frameId)
}, [bodyRef, content, renderedContent])
return renderedContent
}

View file

@ -12,11 +12,13 @@ import {
export function useRichMarkdownSearch({
editor,
isMac,
rootRef
rootRef,
scrollContainerRef
}: {
editor: Editor | null
isMac: boolean
rootRef: RefObject<HTMLDivElement | null>
scrollContainerRef: RefObject<HTMLDivElement | null>
}) {
const searchInputRef = useRef<HTMLInputElement | null>(null)
const [isSearchOpen, setIsSearchOpen] = useState(false)
@ -64,8 +66,12 @@ export function useRichMarkdownSearch({
return
}
// Why: rawActiveMatchIndex starts at -1 before the user navigates, but the
// derived activeMatchIndex is already 0 (first match shown). Using 0 as the
// base when raw is -1 ensures the first Enter press advances to match 1
// instead of computing (-1+1)%N = 0 and leaving the effect unchanged.
setRawActiveMatchIndex((currentIndex) => {
const baseIndex = currentIndex >= 0 ? currentIndex : direction === 1 ? -1 : 0
const baseIndex = Math.max(currentIndex, 0)
return (baseIndex + direction + matchCount) % matchCount
})
},
@ -117,31 +123,40 @@ export function useRichMarkdownSearch({
}
const query = isSearchOpen ? searchQuery : ''
editor.view.dispatch(
editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
activeIndex: activeMatchIndex,
query
})
)
if (!query || activeMatchIndex < 0) {
return
}
const activeMatch = matches[activeMatchIndex]
if (!activeMatch) {
return
}
// Why: rich-mode find should navigate within the editor model instead of
// the rendered DOM so highlight positions stay correct while the user edits.
// Updating the ProseMirror selection keeps scroll-to-match aligned with the
// actual markdown document rather than the transient browser layout.
// Why: combining decoration meta and selection+scrollIntoView into one
// transaction avoids a split-dispatch where the first dispatch updates
// editor.state and the second dispatch's scrollIntoView can be lost
// when ProseMirror coalesces view updates.
const tr = editor.state.tr
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
tr.scrollIntoView()
tr.setMeta(richMarkdownSearchPluginKey, {
activeIndex: activeMatchIndex,
query
})
const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
if (activeMatch) {
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
}
editor.view.dispatch(tr)
}, [activeMatchIndex, editor, isSearchOpen, matches, searchQuery])
// Why: ProseMirror's tr.scrollIntoView() delegates to the view's
// scrollDOMIntoView which may fail to reach the outer flex scroll container
// (the editor element itself has min-height: 100% and no overflow).
// Reading coordsAtPos *after* the dispatch and manually scrolling the
// container mirrors the approach used by MarkdownPreview search.
if (activeMatch) {
const container = scrollContainerRef.current
if (container) {
const coords = editor.view.coordsAtPos(activeMatch.from)
const containerRect = container.getBoundingClientRect()
const relativeTop = coords.top - containerRect.top
const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
container.scrollTo({ top: targetScroll, behavior: 'instant' })
}
}
}, [activeMatchIndex, editor, isSearchOpen, matches, scrollContainerRef, searchQuery])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {

View file

@ -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] }}

View file

@ -1,38 +1,18 @@
import { useEffect, useState } from 'react'
import { Import, Loader2, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
import { useAppStore } from '../../store'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { BROWSER_PANE_SEARCH_ENTRIES } from './browser-search'
const BROWSER_FAMILY_LABELS: Record<string, string> = {
chrome: 'Google Chrome',
chromium: 'Chromium',
arc: 'Arc',
edge: 'Microsoft Edge',
brave: 'Brave',
firefox: 'Firefox',
safari: 'Safari',
manual: 'File'
}
import { BrowserProfileRow } from './BrowserProfileRow'
export { BROWSER_PANE_SEARCH_ENTRIES }
@ -45,12 +25,17 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const setBrowserDefaultUrl = useAppStore((s) => s.setBrowserDefaultUrl)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId)
const setDefaultBrowserSessionProfileId = useAppStore((s) => s.setDefaultBrowserSessionProfileId)
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
const orphanedProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
const nonDefaultProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
const [homePageDraft, setHomePageDraft] = useState(browserDefaultUrl ?? '')
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
const [newProfileName, setNewProfileName] = useState('')
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
// Why: sync draft with store value whenever it changes externally (e.g. the
// in-app browser tab's address bar saves a home page). Without this, the
@ -145,7 +130,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
{showCookies ? (
<SearchableSetting
title="Session & Cookies"
description="Import cookies from Chrome, Edge, or other browsers to use existing logins inside Orca."
description="Manage browser profiles and import cookies from Chrome, Edge, or other browsers."
keywords={[
'cookies',
'session',
@ -163,173 +148,120 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
<div className="space-y-0.5">
<Label>Session &amp; Cookies</Label>
<p className="text-xs text-muted-foreground">
Import cookies from your system browser to reuse existing logins inside Orca.
Select a default profile for new browser tabs. Import cookies and switch profiles
per-tab via the <strong>···</strong> toolbar menu.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="xs"
className="shrink-0 gap-1.5"
disabled={browserSessionImportState?.status === 'importing'}
>
{browserSessionImportState?.status === 'importing' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Import className="size-3" />
)}
Import Cookies
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((profile) => (
<DropdownMenuItem
key={profile.directory}
onSelect={async () => {
const store = useAppStore.getState()
const result = await store.importCookiesFromBrowser(
'default',
browser.family,
profile.directory
)
if (result.ok) {
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser.label} (${profile.name}).`
)
} else {
toast.error(result.reason)
}
}}
>
{profile.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={browser.family}
onSelect={async () => {
const store = useAppStore.getState()
const result = await store.importCookiesFromBrowser(
'default',
browser.family
)
if (result.ok) {
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser.label}.`
)
} else {
toast.error(result.reason)
}
}}
>
From {browser.label}
</DropdownMenuItem>
)
)}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={async () => {
const store = useAppStore.getState()
const result = await store.importCookiesToProfile('default')
if (result.ok) {
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
}
}}
>
From File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="xs"
onClick={() => setNewProfileDialogOpen(true)}
className="shrink-0 gap-1.5"
>
<Plus className="size-3" />
Add Profile
</Button>
</div>
{defaultProfile?.source ? (
<div className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
Imported from{' '}
{BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ??
defaultProfile.source.browserFamily}
{defaultProfile.source.profileName
? ` (${defaultProfile.source.profileName})`
: ''}
</span>
{defaultProfile.source.importedAt ? (
<span className="truncate text-[11px] text-muted-foreground">
{new Date(defaultProfile.source.importedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</span>
) : null}
</div>
<Button
variant="ghost"
size="xs"
className="gap-1 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore.getState().clearDefaultSessionCookies()
if (ok) {
toast.success('Cookies cleared.')
}
}}
>
<Trash2 className="size-3" />
Clear
</Button>
</div>
) : null}
{orphanedProfiles.length > 0 ? (
<div className="space-y-2">
{orphanedProfiles.map((profile) => (
<div
key={profile.id}
className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5"
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{profile.label}</span>
<span className="truncate text-[11px] text-muted-foreground">
{profile.source
? `Imported from ${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
: 'Unused session'}
</span>
</div>
<Button
variant="ghost"
size="xs"
className="gap-1 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore
.getState()
.deleteBrowserSessionProfile(profile.id)
if (ok) {
toast.success('Session removed.')
}
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
))}
</div>
) : null}
<div className="space-y-2">
<BrowserProfileRow
profile={
defaultProfile ?? {
id: 'default',
scope: 'default',
partition: '',
label: 'Default',
source: null
}
}
detectedBrowsers={detectedBrowsers}
importState={browserSessionImportState}
isActive={(defaultBrowserSessionProfileId ?? 'default') === 'default'}
onSelect={() => setDefaultBrowserSessionProfileId(null)}
isDefault
/>
{nonDefaultProfiles.map((profile) => (
<BrowserProfileRow
key={profile.id}
profile={profile}
detectedBrowsers={detectedBrowsers}
importState={browserSessionImportState}
isActive={(defaultBrowserSessionProfileId ?? 'default') === profile.id}
onSelect={() => setDefaultBrowserSessionProfileId(profile.id)}
/>
))}
</div>
</SearchableSetting>
) : null}
<Dialog
open={newProfileDialogOpen}
onOpenChange={(open) => {
if (!open) {
setNewProfileDialogOpen(false)
setNewProfileName('')
}
}}
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
</DialogHeader>
<form
onSubmit={async (e) => {
e.preventDefault()
const trimmed = newProfileName.trim()
if (!trimmed) {
return
}
setIsCreatingProfile(true)
try {
const profile = await useAppStore
.getState()
.createBrowserSessionProfile('isolated', trimmed)
if (profile) {
setNewProfileDialogOpen(false)
setNewProfileName('')
toast.success(`Profile "${profile.label}" created.`)
} else {
toast.error('Failed to create profile.')
}
} finally {
setIsCreatingProfile(false)
}
}}
>
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name"
autoFocus
maxLength={50}
className="mb-4"
/>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewProfileDialogOpen(false)
setNewProfileName('')
}}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!newProfileName.trim() || isCreatingProfile}
>
{isCreatingProfile ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,192 @@
import { Import, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { BrowserCookieImportSummary, BrowserSessionProfile } from '../../../../shared/types'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { useAppStore } from '../../store'
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
type DetectedBrowser = {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}
export type BrowserProfileRowProps = {
profile: BrowserSessionProfile
detectedBrowsers: DetectedBrowser[]
importState: {
profileId: string
status: 'idle' | 'importing' | 'success' | 'error'
summary: BrowserCookieImportSummary | null
error: string | null
} | null
isActive: boolean
onSelect: () => void
isDefault?: boolean
}
export function BrowserProfileRow({
profile,
detectedBrowsers,
importState,
isActive,
onSelect,
isDefault
}: BrowserProfileRowProps): React.JSX.Element {
const isImporting = importState?.profileId === profile.id && importState.status === 'importing'
const handleImportFromBrowser = async (
browserFamily: string,
browserProfile?: string
): Promise<void> => {
const result = await useAppStore
.getState()
.importCookiesFromBrowser(profile.id, browserFamily, browserProfile)
if (result.ok) {
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''} into ${profile.label}.`
)
} else {
toast.error(result.reason)
}
}
const handleImportFromFile = async (): Promise<void> => {
const result = await useAppStore.getState().importCookiesToProfile(profile.id)
if (result.ok) {
toast.success(
`Imported ${result.summary.importedCookies} cookies from file into ${profile.label}.`
)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
}
}
const sourceLabel = profile.source
? `${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
: null
return (
<button
type="button"
onClick={onSelect}
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{profile.label}</span>
{isActive ? (
<span className="shrink-0 rounded border border-border/50 px-1.5 text-[10px] font-medium leading-4 text-foreground/80">
Active
</span>
) : null}
</div>
{sourceLabel ? (
<p className="truncate text-[11px] text-muted-foreground">{sourceLabel}</p>
) : (
<p className="text-[11px] text-muted-foreground">No cookies imported</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground"
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Import className="size-3" />
)}
Import Cookies
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((bp) => (
<DropdownMenuItem
key={bp.directory}
onSelect={() =>
void handleImportFromBrowser(browser.family, bp.directory)
}
>
{bp.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
From {browser.label}
</DropdownMenuItem>
)
)}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
From File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isDefault ? (
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
disabled={!profile.source}
onClick={async () => {
const ok = await useAppStore.getState().clearDefaultSessionCookies()
if (ok) {
toast.success('Default cookies cleared.')
}
}}
>
<Trash2 className="size-3" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore.getState().deleteBrowserSessionProfile(profile.id)
if (ok) {
toast.success(`Profile "${profile.label}" removed.`)
}
}}
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</button>
)
}

View file

@ -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>
)
}

View file

@ -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"
>

View file

@ -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) => {

View file

@ -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'

View file

@ -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) ? (
@ -368,6 +390,50 @@ export function TerminalPane({
/>
</button>
</SearchableSetting>
<SearchableSetting
title="Copy on Select"
description="Automatically copy terminal selections to the clipboard as soon as a selection is made."
keywords={[
'clipboard',
'copy',
'select',
'selection',
'auto',
'automatic',
'x11',
'linux',
'gnome',
'paste'
]}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Copy on Select</Label>
<p className="text-xs text-muted-foreground">
Automatically copy terminal selections to the clipboard as soon as a selection is
made.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalClipboardOnSelect}
onClick={() =>
updateSettings({
terminalClipboardOnSelect: !settings.terminalClipboardOnSelect
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalClipboardOnSelect ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalClipboardOnSelect ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? (

View file

@ -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']
}
]
@ -47,6 +52,23 @@ export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description:
"Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting. Selections and window switching stay safe.",
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
},
{
title: 'Copy on Select',
description:
'Automatically copy terminal selections to the clipboard as soon as a selection is made.',
keywords: [
'clipboard',
'copy',
'select',
'selection',
'auto',
'automatic',
'x11',
'linux',
'gnome',
'paste'
]
}
]

View file

@ -161,25 +161,42 @@ const WorktreeCard = React.memo(function WorktreeCard({
// Stable click handler ignore clicks that are really text selections.
// Why: if the SSH connection is down, show a reconnect dialog instead of
// activating the worktree — all remote operations would fail anyway.
const handleClick = useCallback(() => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
if (useAppStore.getState().activeView !== 'terminal') {
// Why: the sidebar remains visible during the new-workspace flow, so
// clicking a real worktree should switch the main pane back to that
// worktree instead of leaving the create surface visible.
setActiveView('terminal')
}
// Why: always activate the worktree so the user can see terminal history,
// editor state, etc. even when SSH is disconnected. Show the reconnect
// dialog as a non-blocking overlay rather than a gate.
setActiveWorktree(worktree.id)
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
}, [worktree.id, setActiveView, setActiveWorktree, isSshDisconnected])
const handleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const selection = window.getSelection()
// Why: only suppress the click when the selection is *inside this card*
// (a real drag-select on the card's own text). A selection anchored
// elsewhere — e.g. inside the markdown preview while the AI is streaming
// writes — must not block worktree switching, otherwise the user can't
// leave the current worktree without first clicking into a terminal to
// clear the foreign selection.
if (selection && selection.toString().length > 0) {
const card = event.currentTarget
const anchor = selection.anchorNode
const focus = selection.focusNode
const selectionInsideCard =
(anchor instanceof Node && card.contains(anchor)) ||
(focus instanceof Node && card.contains(focus))
if (selectionInsideCard) {
return
}
}
if (useAppStore.getState().activeView !== 'terminal') {
// Why: the sidebar remains visible during the new-workspace flow, so
// clicking a real worktree should switch the main pane back to that
// worktree instead of leaving the create surface visible.
setActiveView('terminal')
}
// Why: always activate the worktree so the user can see terminal history,
// editor state, etc. even when SSH is disconnected. Show the reconnect
// dialog as a non-blocking overlay rather than a gate.
setActiveWorktree(worktree.id)
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
},
[worktree.id, setActiveView, setActiveWorktree, isSshDisconnected]
)
const handleDoubleClick = useCallback(() => {
openModal('edit-meta', {

View file

@ -6,6 +6,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
FolderOpen,
Copy,
@ -13,10 +14,10 @@ import {
BellOff,
Link,
MessageSquare,
Moon,
Pencil,
Pin,
PinOff,
XCircle,
Trash2
} from 'lucide-react'
import { useAppStore } from '@/store'
@ -205,10 +206,18 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
{worktree.comment ? 'Edit Comment' : 'Add Comment'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleCloseTerminals} disabled={isDeleting}>
<XCircle className="size-3.5" />
Shutdown
</DropdownMenuItem>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem onSelect={handleCloseTerminals} disabled={isDeleting}>
<Moon className="size-3.5" />
Sleep
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="max-w-[240px]">
Close all terminals in this workspace to free up memory and CPU. They&apos;ll be
re-created when you reopen it.
</TooltipContent>
</Tooltip>
{/* Why: `git worktree remove` always rejects the main worktree, so we
disable the item upfront. Radix forwards unknown props to the DOM
element, so `title` works directly without a wrapper span this

View file

@ -25,6 +25,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { ShareUsageButton } from './ShareUsageButton'
import { StatCard } from './StatCard'
const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
@ -137,6 +138,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
{summary && daily.length > 0 && (
<ShareUsageButton provider="claude" summary={summary} daily={daily} range={range} />
)}
<DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>

View file

@ -24,6 +24,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { ShareUsageButton } from './ShareUsageButton'
import { StatCard } from './StatCard'
const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
@ -142,6 +143,9 @@ export function CodexUsagePane(): React.JSX.Element {
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
{summary && daily.length > 0 && (
<ShareUsageButton provider="codex" summary={summary} daily={daily} range={range} />
)}
<DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>

View file

@ -0,0 +1,143 @@
import { useCallback, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import { Check, Copy, Share2 } from 'lucide-react'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ShareUsageCard, type ShareUsageCardProps } from './ShareUsageCard'
type ShareUsageButtonProps = ShareUsageCardProps
function XIcon(): React.JSX.Element {
return (
<svg width={16} height={16} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
}
export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Element {
const cardRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const [capturing, setCapturing] = useState(false)
const captureToClipboard = useCallback(async () => {
if (!cardRef.current || capturing) {
return
}
setCapturing(true)
try {
const dataUrl = await toPng(cardRef.current, {
pixelRatio: 2,
backgroundColor: undefined
})
await window.api.ui.writeClipboardImage(dataUrl)
return true
} finally {
setCapturing(false)
}
}, [capturing])
const handleCopy = useCallback(async () => {
const ok = await captureToClipboard()
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [captureToClipboard])
const handleShareToX = useCallback(async () => {
const { provider, summary, range } = props
const providerName = provider === 'claude' ? 'Claude' : 'Codex'
const rangeLabel =
range === '7d'
? 'last 7 days'
: range === '30d'
? 'last 30 days'
: range === '90d'
? 'last 90 days'
: 'all-time'
const totalTokens =
provider === 'claude'
? summary.inputTokens + summary.outputTokens
: (summary as unknown as { totalTokens: number }).totalTokens
const cost = summary.estimatedCostUsd
const costStr =
cost === null ? 'n/a' : cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`
const fmtTokens = (v: number): string => {
if (v >= 1_000_000) {
return `${(v / 1_000_000).toFixed(1)}M`
}
if (v >= 1_000) {
return `${(v / 1_000).toFixed(1)}k`
}
return v.toLocaleString()
}
const lines = [
`My ${rangeLabel} ${providerName} usage via @orca_build`,
'',
`${fmtTokens(totalTokens)} tokens · ${costStr} est. cost`,
'',
'github.com/stablyai/orca'
]
const url = `https://x.com/intent/post?text=${encodeURIComponent(lines.join('\n'))}`
await window.api.shell.openUrl(url)
}, [props])
return (
<Dialog>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label="Share usage">
<Share2 className="size-3.5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Share
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent className="max-w-fit" showCloseButton>
<DialogHeader>
<DialogTitle>Share usage</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-2">
<ShareUsageCard ref={cardRef} {...props} />
<div className="flex w-full max-w-[480px] gap-2">
<Button onClick={() => void handleCopy()} disabled={capturing} className="flex-1">
{copied ? (
<>
<Check className="mr-2 size-4" />
Copied
</>
) : (
<>
<Copy className="mr-2 size-4" />
Copy image
</>
)}
</Button>
<Button
variant="outline"
onClick={() => void handleShareToX()}
disabled={capturing}
className="flex-1"
>
<span className="mr-2">
<XIcon />
</span>
Share on X
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,359 @@
import { forwardRef } from 'react'
import type { ClaudeUsageSummary } from '../../../../shared/claude-usage-types'
import type { CodexUsageSummary } from '../../../../shared/codex-usage-types'
import {
BackgroundGlows,
CardFooter,
formatCost,
formatDateRange,
formatTokens,
getDailySegments,
getDailyTotal,
getLegendItems,
OrcaLogo,
RANGE_LABELS
} from './share-card-utils'
import type { ClaudeShareData, CodexShareData } from './share-card-utils'
export type ShareUsageCardProps = (ClaudeShareData | CodexShareData) & {
range: string
}
// Why: html-to-image uses SVG foreignObject which handles modern CSS fine,
// but inline styles are kept for portability and to avoid Tailwind class stripping.
export const ShareUsageCard = forwardRef<HTMLDivElement, ShareUsageCardProps>(
function ShareUsageCard(props, ref) {
const { provider, summary, daily, range } = props
const slicedDaily = daily.slice(-10)
const totalTokens =
provider === 'claude'
? summary.inputTokens + summary.outputTokens
: (summary as CodexUsageSummary).totalTokens
const topModel =
provider === 'claude'
? ((summary as ClaudeUsageSummary).topModel ?? 'n/a')
: ((summary as CodexUsageSummary).topModel ?? 'n/a')
const sessions =
provider === 'claude'
? (summary as ClaudeUsageSummary).sessions
: (summary as CodexUsageSummary).sessions
const turnsOrEvents =
provider === 'claude'
? { label: 'turns', count: (summary as ClaudeUsageSummary).turns }
: { label: 'events', count: (summary as CodexUsageSummary).events }
const providerLabel = provider === 'claude' ? 'Claude' : 'Codex'
return (
<div
ref={ref}
style={{
width: 480,
padding: '28px 28px 24px',
background: 'linear-gradient(145deg, #111111 0%, #0a0a0a 50%, #0d0d1a 100%)',
borderRadius: 16,
border: '1px solid rgba(255, 255, 255, 0.08)',
color: '#fafafa',
fontFamily: "'Helvetica Neue', Arial, sans-serif",
WebkitFontSmoothing: 'antialiased',
position: 'relative',
overflow: 'hidden'
}}
>
<BackgroundGlows />
<CardHeader providerLabel={providerLabel} range={range} />
<div
style={{ fontSize: 11, color: '#555', position: 'relative', zIndex: 1, marginBottom: 16 }}
>
{formatDateRange(range)}
</div>
<StatsGrid summary={summary} totalTokens={totalTokens} topModel={topModel} />
<div style={{ position: 'relative', zIndex: 1 }}>
<ChartHeader sessions={sessions} turnsOrEvents={turnsOrEvents} />
<DailyChart slicedDaily={slicedDaily} />
<DayLabels slicedDaily={slicedDaily} />
<Legend provider={provider} />
</div>
<CardFooter summary={summary} />
</div>
)
}
)
function CardHeader(props: { providerLabel: string; range: string }): React.JSX.Element {
return (
<div
style={{
display: 'table',
width: '100%',
marginBottom: 6,
position: 'relative',
zIndex: 1
}}
>
<div style={{ display: 'table-cell', verticalAlign: 'middle' }}>
<div style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<OrcaLogo />
</div>
<div style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: 10 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#fafafa', lineHeight: 1.2 }}>
Orca IDE
</div>
<div style={{ fontSize: 10, color: '#555', letterSpacing: 0.3 }}>
{props.providerLabel} Usage
</div>
</div>
</div>
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: '#a1a1a1',
background: 'rgba(255, 255, 255, 0.06)',
padding: '3px 8px',
borderRadius: 6,
letterSpacing: 0.3
}}
>
{RANGE_LABELS[props.range] ?? props.range}
</span>
</div>
</div>
)
}
function StatsGrid(props: {
summary: { estimatedCostUsd: number | null }
totalTokens: number
topModel: string
}): React.JSX.Element {
const cards = [
{
value: formatCost(props.summary.estimatedCostUsd ?? null),
label: 'Est. cost',
bg: 'rgba(20, 71, 230, 0.1)',
border: '1px solid rgba(20, 71, 230, 0.2)',
valueColor: '#93b4ff',
valueFontSize: 16
},
{
value: formatTokens(props.totalTokens),
label: 'Total tokens',
bg: 'rgba(255, 255, 255, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.06)',
valueColor: '#fafafa',
valueFontSize: 16
},
{
value: props.topModel,
label: 'Top model',
bg: 'rgba(255, 255, 255, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.06)',
valueColor: '#fafafa',
valueFontSize: 14
}
]
return (
<div style={{ position: 'relative', zIndex: 1, marginBottom: 20 }}>
{cards.map((card, i) => (
<div
key={card.label}
style={{
display: 'inline-block',
verticalAlign: 'top',
width: 'calc(33.33% - 6px)',
marginLeft: i > 0 ? 8 : 0,
background: card.bg,
border: card.border,
borderRadius: 10,
padding: '10px 12px',
height: 52,
overflow: 'hidden',
boxSizing: 'border-box'
}}
>
<div
style={{
fontSize: card.valueFontSize,
fontWeight: 600,
color: card.valueColor,
lineHeight: 1.2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{card.value}
</div>
<div style={{ fontSize: 10, color: '#666', marginTop: 2, letterSpacing: 0.2 }}>
{card.label}
</div>
</div>
))}
</div>
)
}
function ChartHeader(props: {
sessions: number
turnsOrEvents: { label: string; count: number }
}): React.JSX.Element {
return (
<div style={{ display: 'table', width: '100%', marginBottom: 10 }}>
<div style={{ display: 'table-cell', verticalAlign: 'bottom' }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: '#555',
letterSpacing: 0.3,
textTransform: 'uppercase' as const
}}
>
Daily tokens
</span>
</div>
<div style={{ display: 'table-cell', verticalAlign: 'bottom', textAlign: 'right' }}>
<span style={{ fontSize: 10, color: '#444' }}>
{props.sessions} sessions · {props.turnsOrEvents.count} {props.turnsOrEvents.label}
</span>
</div>
</div>
)
}
function DailyChart(props: {
slicedDaily: Parameters<typeof getDailySegments>[0][]
}): React.JSX.Element {
const CHART_H = 120
const maxSegSum = Math.max(
1,
...props.slicedDaily.map((entry) => {
const segs = getDailySegments(entry)
return segs.reduce((sum, s) => sum + s.value, 0)
})
)
return (
<>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
marginBottom: 6
}}
>
<tbody>
<tr>
{props.slicedDaily.map((entry) => (
<td
key={entry.day}
style={{ textAlign: 'center', padding: '0 3px', fontSize: 8, color: '#444' }}
>
{formatTokens(getDailyTotal(entry))}
</td>
))}
</tr>
</tbody>
</table>
<div style={{ height: CHART_H, overflow: 'hidden', marginBottom: 8 }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
height: '100%'
}}
>
<tbody>
<tr>
{props.slicedDaily.map((entry) => {
const segments = getDailySegments(entry)
return (
<td
key={entry.day}
style={{ verticalAlign: 'bottom', textAlign: 'center', padding: '0 3px' }}
>
{segments.map((seg) =>
seg.value > 0 ? (
<div
key={seg.key}
style={{
height: Math.max(1, Math.round((seg.value / maxSegSum) * CHART_H)),
background: seg.color,
marginLeft: '15%',
marginRight: '15%'
}}
/>
) : null
)}
</td>
)
})}
</tr>
</tbody>
</table>
</div>
</>
)
}
function DayLabels(props: { slicedDaily: { day: string }[] }): React.JSX.Element {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<tbody>
<tr>
{props.slicedDaily.map((entry) => (
<td
key={entry.day}
style={{ textAlign: 'center', fontSize: 9, color: '#555', padding: '0 3px' }}
>
{entry.day.slice(5)}
</td>
))}
</tr>
</tbody>
</table>
)
}
function Legend(props: { provider: 'claude' | 'codex' }): React.JSX.Element {
return (
<div style={{ marginTop: 10 }}>
{getLegendItems(props.provider).map((item, i) => (
<span
key={item.label}
style={{
display: 'inline-block',
marginRight: i < 3 ? 12 : 0,
fontSize: 9,
color: '#555',
lineHeight: '14px'
}}
>
<span
style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: item.color,
verticalAlign: 'middle',
marginRight: 5
}}
/>
<span style={{ verticalAlign: 'middle' }}>{item.label}</span>
</span>
))}
</div>
)
}

View file

@ -0,0 +1,209 @@
import type {
ClaudeUsageDailyPoint,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import type { CodexUsageDailyPoint, CodexUsageSummary } from '../../../../shared/codex-usage-types'
export type ClaudeShareData = {
provider: 'claude'
summary: ClaudeUsageSummary
daily: ClaudeUsageDailyPoint[]
}
export type CodexShareData = {
provider: 'codex'
summary: CodexUsageSummary
daily: CodexUsageDailyPoint[]
}
export function formatTokens(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}k`
}
return value.toLocaleString()
}
export function formatCost(value: number | null): string {
if (value === null) {
return 'n/a'
}
return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
}
export function formatDateRange(range: string): string {
const now = new Date()
const end = now.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
if (range === 'all') {
return `Through ${end}`
}
const days = parseInt(range)
if (Number.isNaN(days)) {
return end
}
const start = new Date(now.getTime() - days * 86_400_000)
const startStr = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return `${startStr} ${end}`
}
export const RANGE_LABELS: Record<string, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time'
}
export function getDailyTotal(entry: ClaudeUsageDailyPoint | CodexUsageDailyPoint): number {
if ('cacheReadTokens' in entry) {
return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
}
return entry.totalTokens
}
export type DailySegment = { key: string; value: number; color: string }
export function getDailySegments(
entry: ClaudeUsageDailyPoint | CodexUsageDailyPoint
): DailySegment[] {
// Why: segment order matches the original charts exactly (top-to-bottom).
// Segments render as stacked block divs in a table cell with vertical-align: bottom.
if ('cacheReadTokens' in entry) {
return [
{ key: 'cache-write', value: entry.cacheWriteTokens, color: 'rgba(217, 70, 239, 0.7)' },
{ key: 'cache-read', value: entry.cacheReadTokens, color: 'rgba(251, 191, 36, 0.7)' },
{ key: 'output', value: entry.outputTokens, color: 'rgba(52, 211, 153, 0.8)' },
{ key: 'input', value: entry.inputTokens, color: 'rgba(56, 189, 248, 0.8)' }
]
}
return [
{ key: 'input', value: entry.inputTokens, color: 'rgba(56, 189, 248, 0.8)' },
{ key: 'output', value: entry.outputTokens, color: 'rgba(52, 211, 153, 0.8)' },
{ key: 'cached', value: entry.cachedInputTokens, color: 'rgba(251, 191, 36, 0.7)' },
{ key: 'reasoning', value: entry.reasoningOutputTokens, color: 'rgba(217, 70, 239, 0.7)' }
]
}
export function getLegendItems(provider: 'claude' | 'codex') {
if (provider === 'claude') {
return [
{ label: 'Input', color: 'rgba(56, 189, 248, 0.8)' },
{ label: 'Output', color: 'rgba(52, 211, 153, 0.8)' },
{ label: 'Cache read', color: 'rgba(251, 191, 36, 0.7)' },
{ label: 'Cache write', color: 'rgba(217, 70, 239, 0.7)' }
]
}
return [
{ label: 'Input', color: 'rgba(56, 189, 248, 0.8)' },
{ label: 'Output', color: 'rgba(52, 211, 153, 0.8)' },
{ label: 'Cached input', color: 'rgba(251, 191, 36, 0.7)' },
{ label: 'Reasoning', color: 'rgba(217, 70, 239, 0.7)' }
]
}
export function OrcaLogo(): React.JSX.Element {
return (
<svg
width={26}
height={26}
viewBox="0 0 318.60232 202.66667"
xmlns="http://www.w3.org/2000/svg"
style={{ opacity: 0.9, verticalAlign: 'middle' }}
>
<g style={{ display: 'inline' }} transform="translate(-6.6666669,-70.666669)">
<path
style={{ display: 'inline', fill: '#ffffff' }}
d="m 177.81311,248.33334 c 23.82304,-41.29793 40.54045,-66.84626 49.51207,-75.66667 6.81685,-6.70196 10.07373,-8.7374 20.07265,-12.54475 34.57822,-13.16655 61.04674,-26.78733 72.37222,-37.24295 9.62924,-8.88966 9.34286,-9.01142 -23.43671,-9.964 -35.71756,-1.03796 -43.72989,0.42119 -62.17546,11.323 -16.72118,9.88265 -34.20103,30.11225 -42.74704,49.47157 -2.57353,5.82985 -14.81294,44.3056 -27.96399,87.90747 -2.86036,9.48343 -3.02466,11.71633 -0.86213,11.71633 0.44382,0 7.29659,-11.25 15.22839,-25 z m -65.14644,-8.32267 C 120,239.3326 130.5,237.50979 136,235.95998 c 5.5,-1.5498 12.25,-3.13783 15,-3.52895 2.75,-0.39111 5,-0.95485 5,-1.25275 0,-0.29789 2.15135,-7.58487 4.78078,-16.19328 8.49209,-27.80201 12.21334,-40.41629 21.13747,-71.65166 4.81891,-16.86667 11.23502,-39.185 14.25802,-49.596301 5.12803,-17.66103 5.74763,-23.07037 2.64253,-23.07037 -1.84887,0 -4.07048,6.908293 -16.72243,52.000001 -21.78975,77.65896 -20.80806,74.74393 -26.84794,79.72251 -7.5925,6.25838 -25.03916,14.82524 -36.10856,17.73044 -17.0947,4.48656 -33.410599,3.86724 -53.116765,-2.01622 -18.569242,-5.54403 -23.142662,-5.80284 -33.639754,-1.9037 -5.875424,2.18242 -9.864152,5.04363 -16.716684,11.99127 -4.95,5.0187 -9.0000001,10.02884 -9.0000001,11.13364 0,1.75174 5.9276921,2.00299 46.3333351,1.96383 25.483334,-0.0247 52.333338,-0.59969 59.666668,-1.27777 z M 252.69513,104.63708 c 12.18267,-3.48651 15.77304,-7.895503 9.63821,-11.835773 -10.19296,-6.546726 -36.19849,-1.77301 -41.19436,7.561863 -1.2556,2.3461 -0.98698,3.2037 1.68353,5.375 2.69471,2.19098 4.59991,2.47691 12.53928,1.88189 5.14899,-0.3859 12.94899,-1.72824 17.33334,-2.98298 z"
/>
</g>
</svg>
)
}
export function BackgroundGlows(): React.JSX.Element {
return (
<>
<div
style={{
position: 'absolute',
top: '-60%',
right: '-20%',
width: 300,
height: 300,
background: 'radial-gradient(circle, rgba(20, 71, 230, 0.08) 0%, transparent 70%)',
pointerEvents: 'none'
}}
/>
<div
style={{
position: 'absolute',
bottom: '-40%',
left: '-10%',
width: 250,
height: 250,
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.05) 0%, transparent 70%)',
pointerEvents: 'none'
}}
/>
</>
)
}
export function CardFooter(props: {
summary: { inputTokens: number; outputTokens: number }
}): React.JSX.Element {
return (
<div
style={{
display: 'table',
width: '100%',
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
position: 'relative',
zIndex: 1
}}
>
<div style={{ display: 'table-cell', verticalAlign: 'middle' }}>
<span style={{ fontSize: 12, color: '#888' }}>
<strong style={{ color: '#ccc' }}>{formatTokens(props.summary.inputTokens)}</strong> input
</span>
<span style={{ fontSize: 12, color: '#888', marginLeft: 16 }}>
<strong style={{ color: '#ccc' }}>{formatTokens(props.summary.outputTokens)}</strong>{' '}
output
</span>
</div>
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<GitHubIcon />
</span>
<span
style={{
fontSize: 11,
color: '#888',
letterSpacing: 0.2,
verticalAlign: 'middle',
marginLeft: 5
}}
>
github.com/stablyai/orca
</span>
</div>
</div>
)
}
function GitHubIcon(): React.JSX.Element {
return (
<svg
width={13}
height={13}
viewBox="0 0 16 16"
fill="#888"
style={{ opacity: 0.6, verticalAlign: 'middle' }}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show more