Skip to content

Commit 60e82ec

Browse files
authored
fix: command-mode highlight wraps the full pane for browser surfaces (#164)
2 parents 53e02bf + 6b8441d commit 60e82ec

6 files changed

Lines changed: 128 additions & 11 deletions

File tree

.claude/skills/debug-standalone-agent-browser/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ agent-browser --session <outer-session> get text body
6767
agent-browser --session <outer-session> screenshot /private/tmp/dormouse.png
6868
```
6969

70+
### Run `dor` by typing into Dormouse — never from your own shell
71+
72+
`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:
73+
74+
- **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`.
75+
- **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.
76+
77+
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.
78+
7079
### Command/mouse subcommands are limited
7180

7281
- `agent-browser keyboard` accepts only `type` and `inserttext` (there is **no** `keyboard press`).

lib/src/components/KillConfirm.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useRef } from 'react';
2-
import { resolvePaneElement } from '../lib/spatial-nav';
1+
import { useRef, type RefObject } from 'react';
2+
import type { DockviewApi } from 'dockview-react';
3+
import { resolvePaneGroupElement } from '../lib/spatial-nav';
34
import { ModalFrame, Shortcut } from './design';
45

56
export type KillExit = 'shake' | 'confirm';
@@ -70,12 +71,16 @@ export function KillConfirmModal({
7071
);
7172
}
7273

73-
export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: {
74+
export function KillConfirmOverlay({ apiRef, confirmKill, paneElements, onCancel }: {
75+
apiRef: RefObject<DockviewApi | null>;
7476
confirmKill: ConfirmKill;
7577
paneElements: Map<string, HTMLElement>;
7678
onCancel: () => void;
7779
}) {
78-
const panelEl = resolvePaneElement(paneElements.get(confirmKill.id));
80+
// Center over the whole pane (header + content) like a terminal — browser
81+
// panes render in an overlay layer with no groupview ancestor. See
82+
// resolvePaneGroupElement.
83+
const panelEl = resolvePaneGroupElement(apiRef.current, confirmKill.id, paneElements);
7984
return (
8085
<KillConfirmModal
8186
char={confirmKill.char}

lib/src/components/Wall.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,6 +1823,7 @@ export function Wall({
18231823
{/* Kill confirmation overlay — centered over the pane being killed */}
18241824
{confirmKill && (
18251825
<KillConfirmOverlay
1826+
apiRef={apiRef}
18261827
confirmKill={confirmKill}
18271828
paneElements={paneElements}
18281829
onCancel={() => rejectKill()}

lib/src/components/wall/WorkspaceSelectionOverlay.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
TERMINAL_SELECTION_BORDER_RADIUS,
66
} from '../design';
77
import { useFocusRingColor } from '../../lib/themes/use-focus-ring-color';
8-
import { resolvePaneElement } from '../../lib/spatial-nav';
8+
import { resolvePaneGroupElement } from '../../lib/spatial-nav';
99
import type { WallMode, WallSelectionKind } from './wall-types';
1010
import { DoorElementsContext, PaneElementsContext, WindowFocusedContext } from './wall-context';
1111
import { MarchingAntsRect } from './MarchingAntsRect';
@@ -36,7 +36,7 @@ export function WorkspaceSelectionOverlay({ apiRef, selectedId, selectedType, mo
3636
const update = () => {
3737
const targetEl = selectedType === 'door'
3838
? doorElements.get(selectedId)
39-
: resolvePaneElement(paneElements.get(selectedId));
39+
: resolvePaneGroupElement(api, selectedId, paneElements);
4040
if (!targetEl) return;
4141

4242
const targetRect = targetEl.getBoundingClientRect();
@@ -52,7 +52,7 @@ export function WorkspaceSelectionOverlay({ apiRef, selectedId, selectedType, mo
5252
update();
5353

5454
const ro = new ResizeObserver(update);
55-
const panelEl = resolvePaneElement(paneElements.get(selectedId));
55+
const panelEl = resolvePaneGroupElement(api, selectedId, paneElements);
5656
if (panelEl) ro.observe(panelEl);
5757
const doorEl = doorElements.get(selectedId);
5858
if (doorEl) ro.observe(doorEl);

lib/src/lib/spatial-nav.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { beforeEach, describe, expect, it } from 'vitest';
5+
import type { DockviewApi } from 'dockview-react';
6+
import { resolvePaneGroupElement } from './spatial-nav';
7+
8+
/** Minimal DockviewApi stub: only `getPanel(id).group.element` is exercised. */
9+
function fakeApi(groups: Record<string, HTMLElement | null>): DockviewApi {
10+
return {
11+
getPanel(id: string) {
12+
if (!(id in groups)) return undefined;
13+
const element = groups[id];
14+
return { group: element ? { element } : undefined };
15+
},
16+
} as unknown as DockviewApi;
17+
}
18+
19+
/** A browser surface's body: mounted in a dv-render-overlay, NOT inside any
20+
* dv-groupview, so a DOM climb finds no group and falls back to this body. */
21+
function makeOverlayBody(): HTMLElement {
22+
const overlay = document.createElement('div');
23+
overlay.className = 'dv-render-overlay';
24+
const body = document.createElement('div');
25+
overlay.appendChild(body);
26+
document.body.appendChild(overlay);
27+
return body;
28+
}
29+
30+
describe('resolvePaneGroupElement', () => {
31+
beforeEach(() => {
32+
document.body.innerHTML = '';
33+
});
34+
35+
it('climbs to the dv-groupview when the api has no group (e.g. terminal body inside it)', () => {
36+
const group = document.createElement('div');
37+
group.className = 'dv-groupview dv-active-group';
38+
const body = document.createElement('div');
39+
group.appendChild(body);
40+
document.body.appendChild(group);
41+
42+
// No api group → resolution falls back to the DOM climb, which lands on the group.
43+
const api = fakeApi({ t1: null });
44+
expect(resolvePaneGroupElement(api, 't1', new Map([['t1', body]]))).toBe(group);
45+
});
46+
47+
it('uses the dockview group element for a browser body rendered in the overlay layer', () => {
48+
const body = makeOverlayBody();
49+
// The full group (tab header + content) still lives elsewhere in the DOM.
50+
const group = document.createElement('div');
51+
group.className = 'dv-groupview';
52+
document.body.appendChild(group);
53+
54+
const api = fakeApi({ b1: group });
55+
expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(group);
56+
});
57+
58+
it('falls back to the body when no panel/group is available', () => {
59+
const body = makeOverlayBody();
60+
const api = fakeApi({}); // getPanel returns undefined
61+
expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(body);
62+
});
63+
64+
it('falls back to the body when the api is null', () => {
65+
const body = makeOverlayBody();
66+
expect(resolvePaneGroupElement(null, 'b1', new Map([['b1', body]]))).toBe(body);
67+
});
68+
69+
it('ignores a disconnected group element and falls back to the body', () => {
70+
const body = makeOverlayBody();
71+
const detachedGroup = document.createElement('div'); // never attached → !isConnected
72+
detachedGroup.className = 'dv-groupview';
73+
74+
const api = fakeApi({ b1: detachedGroup });
75+
expect(resolvePaneGroupElement(api, 'b1', new Map([['b1', body]]))).toBe(body);
76+
});
77+
});

lib/src/lib/spatial-nav.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ export function resolvePaneElement(element: HTMLElement | null | undefined): HTM
99
return (element.closest('[class*="groupview"]') as HTMLElement | null) ?? element;
1010
}
1111

12+
/**
13+
* Resolve the dockview *group* element (`dv-groupview`: tab header + content) for
14+
* a pane id, so selection/spatial bounds cover the whole pane like a terminal.
15+
*
16+
* Terminal panes mount their body INSIDE the `dv-groupview`, so climbing via
17+
* `closest('[class*="groupview"]')` finds the full group. Browser surfaces use
18+
* dockview's `renderer:'always'`, which mounts the body in a `dv-render-overlay`
19+
* layer that is a *sibling* of the groupviews and sits only over the content area
20+
* (below the ~30px tab header). From there the climb finds no groupview and falls
21+
* back to the shorter body — making the command-mode highlight 30px short and
22+
* shifted down (diffplug/dormouse: browser highlight not "exactly like a regular
23+
* terminal"). The panel's `group.element` is the authoritative groupview for
24+
* either renderer, so prefer it; climb the DOM only as a fallback for transient
25+
* states where the panel isn't in the api yet.
26+
*/
27+
export function resolvePaneGroupElement(
28+
api: DockviewApi | null,
29+
id: string,
30+
paneElements: Map<string, HTMLElement>,
31+
): HTMLElement | null {
32+
const groupEl = api?.getPanel(id)?.group?.element ?? null;
33+
if (groupEl?.isConnected) return groupEl;
34+
return resolvePaneElement(paneElements.get(id));
35+
}
36+
1237
/** Find the closest adjacent panel to use as a restore anchor.
1338
* Returns the neighbor ID and the direction the current panel was relative to it,
1439
* which matches Dockview's addPanel position.direction semantics. For example,
@@ -20,7 +45,7 @@ export function findReattachNeighbor(
2045
api: DockviewApi,
2146
paneElements: Map<string, HTMLElement>,
2247
): { neighborId: string | null; direction: DoorDirection } {
23-
const currentEl = resolvePaneElement(paneElements.get(currentId));
48+
const currentEl = resolvePaneGroupElement(api, currentId, paneElements);
2449
if (!currentEl) return { neighborId: null, direction: 'right' };
2550

2651
const c = currentEl.getBoundingClientRect();
@@ -33,7 +58,7 @@ export function findReattachNeighbor(
3358

3459
for (const panel of api.panels) {
3560
if (panel.id === currentId) continue;
36-
const el = resolvePaneElement(paneElements.get(panel.id));
61+
const el = resolvePaneGroupElement(api, panel.id, paneElements);
3762
if (!el) continue;
3863
const r = el.getBoundingClientRect();
3964

@@ -82,7 +107,7 @@ export function findPaneInDirection(
82107
api: DockviewApi,
83108
paneElements: Map<string, HTMLElement>,
84109
): string | null {
85-
const currentEl = resolvePaneElement(paneElements.get(currentId));
110+
const currentEl = resolvePaneGroupElement(api, currentId, paneElements);
86111
if (!currentEl) return null;
87112
const c = currentEl.getBoundingClientRect();
88113
const isHorizontal = direction === 'ArrowLeft' || direction === 'ArrowRight';
@@ -91,7 +116,7 @@ export function findPaneInDirection(
91116

92117
for (const panel of api.panels) {
93118
if (panel.id === currentId) continue;
94-
const el = resolvePaneElement(paneElements.get(panel.id));
119+
const el = resolvePaneGroupElement(api, panel.id, paneElements);
95120
if (!el) continue;
96121
const r = el.getBoundingClientRect();
97122

0 commit comments

Comments
 (0)