Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b1813f3
dor ensure: never change focus (created / existing / restarted)
nedtwigg Jul 5, 2026
2805ae9
dor ensure: render the focus-neutral pane (inactive needs renderer:'a…
nedtwigg Jul 5, 2026
0a52906
dor ensure: render the focus-neutral pane by adding it active, then r…
nedtwigg Jul 5, 2026
36900c6
dor ensure: re-assert caller focus after the focus-neutral split (key…
nedtwigg Jul 5, 2026
2162be3
dor ensure: fix stale comments and dead guard from focus-neutral iter…
nedtwigg Jul 5, 2026
822b715
dor kill: don't steal selection when killing an unselected pane
nedtwigg Jul 5, 2026
cbe779e
dor iframe / dor ab: open the surface focus-neutral, like dor ensure
nedtwigg Jul 5, 2026
53aa46b
Wall: dedup the focus-neutral surface-add dispatch (post-/simplify)
nedtwigg Jul 5, 2026
cc45524
dor iframe/ab: fix selectedType desync on focus-neutral replace + --m…
nedtwigg Jul 5, 2026
ff03ff7
Wall: key the focus-neutral replace exception on user selection, not …
nedtwigg Jul 6, 2026
3679a43
Wall/kill: heal DOM focus after mutations, without stealing it
nedtwigg Jul 6, 2026
aa0974b
Wall: teach killPaneImmediately to tear down doored surfaces
nedtwigg Jul 6, 2026
9ca1a71
kill/Wall: overlay ring gated on wasSelected; header-kill docs; panel…
nedtwigg Jul 6, 2026
50601ac
Wall: replace suppressActivationSelectRef with a programmatic-activat…
nedtwigg Jul 6, 2026
147101b
Wall: tag all add-side programmatic activation
nedtwigg Jul 6, 2026
916f169
kill: evaluate selection live at removal time; tag the removal
nedtwigg Jul 6, 2026
451c383
auto-spawn: adopt explicitly on null-or-dangling selection; tag the add
nedtwigg Jul 6, 2026
8beed5d
Wall: delete the door guard; listener adopts untagged activation as u…
nedtwigg Jul 6, 2026
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 docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ Both CLI entry points use the same content-surface placement rule in
otherwise split next to the reference surface. `dor iframe` also accepts
`--surface`, `--minimize`, and `--json`.

Both open the surface **focus-neutrally** (like `dor ensure`): it renders in the
background and the caller keeps focus — except when replacing the pane the user
is currently selected on, where selection follows to the replacement (it would
otherwise dangle on the removed panel); replacing an active-but-unselected pane
leaves the user's selection, including a door selection, untouched.
`createContentSurface`'s
`focusNeutral` routes through the shared `runSurfaceAdd` helper (see
`docs/specs/layout.md` corner case #12).

Surface lifetime owns backing resources:

- Killing an agent-browser-rendered pane marks the session closed and runs
Expand Down
10 changes: 7 additions & 3 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ Shell-selection replacement uses the same pane add/remove primitives but also sh

### Kill (in-place fade + FLIP reclaim)

`orchestrateKill(api, killedId)` in `lib/src/lib/kill-animation.ts` runs on kill confirmation. `Wall.tsx` owns the command dispatch and calls it after the user confirms. It fades the real pane element in place (its content dissolves against the same-colored background), then removes the panel and FLIP-reveals the survivors:
`orchestrateKill(api, killedId, isSelected, …, onRemoved?)` in `lib/src/lib/kill-animation.ts` runs on kill confirmation. `Wall.tsx` owns the command dispatch and calls it after the user confirms. `isSelected` is a live reader, not a snapshot: the selection tail evaluates `isSelected(killedId)` at removal time (up to ~1s after the fade starts), and the overlay ring reads it once at fade start. The optional `onRemoved` callback fires once, on every path, immediately after `removePanel`: collapsing a branch can re-parent and blur the surviving selected pane, so `killPaneImmediately` uses it to re-assert that pane's DOM focus (through the shared `reassertPaneFocus` guard, see corner case #12). It fades the real pane element in place (its content dissolves against the same-colored background), then removes the panel and FLIP-reveals the survivors:

1. Add `.pane-fading-out` (or `.pane-fading-and-shrinking-to-br` for a last-pane kill) to the killed pane's group element. Block pointer events during the fade.
2. On `animationend`, snapshot `getBoundingClientRect` for every surviving panel's group element.
Expand All @@ -368,11 +368,13 @@ Shell-selection replacement uses the same pane add/remove primitives but also sh

Case handling is purely rect-based (measure before and after removal), so 2-pane splits, linear 3+ rows/columns, and nested splits all fall through the same code path with no per-case branching.

Selection tail: after removal, `orchestrateKill` moves selection to a survivor (`panels[0]`, or `null` → auto-spawn when the last pane goes) **only when `isSelected(killedId)` reads true at removal time** — the killed pane is the selected one. `killPaneImmediately` passes a live reader (`selectedType === 'pane' && selectedId === killedId`) rather than a snapshot, so killing a background surface leaves the user's selection untouched, and a selection move *during* the ~1s fade is honored by design: navigating away from a dying selected pane means the tail no longer yanks selection to `panels[0]`, and navigating onto a dying pane means the tail adopts a survivor instead of leaving selection dangling on the removed panel. (The removal is tagged with `withProgrammaticActivation`, so dockview's activate-the-survivor echo — which previously covered those two edges by accident — no longer reads as user intent; the live check covers them explicitly.) The header kill button cannot produce an unselected kill: dockview activates the pane on pointerdown (`doSetGroupActive`) before the button's click handler runs, so mouse kills are always selected-pane kills. The not-selected cases are `dor kill` of a background surface (`dor kill surface:3`) and ensure's throwaway teardown.

### Auto-spawn delay

When `onDidRemovePanel` triggers the "always keep one pane visible" auto-spawn (see corner case #10), the `api.addPanel` call is deferred by 440ms **for the minimize path** — letting the selection-overlay slide to the door finish before the replacement's reveal starts. The kill path uses no extra delay (0ms): kill sequencing is already handled inside `orchestrateKill`, which removes the panel only after the fade's `animationend`. Reduced-motion users also get 0ms. The deferred spawn re-checks `totalPanels` at fire time and becomes a no-op if anything repopulated the pane area during the delay (e.g. a door reattach). If it does create a replacement pane, that pane spawns with the current default shell selection, matching manual splits and the standalone `[+]` action.

The deferred spawn also only calls `selectPane` if selection is null. The kill handler clears selection to null, so the new pane takes focus. The minimize flow sets selection to the just-created door; preserving that door focus across the delay is the point.
The deferred spawn adopts the replacement (`selectPane`) only when the current selection no longer points at anything real: null (`orchestrateKill`'s selection tail cleared it after a selected last-pane kill) or dangling (selection still names the removed pane, which the 0ms kill timer can observe if it beats React's flush of `setSelectedId(null)` into `selectedIdRef`). A *valid* selection is left alone — the just-created door on the minimize path (preserving that door focus across the delay is the point) or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection.

## Corner cases

Expand All @@ -386,7 +388,8 @@ The deferred spawn also only calls `selectPane` if selection is null. The kill h
8. **Center drop merges panels**: intercepted at group-level `model.onWillDrop` and converted to a swap.
9. **Group drag has null panelId**: falls back to `api.getGroup(groupId).activePanel.id`.
10. **Auto-spawn on empty**: `onDidRemovePanel` creates a new session whenever the last visible pane is removed, whether or not doors exist — there is always a pane visible. The `addPanel` call is delayed 440ms on the minimize path (see "Auto-spawn delay" under Animations); the kill path is sequenced by `orchestrateKill` instead.
11. **Door focus survives auto-spawn**: `api.addPanel` auto-activates the new panel, firing `onDidActivePanelChange`. When the current selection is a door (e.g., just-minimized last pane), that listener must not flip `selectedId` to the new pane — otherwise `selectedType === 'door'` + `selectedId === newPaneId` desyncs and the door loses its highlight while the `WorkspaceSelectionOverlay` is stuck on the stale door rect. The listener early-returns when `selectedType === 'door'`.
11. **Door focus survives auto-spawn**: `api.addPanel` auto-activates the new panel, firing `onDidActivePanelChange`. When the current selection is a door (e.g., just-minimized last pane), selection must not flip to the new pane — otherwise `selectedType === 'door'` + `selectedId === newPaneId` desyncs and the door loses its highlight while the `WorkspaceSelectionOverlay` is stuck on the stale door rect. The auto-spawn's `addPanel` is tagged with `withProgrammaticActivation` and applies its adopt-or-leave selection policy explicitly at the spawn site (adopt only when selection is null or dangling; a valid door or live-pane selection is left alone), so door survival lives in the spawn-site policy — the listener no longer has a door special case. A dockview-native user interaction, though, DOES move selection off a door now — dragging a panel, or DOM focus landing in a surface (an embed focusing itself) — matching how those already move selection off a pane; explicit user selection of a pane always wins over door stickiness.
12. **Focus-neutral surface creation (`dor ensure` / `dor iframe` / `dor ab`)**: unlike `dor split`, these must never move focus off the caller (`docs/specs/dor-cli.md`, `docs/specs/dor-browser.md`). Adding the pane `inactive` does **not** work: dockview renders and lays out a pane only once it becomes its group's active panel (a new group's `setActive` runs `doSetActivePanel`), so an `inactive` add — which skips both — leaves the pane behind a blank tile until the first click activates it. Instead the shared `runSurfaceAdd(focusNeutral, add)` helper adds the pane **active** (so it renders), then `add` hands the active group straight back to the caller via `settleFocusAfterAdd` (`caller.api.setActive()`); the new pane stays active *within its own group* and keeps rendering. The helper runs the add inside `withProgrammaticActivation` (`lib/src/lib/programmatic-activation.ts`), a depth-counted tag the `onDidActivePanelChange` listener (#11) checks so this programmatic activation churn never reads as user intent, and `selectedId`/mode never leave the caller. But adding the pane re-parents the caller's grid subtree in the DOM, which **blurs its xterm textarea** — and since `selectedId` never changed, `TerminalPanel`'s focus effect does not re-run to reclaim it, so the caller would silently stop receiving keystrokes until clicked. So the helper re-asserts the selected pane's focus with a deferred `focusSession` through the shared `reassertPaneFocus` rAF helper (deferred to beat dockview's post-split focus handling, like the click-focus rAF in `enterTerminalMode`), gated on three conditions: the pane is still the selected pane in passthrough (the condition `TerminalPanel` uses for `isFocused`, so a legitimate selection move makes it a no-op); the window still has focus (`document.hasFocus()` — a background `dor` command must never yank cross-frame focus out of the host editor, e.g. VS Code); and DOM focus is not in an in-page editable control (the inline-rename input, a non-terminal `<input>`/`<textarea>`/contenteditable — re-parent blur drops focus to `<body>`, so a focused control means the user put it there; the terminal's own `xterm-helper-textarea` is exempt since re-focusing it is idempotent). Callers skip `selectPane`; the `--minimize` case passes `select: false` to `minimizePane` so it creates the door without stealing selection or switching to command mode. (`dor iframe` / `dor ab` replacing the pane the user is *currently selected on* are the exception: selection follows to the replacement, since it would otherwise dangle on the removed panel. Replacing an active-but-unselected pane leaves the user's selection — including a door selection — untouched.) If a freshly created shell never reports OSC 633 integration, ensure tears the throwaway pane down with `killPaneImmediately`; because the focus-neutral create never selected it, `orchestrateKill`'s live selection check (`isSelected(killedId)` read at removal time, see the kill "Selection tail" above) leaves the caller's selection intact. For a `--minimize` create the throwaway is already a door with no dockview panel, and `killPaneImmediately` handles that too — disposing the session and removing the door directly (no panel to animate). Coverage rule: every programmatic dockview mutation runs tagged inside `withProgrammaticActivation` — the focus-neutral adds here, `selectPane`/`enterTerminalMode`'s own trailing `setActive`, reattach's restore mutations (`handleReattach`), the kill removal, `minimizePane`'s removal, and the empty-pane auto-spawn's `addPanel` — so an untagged activation is a dockview-native user interaction (a panel drag, or DOM focus landing in a surface), which the listener adopts through the real dispatchers: `enterTerminalMode` in passthrough, `selectPane` in command. Selection policy lives at the mutation sites, not the listener; `lib/src/lib/programmatic-activation.ts` holds the rationale.

## Files

Expand All @@ -400,6 +403,7 @@ The deferred spawn also only calls `selectPane` if selection is null. The kill h
| `lib/src/components/wall/MarchingAntsRect.tsx` | SVG marching-ants border path and dash sizing |
| `lib/src/components/wall/MouseOverrideBanner.tsx` | Temporary mouse override banner shown from the header icon |
| `lib/src/components/wall/use-dockview-ready.ts` | Dockview ready/setup handler: restore/create panels, DnD swap wiring, active panel sync, auto-spawn |
| `lib/src/lib/programmatic-activation.ts` | `withProgrammaticActivation` depth-tag distinguishing programmatic add-side dockview activation from user activation |
| `lib/src/components/wall/use-wall-keyboard.ts` | Capture-phase keyboard dispatch for mode switching, pane/door commands, copy/paste, selection drag keys |
| `lib/src/lib/vscode-keybindings.ts` | VS Code-hosted workbench chord mirror allowlist |
| `lib/src/components/wall/use-session-persistence.ts` | Debounced layout/session save, flush requests, pagehide, PTY exit, file-drop paste routing |
Expand Down
2 changes: 2 additions & 0 deletions dor/src/commands/ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const ensureCommand: Command = {
brief: 'Ensure one surface is running a command.',
fullDescription: `Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command.

ensure never changes focus. Whether it reuses a match, restarts one, or creates a new split, the surface you ran it from keeps focus — a newly created surface (visible or minimized) starts in the background.

Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), not process inspection. This captures the typed command (\`npm run dev\`), not the forked child process (\`node .../vite\`), and works for shells the user started by hand as well as shells Dormouse started. The match is exact: \`npm run dev\` and \`npm run dev --host\` are different commands and get separate surfaces.

ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell.
Expand Down
2 changes: 2 additions & 0 deletions dor/test/snapshots/help/ensure.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ USAGE

Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command.

ensure never changes focus. Whether it reuses a match, restarts one, or creates a new split, the surface you ran it from keeps focus — a newly created surface (visible or minimized) starts in the background.

Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), not process inspection. This captures the typed command (`npm run dev`), not the forked child process (`node .../vite`), and works for shells the user started by hand as well as shells Dormouse started. The match is exact: `npm run dev` and `npm run dev --host` are different commands and get separate surfaces.

ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell.
Expand Down
Loading
Loading