Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/skills/debug-standalone-agent-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ agent-browser --session <outer-session> get text body
agent-browser --session <outer-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 '<stale-id>' 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`).
Expand Down
13 changes: 9 additions & 4 deletions lib/src/components/KillConfirm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,12 +71,16 @@ export function KillConfirmModal({
);
}

export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: {
export function KillConfirmOverlay({ apiRef, confirmKill, paneElements, onCancel }: {
apiRef: RefObject<DockviewApi | null>;
confirmKill: ConfirmKill;
paneElements: Map<string, HTMLElement>;
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 (
<KillConfirmModal
char={confirmKill.char}
Expand Down
1 change: 1 addition & 0 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,7 @@ export function Wall({
{/* Kill confirmation overlay — centered over the pane being killed */}
{confirmKill && (
<KillConfirmOverlay
apiRef={apiRef}
confirmKill={confirmKill}
paneElements={paneElements}
onCancel={() => rejectKill()}
Expand Down
6 changes: 3 additions & 3 deletions lib/src/components/wall/WorkspaceSelectionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
77 changes: 77 additions & 0 deletions lib/src/lib/spatial-nav.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, HTMLElement | null>): 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);
});
});
33 changes: 29 additions & 4 deletions lib/src/lib/spatial-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HTMLElement>,
): 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,
Expand All @@ -20,7 +45,7 @@ export function findReattachNeighbor(
api: DockviewApi,
paneElements: Map<string, HTMLElement>,
): { 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();
Expand All @@ -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();

Expand Down Expand Up @@ -82,7 +107,7 @@ export function findPaneInDirection(
api: DockviewApi,
paneElements: Map<string, HTMLElement>,
): 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';
Expand All @@ -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();

Expand Down
Loading