diff --git a/.claude/skills/debug-standalone-agent-browser/SKILL.md b/.claude/skills/debug-standalone-agent-browser/SKILL.md index 8b9aceeb..134543a1 100644 --- a/.claude/skills/debug-standalone-agent-browser/SKILL.md +++ b/.claude/skills/debug-standalone-agent-browser/SKILL.md @@ -67,6 +67,15 @@ agent-browser --session get text body agent-browser --session screenshot /private/tmp/dormouse.png ``` +### Run `dor` by typing into Dormouse — never from your own shell + +`dor` commands (`dor ab open`, `dor split`, …) must be **typed into the Dormouse terminal under test** (the xterm — see *Typing into xterm* and *Submitting (Enter)* below), exactly as a user would. Do **not** run the staged `dor` (or `node .../dor.js`) from your own shell, even if you point it at the harness's `DORMOUSE_CONTROL_SOCKET`/`DORMOUSE_CONTROL_TOKEN`. Two ways it breaks: + +- **Wrong instance.** Your dev shell is often itself running *inside the installed Dormouse*, so it inherits that app's `DORMOUSE_SURFACE_ID`, `DORMOUSE_CONTROL_SOCKET`, and `DORMOUSE_CONTROL_TOKEN`. A bare `dor` then drives (or errors against) the **installed** app, not the harness — e.g. `Warning: could not open the Dormouse browser surface: surface '' was not found`. +- **Wrong / missing caller surface.** `dor` resolves its target from `DORMOUSE_SURFACE_ID` (the pane it runs in), then the focused surface. Typed into the xterm, that variable is the harness pane, so surface targeting *and the split-vs-replace behavior match real usage*: `dor ab open` **splits** next to a **touched** terminal but **replaces** an **untouched** one (`createContentSurface`'s `replaceUntouchedTerminal`). Any input into a terminal touches it, so a user who typed the command gets a split — but an externally-run `dor` leaves the terminal untouched (and has no caller pane), so you get a replace and the flow no longer matches what the user sees. + +So to reproduce a user flow faithfully: `keyboard inserttext` the `dor …` line into the xterm, submit it with the synthetic Enter, then watch the harness log / DOM for the result. + ### Command/mouse subcommands are limited - `agent-browser keyboard` accepts only `type` and `inserttext` (there is **no** `keyboard press`). diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx index f2b1ed76..24779b3d 100644 --- a/lib/src/components/KillConfirm.tsx +++ b/lib/src/components/KillConfirm.tsx @@ -1,5 +1,6 @@ -import { useRef } from 'react'; -import { resolvePaneElement } from '../lib/spatial-nav'; +import { useRef, type RefObject } from 'react'; +import type { DockviewApi } from 'dockview-react'; +import { resolvePaneGroupElement } from '../lib/spatial-nav'; import { ModalFrame, Shortcut } from './design'; export type KillExit = 'shake' | 'confirm'; @@ -70,12 +71,16 @@ export function KillConfirmModal({ ); } -export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: { +export function KillConfirmOverlay({ apiRef, confirmKill, paneElements, onCancel }: { + apiRef: RefObject; confirmKill: ConfirmKill; paneElements: Map; onCancel: () => void; }) { - const panelEl = resolvePaneElement(paneElements.get(confirmKill.id)); + // Center over the whole pane (header + content) like a terminal — browser + // panes render in an overlay layer with no groupview ancestor. See + // resolvePaneGroupElement. + const panelEl = resolvePaneGroupElement(apiRef.current, confirmKill.id, paneElements); return ( rejectKill()} diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 5fef3d2b..2a9ae854 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -5,7 +5,7 @@ import { TERMINAL_SELECTION_BORDER_RADIUS, } from '../design'; import { useFocusRingColor } from '../../lib/themes/use-focus-ring-color'; -import { resolvePaneElement } from '../../lib/spatial-nav'; +import { resolvePaneGroupElement } from '../../lib/spatial-nav'; import type { WallMode, WallSelectionKind } from './wall-types'; import { DoorElementsContext, PaneElementsContext, WindowFocusedContext } from './wall-context'; import { MarchingAntsRect } from './MarchingAntsRect'; @@ -36,7 +36,7 @@ export function WorkspaceSelectionOverlay({ apiRef, selectedId, selectedType, mo const update = () => { const targetEl = selectedType === 'door' ? doorElements.get(selectedId) - : resolvePaneElement(paneElements.get(selectedId)); + : resolvePaneGroupElement(api, selectedId, paneElements); if (!targetEl) return; const targetRect = targetEl.getBoundingClientRect(); @@ -52,7 +52,7 @@ export function WorkspaceSelectionOverlay({ apiRef, selectedId, selectedType, mo update(); const ro = new ResizeObserver(update); - const panelEl = resolvePaneElement(paneElements.get(selectedId)); + const panelEl = resolvePaneGroupElement(api, selectedId, paneElements); if (panelEl) ro.observe(panelEl); const doorEl = doorElements.get(selectedId); if (doorEl) ro.observe(doorEl); diff --git a/lib/src/lib/spatial-nav.test.ts b/lib/src/lib/spatial-nav.test.ts new file mode 100644 index 00000000..b9547511 --- /dev/null +++ b/lib/src/lib/spatial-nav.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { DockviewApi } from 'dockview-react'; +import { resolvePaneGroupElement } from './spatial-nav'; + +/** Minimal DockviewApi stub: only `getPanel(id).group.element` is exercised. */ +function fakeApi(groups: Record): DockviewApi { + return { + getPanel(id: string) { + if (!(id in groups)) return undefined; + const element = groups[id]; + return { group: element ? { element } : undefined }; + }, + } as unknown as DockviewApi; +} + +/** A browser surface's body: mounted in a dv-render-overlay, NOT inside any + * dv-groupview, so a DOM climb finds no group and falls back to this body. */ +function makeOverlayBody(): HTMLElement { + const overlay = document.createElement('div'); + overlay.className = 'dv-render-overlay'; + const body = document.createElement('div'); + overlay.appendChild(body); + document.body.appendChild(overlay); + return body; +} + +describe('resolvePaneGroupElement', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('climbs to the dv-groupview when the api has no group (e.g. terminal body inside it)', () => { + const group = document.createElement('div'); + group.className = 'dv-groupview dv-active-group'; + const body = document.createElement('div'); + group.appendChild(body); + document.body.appendChild(group); + + // No api group → resolution falls back to the DOM climb, which lands on the group. + const api = fakeApi({ t1: null }); + expect(resolvePaneGroupElement(api, 't1', new Map([['t1', body]]))).toBe(group); + }); + + it('uses the dockview group element for a browser body rendered in the overlay layer', () => { + const body = makeOverlayBody(); + // The full group (tab header + content) still lives elsewhere in the DOM. + const group = document.createElement('div'); + group.className = 'dv-groupview'; + document.body.appendChild(group); + + const api = fakeApi({ b1: group }); + expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(group); + }); + + it('falls back to the body when no panel/group is available', () => { + const body = makeOverlayBody(); + const api = fakeApi({}); // getPanel returns undefined + expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(body); + }); + + it('falls back to the body when the api is null', () => { + const body = makeOverlayBody(); + expect(resolvePaneGroupElement(null, 'b1', new Map([['b1', body]]))).toBe(body); + }); + + it('ignores a disconnected group element and falls back to the body', () => { + const body = makeOverlayBody(); + const detachedGroup = document.createElement('div'); // never attached → !isConnected + detachedGroup.className = 'dv-groupview'; + + const api = fakeApi({ b1: detachedGroup }); + expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(body); + }); +}); diff --git a/lib/src/lib/spatial-nav.ts b/lib/src/lib/spatial-nav.ts index d2f39661..8d926270 100644 --- a/lib/src/lib/spatial-nav.ts +++ b/lib/src/lib/spatial-nav.ts @@ -9,6 +9,31 @@ export function resolvePaneElement(element: HTMLElement | null | undefined): HTM return (element.closest('[class*="groupview"]') as HTMLElement | null) ?? element; } +/** + * Resolve the dockview *group* element (`dv-groupview`: tab header + content) for + * a pane id, so selection/spatial bounds cover the whole pane like a terminal. + * + * Terminal panes mount their body INSIDE the `dv-groupview`, so climbing via + * `closest('[class*="groupview"]')` finds the full group. Browser surfaces use + * dockview's `renderer:'always'`, which mounts the body in a `dv-render-overlay` + * layer that is a *sibling* of the groupviews and sits only over the content area + * (below the ~30px tab header). From there the climb finds no groupview and falls + * back to the shorter body — making the command-mode highlight 30px short and + * shifted down (diffplug/dormouse: browser highlight not "exactly like a regular + * terminal"). The panel's `group.element` is the authoritative groupview for + * either renderer, so prefer it; climb the DOM only as a fallback for transient + * states where the panel isn't in the api yet. + */ +export function resolvePaneGroupElement( + api: DockviewApi | null, + id: string, + paneElements: Map, +): HTMLElement | null { + const groupEl = api?.getPanel(id)?.group?.element ?? null; + if (groupEl?.isConnected) return groupEl; + return resolvePaneElement(paneElements.get(id)); +} + /** Find the closest adjacent panel to use as a restore anchor. * Returns the neighbor ID and the direction the current panel was relative to it, * which matches Dockview's addPanel position.direction semantics. For example, @@ -20,7 +45,7 @@ export function findReattachNeighbor( api: DockviewApi, paneElements: Map, ): { neighborId: string | null; direction: DoorDirection } { - const currentEl = resolvePaneElement(paneElements.get(currentId)); + const currentEl = resolvePaneGroupElement(api, currentId, paneElements); if (!currentEl) return { neighborId: null, direction: 'right' }; const c = currentEl.getBoundingClientRect(); @@ -33,7 +58,7 @@ export function findReattachNeighbor( for (const panel of api.panels) { if (panel.id === currentId) continue; - const el = resolvePaneElement(paneElements.get(panel.id)); + const el = resolvePaneGroupElement(api, panel.id, paneElements); if (!el) continue; const r = el.getBoundingClientRect(); @@ -82,7 +107,7 @@ export function findPaneInDirection( api: DockviewApi, paneElements: Map, ): string | null { - const currentEl = resolvePaneElement(paneElements.get(currentId)); + const currentEl = resolvePaneGroupElement(api, currentId, paneElements); if (!currentEl) return null; const c = currentEl.getBoundingClientRect(); const isHorizontal = direction === 'ArrowLeft' || direction === 'ArrowRight'; @@ -91,7 +116,7 @@ export function findPaneInDirection( for (const panel of api.panels) { if (panel.id === currentId) continue; - const el = resolvePaneElement(paneElements.get(panel.id)); + const el = resolvePaneGroupElement(api, panel.id, paneElements); if (!el) continue; const r = el.getBoundingClientRect();