From 1c9438bb9d32e28281d1fcda96923d652edaf596 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 11:42:12 -0700 Subject: [PATCH 1/7] Rename standalone browser dev task to innerdogfood --- .claude/skills/debug-standalone-agent-browser/SKILL.md | 6 +++--- AGENTS.md | 9 +++++++++ docs/specs/standalone.md | 2 +- docs/specs/transport.md | 2 +- lib/src/lib/mirrored-constants.test.ts | 2 +- package.json | 2 +- scripts/pairing-walkthrough/README.md | 4 ++-- scripts/pairing-walkthrough/ab.mjs | 2 +- scripts/pairing-walkthrough/proc.mjs | 2 +- scripts/pairing-walkthrough/steps.mjs | 6 +++--- standalone/package.json | 2 +- standalone/scripts/dev-agent-browser.mjs | 2 +- 12 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.claude/skills/debug-standalone-agent-browser/SKILL.md b/.claude/skills/debug-standalone-agent-browser/SKILL.md index 55176113f..45ec1c7ec 100644 --- a/.claude/skills/debug-standalone-agent-browser/SKILL.md +++ b/.claude/skills/debug-standalone-agent-browser/SKILL.md @@ -1,6 +1,6 @@ --- name: debug-standalone-agent-browser -description: Use when debugging Dormouse standalone behavior through the browser-based agent-browser harness instead of Tauri. Covers launching a fresh `pnpm dev:standalone:ab` run, observing sidecar and in-browser logs together, driving the UI with `agent-browser`, clearing stale nested browser sessions, and timing agent-browser screencast/tab behavior. +description: Use when debugging Dormouse standalone behavior through the browser-based agent-browser harness instead of Tauri. Covers launching a fresh `pnpm innerdogfood` run, observing sidecar and in-browser logs together, driving the UI with `agent-browser`, clearing stale nested browser sessions, and timing agent-browser screencast/tab behavior. --- # Debug Standalone With Agent Browser @@ -15,7 +15,7 @@ Run from the repo root: DORMOUSE_BROWSER_DEV_AB_SESSION=dormouse-debug-$(date +%s) \ DORMOUSE_BROWSER_DEV_VITE_PORT=1550 \ DORMOUSE_BROWSER_DEV_HOST_PORT=1552 \ -pnpm dev:standalone:ab +pnpm innerdogfood ``` The harness: @@ -59,7 +59,7 @@ Stop any running harness with Ctrl-C (or `pkill -f dev-agent-browser.mjs`) befor ## Driving Dormouse -Use the outer harness session printed by `dev:standalone:ab`: +Use the outer harness session printed by `innerdogfood`: ```sh agent-browser --session snapshot -i diff --git a/AGENTS.md b/AGENTS.md index 51f339e03..541510db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,15 @@ pnpm install # install deps pnpm build # build lib, vscode extension, Pocket, and website ``` +**For `innerdogfood` inside Dormouse, run:** + +```sh +dor ensure -- pnpm innerdogfood +dor ab --key innerdogfood open http://localhost:1420 +``` + +See `docs/specs/transport.md` → "Standalone browser-dev harness". + **Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so marking a PR ready for review is what spends them. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 6ea823831..1fd577409 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -538,7 +538,7 @@ Source of truth: `standalone/package.json` (package scripts), Frontend edits hot-reload, but changes to the sidecar, the staged dor CLI, or the bundled `lib/src/host/` sources need a manual re-stage and app restart — the dev loop does not watch them. -- `pnpm dev:standalone:ab` runs the sidecar + webview in a normal browser via the +- `pnpm innerdogfood` runs the sidecar + webview in a normal browser via the browser-dev harness instead of the Tauri WebView (`docs/specs/transport.md`, Standalone browser-dev harness). diff --git a/docs/specs/transport.md b/docs/specs/transport.md index bb00c2b30..ab26e160d 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -26,7 +26,7 @@ Optional booleans: ### Standalone browser-dev harness -`pnpm dev:standalone:ab` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present. +`pnpm innerdogfood` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present. The bridge is a transport shim over the same sidecar protocol, not a second PTY implementation: fire-and-forget commands `POST /__dormouse_dev_host/send`, request/response commands `POST /__dormouse_dev_host/invoke`, host→webview events as SSE on `GET /__dormouse_dev_host/events`, and browser console output mirrored to `POST /__dormouse_dev_host/console` so one terminal shows sidecar, Vite, and in-browser logs together. The Burrow rides it too, on the message names below ("Message protocol"), so the harness runs a real Burrow against a per-run temp state directory (`docs/specs/standalone.md` → "Burrow service"). diff --git a/lib/src/lib/mirrored-constants.test.ts b/lib/src/lib/mirrored-constants.test.ts index c422b832d..531a77b8c 100644 --- a/lib/src/lib/mirrored-constants.test.ts +++ b/lib/src/lib/mirrored-constants.test.ts @@ -141,7 +141,7 @@ describe('the pairing walkthrough mirrors the copy it clicks', () => { // Not copy but a log line: the harness prints where the Burrow keeps its // state and the walkthrough parses it back out. Rendered with a stand-in path // rather than compared as text, so the two only have to agree on what the - // line looks like — the `[dev:standalone:ab]` prefix `log()` adds is free to + // line looks like — the `[innerdogfood]` prefix `log()` adds is free to // change, and the capture group has to survive. it('parses the state directory the harness actually logs', () => { const harness = 'standalone/scripts/dev-agent-browser.mjs'; diff --git a/package.json b/package.json index 904f8071d..54d4f82a6 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev:canopy": "pnpm --filter canopy storybook", "dev:lib": "pnpm --filter dormouse-lib dev", "dev:standalone": "node scripts/free-dev-port.mjs && pnpm --filter dormouse-standalone tauri dev", - "dev:standalone:ab": "pnpm --filter dormouse-standalone dev:agent-browser", + "innerdogfood": "pnpm --filter dormouse-standalone innerdogfood", "dev:relay": "pnpm --filter dormouse-lib build:pocket && pnpm --filter relay dev", "dev:website": "pnpm --filter dormouse-website dev", "build:vscode": "pnpm --filter dormouse-lib build && pnpm --filter dormouse build:frontend && pnpm --filter dormouse build", diff --git a/scripts/pairing-walkthrough/README.md b/scripts/pairing-walkthrough/README.md index dd14bb421..ef51826fd 100644 --- a/scripts/pairing-walkthrough/README.md +++ b/scripts/pairing-walkthrough/README.md @@ -75,7 +75,7 @@ it. | # | Step | What happens | | --- | --- | --- | | 1 | `relay` | `pnpm dev:relay` with an isolated `DORMOUSE_STATE_DIR`, then waits for `:3000` to answer. | -| 2 | `burrow` | `pnpm dev:standalone:ab` with `DORMOUSE_REMOTE_CONNECT_SRC` pointed at that Relay, then waits for the app's first terminal. → `01-burrow-booted.png` | +| 2 | `burrow` | `pnpm innerdogfood` with `DORMOUSE_REMOTE_CONNECT_SRC` pointed at that Relay, then waits for the app's first terminal. → `01-burrow-booted.png` | | 3 | `settings` | Clicks the baseboard's Settings button and scrolls to Remote control. → `02-settings-open.png` | | 4 | `enroll` | Types the Relay URL, the setup password and the machine name into the real form, submits, and waits for **Connected**. → `03-enroll-form.png`, `04-enrolled.png` | | 5 | `qr` | Clicks **Set up a phone**, waits for the code, screenshots, crops to the QR, makes a camera-shaped Y4M, and decodes the crop to prove it is legible. → `qr-full.png`, `qr.png`, `qr.y4m`, `invitation-url.txt` | @@ -125,7 +125,7 @@ at the end. **Nothing is written into the repo.** ``` relay.log the Relay's whole stdout/stderr -burrow.log the dev:standalone:ab harness, sidecar and Vite +burrow.log the innerdogfood harness, sidecar and Vite 01-burrow-booted.png … one screenshot per UI step qr-full.png the Burrow webview at the moment the QR was measured qr.png the QR alone, cropped with a little padding diff --git a/scripts/pairing-walkthrough/ab.mjs b/scripts/pairing-walkthrough/ab.mjs index 9b9cdc0b0..4d6bba54f 100644 --- a/scripts/pairing-walkthrough/ab.mjs +++ b/scripts/pairing-walkthrough/ab.mjs @@ -3,7 +3,7 @@ * (`scripts/pairing-walkthrough/README.md`). * * One instance is one `--session`, which is one isolated browser. The Burrow runs - * in the session the `dev:standalone:ab` harness opened; the Pocket browser is a + * in the session the `innerdogfood` harness opened; the Pocket browser is a * second instance with its own session name, which is why this is a class rather * than a module of free functions. */ diff --git a/scripts/pairing-walkthrough/proc.mjs b/scripts/pairing-walkthrough/proc.mjs index 62e90d539..08c18a6cd 100644 --- a/scripts/pairing-walkthrough/proc.mjs +++ b/scripts/pairing-walkthrough/proc.mjs @@ -27,7 +27,7 @@ const started = []; * `logPath`, and hand back a handle whose `lines` the caller can poll. * * **Its own process group, always.** `pnpm dev:relay` and - * `pnpm dev:standalone:ab` each fan out into a tree (pnpm → node → vite → + * `pnpm innerdogfood` each fan out into a tree (pnpm → node → vite → * esbuild), and killing only the pnpm shim orphans everything under it. * `detached: true` plus a `process.kill(-pid)` at teardown takes the group. */ diff --git a/scripts/pairing-walkthrough/steps.mjs b/scripts/pairing-walkthrough/steps.mjs index f45ee719f..7cccf64ac 100644 --- a/scripts/pairing-walkthrough/steps.mjs +++ b/scripts/pairing-walkthrough/steps.mjs @@ -38,7 +38,7 @@ const SCAN_LABEL = 'Scan a setup code'; /** * The harness line that says where the Burrow keeps its enrollment + ACL. The - * `[dev:standalone:ab]` prefix `log()` adds is deliberately not matched: it is + * `[innerdogfood]` prefix `log()` adds is deliberately not matched: it is * there for a human reading interleaved output, so pinning it would make a * cosmetic log change break this run. * @@ -273,7 +273,7 @@ async function stepRelay(ctx) { } /** - * Boot the real Burrow in the `dev:standalone:ab` harness and wait for the app. + * Boot the real Burrow in the `innerdogfood` harness and wait for the app. * * `DORMOUSE_REMOTE_CONNECT_SRC` has to be set *here*, at launch, not later: the * harness re-runs `pnpm stage` on the way up, which is what bakes the allowed @@ -282,7 +282,7 @@ async function stepRelay(ctx) { */ async function stepBurrow(ctx) { const { repoRoot, opts } = ctx; - const handle = spawnLogged('pnpm', ['dev:standalone:ab'], { + const handle = spawnLogged('pnpm', ['innerdogfood'], { cwd: repoRoot, logPath: ctx.path('burrow.log'), prefix: 'burrow', diff --git a/standalone/package.json b/standalone/package.json index 350c2e851..53baf6395 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "dev:agent-browser": "pnpm run stage && node scripts/dev-agent-browser.mjs", + "innerdogfood": "pnpm run stage && node scripts/dev-agent-browser.mjs", "prebuild": "pnpm --filter remote-lib-common build", "build": "pnpm run stage && tsc -b && vite build", "stage": "pnpm run stage:dor-cli && pnpm run stage:sidecar-proxy", diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 8f1af50e0..d7d0a89bf 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -57,7 +57,7 @@ let shuttingDown = false; let requestSeq = 0; function log(message) { - console.error(`[dev:standalone:ab] ${message}`); + console.error(`[innerdogfood] ${message}`); } function sendSse(res, event, data) { From 420bfb2aa285a51c97bd0cc04aa14f3a35516553 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 11:59:17 -0700 Subject: [PATCH 2/7] Fix terminal hyperlink clicks by deferring pointer capture --- docs/specs/mouse-and-clipboard.md | 1 + docs/specs/mouse-and-clipboard.rationale.md | 4 ++- lib/src/lib/terminal-mouse-router.test.ts | 25 ++++++++++++++--- lib/src/lib/terminal-mouse-router.ts | 30 +++++++++++---------- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 1e296070d..f8a768829 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -62,6 +62,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection +- **Must capture mouse pointers only after the drag threshold** (rationale). Test: `lib/src/lib/terminal-mouse-router.test.ts`. - A click-and-drag in the terminal content area begins a selection; a ~4px movement threshold separates a plain click (which only shifts pane focus) from a drag. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 5491d2863..9d62017df 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -4,7 +4,9 @@ ## 3.1 Initiating a Selection -**How a mouse-up outside the iframe still reaches us.** Capture is taken on mouse-down, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. +**Why plain clicks stay uncaptured.** Reproduced with Codex CLI 0.153.4 in the Chromium innerdogfood harness (2026-09): Codex emitted valid OSC 8 links and xterm recognized their targets. Capturing on Dormouse's wrapper at pointerdown retargeted mouseup outside xterm's screen and cleared its hovered link, preventing activation. Leaving the click uncaptured restored the confirmation dialog. + +**How a mouse-up outside the iframe still reaches us.** Capture is taken when movement crosses the selection-drag threshold, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. ## 5.1 Detection diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index aa16464eb..c93f7cb8a 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -350,19 +350,38 @@ describe('terminal-mouse-router: override suppression', () => { } // Drives a left-button mouse press into an active selection drag (capture + - // pendingDrag created on press, drag begun once movement crosses threshold). + // pendingDrag created on press, capture taken once movement crosses threshold). function startMouseDrag(element: FakeElement) { element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); element.emit('mousedown', mouseEvent({ clientX: 5, clientY: 5 })); windowHost.emit('mousemove', mouseEvent({ clientX: 25, clientY: 15 })); } - it('captures the mouse pointer on a left-button press over terminal-owned content', () => { + it('leaves a plain click uncaptured and unsuppressed so xterm can activate hyperlinks', () => { const { cleanup, element } = createHarness(windowHost); + const down = mouseEvent(); + const up = mouseEvent(); - element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); + element.emit('pointerdown', mousePointer()); + element.emit('mousedown', down); + windowHost.emit('mousemove', mouseEvent({ clientX: 6, buttons: 1 })); + windowHost.emit('pointerup', mousePointer({ clientX: 6 })); + windowHost.emit('mouseup', up); + + expect(element.setPointerCapture).not.toHaveBeenCalled(); + expect(down.stopPropagation).not.toHaveBeenCalled(); + expect(up.stopPropagation).not.toHaveBeenCalled(); + expect(getMouseSelectionState('t1').selection).toBeNull(); + cleanup(); + }); + + it('captures the mouse pointer once movement begins a selection drag', () => { + const { cleanup, element } = createHarness(windowHost); + + startMouseDrag(element); expect(element.setPointerCapture).toHaveBeenCalledWith(1); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); cleanup(); }); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index cd3bc5dd5..2f93dab8b 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -96,6 +96,7 @@ export function attachTerminalMouseRouter({ // released over the host page, which lets us finalize an outside release at // once instead of waiting for the window-mousemove heal. let mouseDragPointerId: number | null = null; + let mousePressPointerId: number | null = null; // True between a captured mouse pointerup we saw and the compatibility mouseup // we expect to follow it for an *inside* release; see onWindowPointerUp. let awaitingOutsideMouseUp = false; @@ -154,6 +155,18 @@ export function attachTerminalMouseRouter({ const dx = ev.clientX - pendingDrag.clientX; const dy = ev.clientY - pendingDrag.clientY; if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; + // Capture only once this is a selection drag. Capturing the wrapper on + // pointerdown retargets a plain click's mouseup outside xterm's screen, + // so its OSC 8 link handler never sees the release. + if (!pendingDrag.touchLike && mousePressPointerId !== null) { + try { + element.setPointerCapture(mousePressPointerId); + mouseDragPointerId = mousePressPointerId; + } catch { + // The window-mousemove heal still covers an outside release. + mouseDragPointerId = null; + } + } // Touch has no Alt to read mid-drag, so its double-tap block mode latches // for the whole drag; desktop Alt stays live (see onAltChange). A tap can // no longer chain into the next press once a drag has begun. @@ -221,23 +234,10 @@ export function attachTerminalMouseRouter({ const onPointerDown = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { - // Capture the mouse pointer for left-button presses on terminal-owned - // content so a selection drag released *outside* our iframe still reports - // back. Chromium delivers the captured pointerup across the frame boundary - // even when the button comes up over the host page, letting onWindowPointerUp - // finalize the drag immediately rather than waiting for the cursor to wander - // back in (the window-mousemove heal). Engines that don't honor cross-frame - // capture get no such pointerup and fall back to that heal. if (ev.button !== 0) return; const { terminalOwns } = terminalOwnsEvent(ev); if (!terminalOwns) return; - try { - element.setPointerCapture(ev.pointerId); - mouseDragPointerId = ev.pointerId; - } catch { - // Best-effort continuity aid; the heal still covers us if capture is rejected. - mouseDragPointerId = null; - } + mousePressPointerId = ev.pointerId; return; } if (!ev.isPrimary) return; @@ -291,6 +291,7 @@ export function attachTerminalMouseRouter({ // The button came up inside the iframe; cancel any pending outside-release // finalize (see onWindowPointerUp) and end the drag through the normal path. awaitingOutsideMouseUp = false; + mousePressPointerId = null; finishPendingOrActiveDrag(ev); }; @@ -302,6 +303,7 @@ export function attachTerminalMouseRouter({ const onWindowPointerUp = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { + if (mousePressPointerId === ev.pointerId) mousePressPointerId = null; if (mouseDragPointerId !== ev.pointerId) return; mouseDragPointerId = null; // Capture auto-releases on pointerup, but be explicit. From ea291b4dd997ea19727c398dbba03eed279a5b7b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:08:26 -0700 Subject: [PATCH 3/7] Simplify the deferred pointer capture and its docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the pendingDrag.pointerId field that already existed for this and was written but never read, instead of a second module-level pointer id with two hand-written reset sites — pendingDrag is nulled on every finalize path, so it already owns the lifetime. Drop the terminalOwnsEvent probe from onPointerDown: it now guards only a scalar stash, while the capture it used to gate is gated by pendingDrag, which beginPendingDrag creates only when the terminal owns the press. That removes a second getBoundingClientRect per left-button press. Repoint the window-mousemove backstop comment at the capture's new home, fix the test helper comment that claimed capture in two places, and drive the non-left-button test past the drag threshold — with no press capturing any more, it no longer distinguished anything. Fold the new spec rule into the bullet that defines the threshold it cites, put the AGENTS.md innerdogfood note in house form, and widen the standalone.md `dev:standalone*` pointer that the rename left behind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MDoTLwhYDdKqux4t1V9mqj --- AGENTS.md | 11 ++------ docs/specs/mouse-and-clipboard.md | 3 +- docs/specs/standalone.md | 2 +- lib/src/lib/terminal-mouse-router.test.ts | 8 ++++-- lib/src/lib/terminal-mouse-router.ts | 34 +++++++++++++---------- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 541510db9..e4ef2038a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,9 @@ pnpm install # install deps pnpm build # build lib, vscode extension, Pocket, and website ``` -**For `innerdogfood` inside Dormouse, run:** - -```sh -dor ensure -- pnpm innerdogfood -dor ab --key innerdogfood open http://localhost:1420 -``` - -See `docs/specs/transport.md` → "Standalone browser-dev harness". +**Run `innerdogfood` in visible panes** — `dor ensure -- pnpm innerdogfood`, then +`dor ab --key innerdogfood open http://localhost:1420` +(`docs/specs/transport.md` → "Standalone browser-dev harness"). **Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so marking a PR ready for review is what spends them. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index f8a768829..9385fa0a5 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -62,8 +62,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection -- **Must capture mouse pointers only after the drag threshold** (rationale). Test: `lib/src/lib/terminal-mouse-router.test.ts`. -- A click-and-drag in the terminal content area begins a selection; a ~4px movement threshold separates a plain click (which only shifts pane focus) from a drag. +- A click-and-drag in the terminal content area begins a selection; a ~4px movement threshold separates a plain click (which only shifts pane focus) from a drag. **The mouse pointer must not be captured until that threshold is crossed** (rationale). - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. - **A drag whose button comes up outside the webview iframe must still finalize**, by captured `pointerup` or the window-`mousemove` backstop (rationale). diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 1fd577409..b4e7bf4de 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -519,7 +519,7 @@ Source of truth: `init_log` / `read_update_log` in `standalone/src-tauri/src/lib Source of truth: `standalone/package.json` (package scripts), `standalone/src-tauri/tauri.conf.json` (`build`, `bundle.resources`), and the root -`package.json` for the `dev:standalone*` orchestration. +`package.json` for the `dev:standalone` and `innerdogfood` orchestration. - `stage` = `stage:dor-cli` (build + stage the dor CLI, `docs/specs/dor-cli.md`) plus `stage:sidecar-proxy` (`build-sidecar-proxy.mjs` bundles the diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index c93f7cb8a..7e6654cd1 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -349,8 +349,8 @@ describe('terminal-mouse-router: override suppression', () => { return pointerEvent({ pointerType: 'mouse', ...overrides }); } - // Drives a left-button mouse press into an active selection drag (capture + - // pendingDrag created on press, capture taken once movement crosses threshold). + // Drives a left-button mouse press into an active selection drag (pendingDrag + // created on press; capture and drag both begin once movement crosses threshold). function startMouseDrag(element: FakeElement) { element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); element.emit('mousedown', mouseEvent({ clientX: 5, clientY: 5 })); @@ -388,7 +388,9 @@ describe('terminal-mouse-router: override suppression', () => { it('does not capture the mouse pointer for non-left buttons', () => { const { cleanup, element } = createHarness(windowHost); - element.emit('pointerdown', mousePointer({ button: 2 })); + element.emit('pointerdown', mousePointer({ button: 2, clientX: 5, clientY: 5 })); + element.emit('mousedown', mouseEvent({ button: 2, clientX: 5, clientY: 5 })); + windowHost.emit('mousemove', mouseEvent({ button: 2, clientX: 25, clientY: 15 })); expect(element.setPointerCapture).not.toHaveBeenCalled(); cleanup(); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 2f93dab8b..2e7a4b74d 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -96,6 +96,9 @@ export function attachTerminalMouseRouter({ // released over the host page, which lets us finalize an outside release at // once instead of waiting for the window-mousemove heal. let mouseDragPointerId: number | null = null; + // The id of the latest mouse pointerdown, bridged to the compatibility mousedown + // that follows it: only pointerdown carries a pointer id, and the capture below + // happens on a window mousemove, which has none. let mousePressPointerId: number | null = null; // True between a captured mouse pointerup we saw and the compatibility mouseup // we expect to follow it for an *inside* release; see onWindowPointerUp. @@ -158,10 +161,11 @@ export function attachTerminalMouseRouter({ // Capture only once this is a selection drag. Capturing the wrapper on // pointerdown retargets a plain click's mouseup outside xterm's screen, // so its OSC 8 link handler never sees the release. - if (!pendingDrag.touchLike && mousePressPointerId !== null) { + if (!pendingDrag.touchLike && pendingDrag.pointerId !== null) { + const pressPointerId = pendingDrag.pointerId; try { - element.setPointerCapture(mousePressPointerId); - mouseDragPointerId = mousePressPointerId; + element.setPointerCapture(pressPointerId); + mouseDragPointerId = pressPointerId; } catch { // The window-mousemove heal still covers an outside release. mouseDragPointerId = null; @@ -229,14 +233,15 @@ export function attachTerminalMouseRouter({ consumePointerEvent(ev, true); return; } - beginPendingDrag(ev, { pointerId: null, touchLike: false }); + beginPendingDrag(ev, { pointerId: mousePressPointerId, touchLike: false }); }; const onPointerDown = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { if (ev.button !== 0) return; - const { terminalOwns } = terminalOwnsEvent(ev); - if (!terminalOwns) return; + // Only stash the id. Whether the terminal owns the press is decided by the + // mousedown that follows, which is what creates pendingDrag — and without a + // pendingDrag the id is never read. mousePressPointerId = ev.pointerId; return; } @@ -273,13 +278,14 @@ export function attachTerminalMouseRouter({ const onWindowMouseMove = (ev: MouseEvent) => { // Backstop for engines that don't deliver a cross-frame captured pointerup - // (see onPointerDown). A mouse drag is otherwise kept alive only by the - // window 'mouseup' below, and when the button is released outside our iframe - // that mouseup is delivered to the host document and never reaches us, - // leaving the drag stuck. The next move we see (e.g. when the pointer - // re-enters) reports no buttons held — treat that as the mouseup we missed - // and finalize the drag in place. A genuine drag that leaves and re-enters - // still holding the button reports buttons===1, so this never fires mid-drag. + // (see the capture in updatePendingOrActiveDrag). A mouse drag is otherwise + // kept alive only by the window 'mouseup' below, and when the button is + // released outside our iframe that mouseup goes to the host document and + // never reaches us, leaving the drag stuck. The next move we see (e.g. when + // the pointer re-enters) reports no buttons held — treat that as the mouseup + // we missed and finalize the drag in place. A genuine drag that leaves and + // re-enters still holding the button reports buttons===1, so this never + // fires mid-drag. if (ev.buttons === 0 && (pendingDrag || isDragging(id))) { finishPendingOrActiveDrag(ev); return; @@ -291,7 +297,6 @@ export function attachTerminalMouseRouter({ // The button came up inside the iframe; cancel any pending outside-release // finalize (see onWindowPointerUp) and end the drag through the normal path. awaitingOutsideMouseUp = false; - mousePressPointerId = null; finishPendingOrActiveDrag(ev); }; @@ -303,7 +308,6 @@ export function attachTerminalMouseRouter({ const onWindowPointerUp = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { - if (mousePressPointerId === ev.pointerId) mousePressPointerId = null; if (mouseDragPointerId !== ev.pointerId) return; mouseDragPointerId = null; // Capture auto-releases on pointerup, but be explicit. From bdc3b97ff89e6193f124102e05f0b3f5511d5256 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:10:14 -0700 Subject: [PATCH 4/7] Preserve scope and regression reference in testing guidance --- AGENTS.md | 2 +- docs/specs/mouse-and-clipboard.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4ef2038a..06e574abb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ pnpm install # install deps pnpm build # build lib, vscode extension, Pocket, and website ``` -**Run `innerdogfood` in visible panes** — `dor ensure -- pnpm innerdogfood`, then +**Inside Dormouse, run `innerdogfood`** — `dor ensure -- pnpm innerdogfood`, then `dor ab --key innerdogfood open http://localhost:1420` (`docs/specs/transport.md` → "Standalone browser-dev harness"). diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 9385fa0a5..2ea57437e 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -62,7 +62,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection -- A click-and-drag in the terminal content area begins a selection; a ~4px movement threshold separates a plain click (which only shifts pane focus) from a drag. **The mouse pointer must not be captured until that threshold is crossed** (rationale). +- **Must capture mouse pointers only after movement crosses the ~4px selection-drag threshold** (rationale). Plain clicks retain pane focus and hyperlink activation. Test: `lib/src/lib/terminal-mouse-router.test.ts`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. - **A drag whose button comes up outside the webview iframe must still finalize**, by captured `pointerup` or the window-`mousemove` backstop (rationale). From 3e1a4bf7528df69b2713e52e69ad54120f51eed8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:24:21 -0700 Subject: [PATCH 5/7] Capture mouse presses on xterm screen to preserve edge drags --- docs/specs/mouse-and-clipboard.md | 2 +- docs/specs/mouse-and-clipboard.rationale.md | 4 +- lib/src/lib/terminal-mouse-router.test.ts | 32 ++++++++++----- lib/src/lib/terminal-mouse-router.ts | 45 ++++++++------------- 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 2ea57437e..0fb23faa2 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -62,7 +62,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection -- **Must capture mouse pointers only after movement crosses the ~4px selection-drag threshold** (rationale). Plain clicks retain pane focus and hyperlink activation. Test: `lib/src/lib/terminal-mouse-router.test.ts`. +- **Must begin selection after a click-and-drag crosses ~4px**; plain clicks shift pane focus or activate hyperlinks. **Must capture mouse presses on xterm’s screen immediately** (rationale). Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. - **A drag whose button comes up outside the webview iframe must still finalize**, by captured `pointerup` or the window-`mousemove` backstop (rationale). diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 9d62017df..51279d5bd 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -4,9 +4,9 @@ ## 3.1 Initiating a Selection -**Why plain clicks stay uncaptured.** Reproduced with Codex CLI 0.153.4 in the Chromium innerdogfood harness (2026-09): Codex emitted valid OSC 8 links and xterm recognized their targets. Capturing on Dormouse's wrapper at pointerdown retargeted mouseup outside xterm's screen and cleared its hovered link, preventing activation. Leaving the click uncaptured restored the confirmation dialog. +**Why mouse capture targets xterm’s screen.** Reproduced with Codex CLI 0.153.4 in the Chromium innerdogfood harness (2026-09): Codex emitted valid OSC 8 links and xterm recognized their targets. Capturing on Dormouse's wrapper at pointerdown retargeted mouseup outside xterm's screen and cleared its hovered link, preventing activation. Capturing on xterm’s screen keeps the release on the link-handler path. Deferring capture until the drag threshold restored clicks too, but lost a selection whose first coalesced movement left the iframe; early screen capture preserves both behaviors. An innerdogfood iframe probe starting 2 px inside the top edge and moving directly to −30 px delivered the move and release to `.xterm-screen` and finalized the selection (Chromium, 2026-09). -**How a mouse-up outside the iframe still reaches us.** Capture is taken when movement crosses the selection-drag threshold, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. +**How a mouse-up outside the iframe still reaches us.** Capture is taken on mouse-down, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. ## 5.1 Detection diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 7e6654cd1..d94ef420b 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -34,6 +34,7 @@ class ListenerHost { class FakeElement extends ListenerHost { setPointerCapture = vi.fn(); releasePointerCapture = vi.fn(); + querySelector = vi.fn(); getBoundingClientRect(): Pick { return { left: 0, top: 0 }; @@ -86,6 +87,8 @@ const dims: TerminalOverlayDims = { function createHarness(windowHost: ListenerHost) { const element = new FakeElement(); + const screen = new FakeElement(); + element.querySelector.mockReturnValue(screen); const terminal = { cols: 80, clearSelection: vi.fn(), @@ -103,7 +106,7 @@ function createHarness(windowHost: ListenerHost) { getOverlayDims: () => dims, setSelectionBaseline: vi.fn(), }); - return { cleanup, element, terminal, windowHost }; + return { cleanup, element, screen, terminal, windowHost }; } let windowHost: ListenerHost; @@ -350,15 +353,15 @@ describe('terminal-mouse-router: override suppression', () => { } // Drives a left-button mouse press into an active selection drag (pendingDrag - // created on press; capture and drag both begin once movement crosses threshold). + // and screen capture created on press; drag begins once movement crosses threshold). function startMouseDrag(element: FakeElement) { element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); element.emit('mousedown', mouseEvent({ clientX: 5, clientY: 5 })); windowHost.emit('mousemove', mouseEvent({ clientX: 25, clientY: 15 })); } - it('leaves a plain click uncaptured and unsuppressed so xterm can activate hyperlinks', () => { - const { cleanup, element } = createHarness(windowHost); + it('captures a plain click on xterm’s screen without suppressing its hyperlink events', () => { + const { cleanup, element, screen } = createHarness(windowHost); const down = mouseEvent(); const up = mouseEvent(); @@ -369,36 +372,43 @@ describe('terminal-mouse-router: override suppression', () => { windowHost.emit('mouseup', up); expect(element.setPointerCapture).not.toHaveBeenCalled(); + expect(element.querySelector).toHaveBeenCalledWith('.xterm-screen'); + expect(screen.setPointerCapture).toHaveBeenCalledWith(1); + expect(screen.releasePointerCapture).toHaveBeenCalledWith(1); expect(down.stopPropagation).not.toHaveBeenCalled(); expect(up.stopPropagation).not.toHaveBeenCalled(); expect(getMouseSelectionState('t1').selection).toBeNull(); cleanup(); }); - it('captures the mouse pointer once movement begins a selection drag', () => { - const { cleanup, element } = createHarness(windowHost); + it('captures before the first move so a drag can begin outside the iframe', () => { + const { cleanup, element, screen } = createHarness(windowHost); - startMouseDrag(element); + element.emit('pointerdown', mousePointer({ clientY: 2 })); + element.emit('mousedown', mouseEvent({ clientY: 2 })); + expect(screen.setPointerCapture).toHaveBeenCalledWith(1); + expect(getMouseSelectionState('t1').selection).toBeNull(); - expect(element.setPointerCapture).toHaveBeenCalledWith(1); + windowHost.emit('mousemove', mouseEvent({ clientY: -20, buttons: 1 })); expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); cleanup(); }); it('does not capture the mouse pointer for non-left buttons', () => { - const { cleanup, element } = createHarness(windowHost); + const { cleanup, element, screen } = createHarness(windowHost); element.emit('pointerdown', mousePointer({ button: 2, clientX: 5, clientY: 5 })); element.emit('mousedown', mouseEvent({ button: 2, clientX: 5, clientY: 5 })); windowHost.emit('mousemove', mouseEvent({ button: 2, clientX: 25, clientY: 15 })); expect(element.setPointerCapture).not.toHaveBeenCalled(); + expect(screen.setPointerCapture).not.toHaveBeenCalled(); cleanup(); }); it('finalizes a mouse drag from a captured pointerup when the button is released outside the iframe', () => { vi.useFakeTimers(); - const { cleanup, element } = createHarness(windowHost); + const { cleanup, element, screen } = createHarness(windowHost); startMouseDrag(element); expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); @@ -406,7 +416,7 @@ describe('terminal-mouse-router: override suppression', () => { // Released outside the iframe: only the captured pointerup reaches us, never // the compatibility mouseup. The deferred finalize ends the drag in place. windowHost.emit('pointerup', mousePointer({ clientX: 500, clientY: 500 })); - expect(element.releasePointerCapture).toHaveBeenCalledWith(1); + expect(screen.releasePointerCapture).toHaveBeenCalledWith(1); expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); vi.runAllTimers(); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 2e7a4b74d..6965c947a 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -67,6 +67,10 @@ export function attachTerminalMouseRouter({ return { row: absRow, col, startedInScrollback }; }; + // xterm's linkifier listens on its screen, not our outer wrapper. Capture + // there so even a plain click's retargeted mouseup reaches the link handler. + // Take capture on pointerdown: the first move may already be outside the iframe. + const mouseCaptureElement = element.querySelector('.xterm-screen') ?? element; const DRAG_THRESHOLD_PX_SQ = 16; // Touch has no Alt key, so a double-tap-then-drag is how a block selection is // started on touch. A second touch within this window and distance of the @@ -82,7 +86,6 @@ export function attachTerminalMouseRouter({ button: number; clientX: number; clientY: number; - pointerId: number | null; touchLike: boolean; } | null = null; let activePointerId: number | null = null; @@ -96,10 +99,6 @@ export function attachTerminalMouseRouter({ // released over the host page, which lets us finalize an outside release at // once instead of waiting for the window-mousemove heal. let mouseDragPointerId: number | null = null; - // The id of the latest mouse pointerdown, bridged to the compatibility mousedown - // that follows it: only pointerdown carries a pointer id, and the capture below - // happens on a window mousemove, which has none. - let mousePressPointerId: number | null = null; // True between a captured mouse pointerup we saw and the compatibility mouseup // we expect to follow it for an *inside* release; see onWindowPointerUp. let awaitingOutsideMouseUp = false; @@ -116,7 +115,7 @@ export function attachTerminalMouseRouter({ const beginPendingDrag = ( ev: MouseEvent | PointerEvent, - opts: { pointerId: number | null; touchLike: boolean; block?: boolean }, + opts: { touchLike: boolean; block?: boolean }, ) => { const { state, cell, terminalOwns } = terminalOwnsEvent(ev); // Touch suppresses compatibility mousedown, so popup's mouse listener @@ -140,7 +139,6 @@ export function attachTerminalMouseRouter({ button: ev.button, clientX: ev.clientX, clientY: ev.clientY, - pointerId: opts.pointerId, touchLike: opts.touchLike, }; return true; @@ -158,19 +156,6 @@ export function attachTerminalMouseRouter({ const dx = ev.clientX - pendingDrag.clientX; const dy = ev.clientY - pendingDrag.clientY; if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; - // Capture only once this is a selection drag. Capturing the wrapper on - // pointerdown retargets a plain click's mouseup outside xterm's screen, - // so its OSC 8 link handler never sees the release. - if (!pendingDrag.touchLike && pendingDrag.pointerId !== null) { - const pressPointerId = pendingDrag.pointerId; - try { - element.setPointerCapture(pressPointerId); - mouseDragPointerId = pressPointerId; - } catch { - // The window-mousemove heal still covers an outside release. - mouseDragPointerId = null; - } - } // Touch has no Alt to read mid-drag, so its double-tap block mode latches // for the whole drag; desktop Alt stays live (see onAltChange). A tap can // no longer chain into the next press once a drag has begun. @@ -233,16 +218,20 @@ export function attachTerminalMouseRouter({ consumePointerEvent(ev, true); return; } - beginPendingDrag(ev, { pointerId: mousePressPointerId, touchLike: false }); + beginPendingDrag(ev, { touchLike: false }); }; const onPointerDown = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { if (ev.button !== 0) return; - // Only stash the id. Whether the terminal owns the press is decided by the - // mousedown that follows, which is what creates pendingDrag — and without a - // pendingDrag the id is never read. - mousePressPointerId = ev.pointerId; + if (!terminalOwnsEvent(ev).terminalOwns) return; + try { + mouseCaptureElement.setPointerCapture(ev.pointerId); + mouseDragPointerId = ev.pointerId; + } catch { + // The window-mousemove heal still covers an outside release. + mouseDragPointerId = null; + } return; } if (!ev.isPrimary) return; @@ -254,7 +243,7 @@ export function attachTerminalMouseRouter({ const doubleTap = lastTouchTap !== null && Date.now() - lastTouchTap.time <= DOUBLE_TAP_MS && dx * dx + dy * dy <= DOUBLE_TAP_DIST_PX_SQ; - const handled = beginPendingDrag(ev, { pointerId: ev.pointerId, touchLike: true, block: doubleTap }); + const handled = beginPendingDrag(ev, { touchLike: true, block: doubleTap }); if (!handled) return; activePointerId = ev.pointerId; suppressSyntheticMouseUntil = Date.now() + 800; @@ -278,7 +267,7 @@ export function attachTerminalMouseRouter({ const onWindowMouseMove = (ev: MouseEvent) => { // Backstop for engines that don't deliver a cross-frame captured pointerup - // (see the capture in updatePendingOrActiveDrag). A mouse drag is otherwise + // (see onPointerDown). A mouse drag is otherwise // kept alive only by the window 'mouseup' below, and when the button is // released outside our iframe that mouseup goes to the host document and // never reaches us, leaving the drag stuck. The next move we see (e.g. when @@ -312,7 +301,7 @@ export function attachTerminalMouseRouter({ mouseDragPointerId = null; // Capture auto-releases on pointerup, but be explicit. try { - element.releasePointerCapture(ev.pointerId); + mouseCaptureElement.releasePointerCapture(ev.pointerId); } catch { // already released } From ced9ba13a702ce91c091263a7719531451cfca7d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:30:27 -0700 Subject: [PATCH 6/7] Add screen lookup to terminal registry test element --- lib/src/lib/terminal-registry.alert.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 956fd3ab8..799e8bd0d 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -182,6 +182,10 @@ class MockElement { addEventListener(): void {} removeEventListener(): void {} + querySelector(): MockElement | null { + return null; + } + querySelectorAll(): MockElement[] { return []; } From c587dbb679fa5a88d53fb184026cd1164a49654f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:35:32 -0700 Subject: [PATCH 7/7] Drop unrelated mouse-router comment rewrap --- lib/src/lib/terminal-mouse-router.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 6965c947a..a30864ccc 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -267,14 +267,13 @@ export function attachTerminalMouseRouter({ const onWindowMouseMove = (ev: MouseEvent) => { // Backstop for engines that don't deliver a cross-frame captured pointerup - // (see onPointerDown). A mouse drag is otherwise - // kept alive only by the window 'mouseup' below, and when the button is - // released outside our iframe that mouseup goes to the host document and - // never reaches us, leaving the drag stuck. The next move we see (e.g. when - // the pointer re-enters) reports no buttons held — treat that as the mouseup - // we missed and finalize the drag in place. A genuine drag that leaves and - // re-enters still holding the button reports buttons===1, so this never - // fires mid-drag. + // (see onPointerDown). A mouse drag is otherwise kept alive only by the + // window 'mouseup' below, and when the button is released outside our iframe + // that mouseup is delivered to the host document and never reaches us, + // leaving the drag stuck. The next move we see (e.g. when the pointer + // re-enters) reports no buttons held — treat that as the mouseup we missed + // and finalize the drag in place. A genuine drag that leaves and re-enters + // still holding the button reports buttons===1, so this never fires mid-drag. if (ev.buttons === 0 && (pendingDrag || isDragging(id))) { finishPendingOrActiveDrag(ev); return;