dor: focus-neutral surface commands (ensure / iframe / ab) + dor kill focus fix#212
Merged
Conversation
The matched and restarted outcomes already left focus alone, but creating a surface went through createSplitSurface, which selected the new pane and let dockview auto-activate it (and --minimize selected the new door and entered command mode). Running `dor ensure` therefore yanked focus off the caller. Add a focusNeutral option to createSplitSurface (dor split does not pass it): add the pane with inactive:true to suppress dockview's auto-activation and its selecting onDidActivePanelChange, skip selectPane, and pass select:false to minimizePane so the --minimize case creates the door without stealing selection or switching to command mode. On integration failure, remove the throwaway pane with a plain disposeSession + removePanel instead of killPaneImmediately, whose kill animation reselects panels[0]. Document the invariant in the ensure help text and layout.md corner case #12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lways') The previous commit added the ensure pane with inactive:true so it would not steal focus. That worked, but dockview's doAddPanel only mounts an inactive panel's content when its renderer is 'always' (skipSetActive skips openPanel otherwise), so with the default onlyWhenVisible renderer the pane spawned its shell yet showed blank. Pair inactive:true with renderer:'always' in createSplitSurface's focusNeutral branch so the new pane renders in the background without activating. Note the gotcha in layout.md corner case #12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…estoring the caller The renderer:'always' attempt still showed a blank pane. Root cause (verified against dockview-core): dockview renders and lays out a pane only once it becomes its group's active panel, via the group's setActive → doSetActivePanel. An `inactive` add skips setActive on the new group, so its panel never becomes active-in-group and its content element is never mounted — the shell runs behind a blank tile until the first click activates it. Add the pane active (it renders normally), then hand the active group straight back to the caller with restoreActivePanel.api.setActive(). The new pane stays active *within its own group*, so it keeps rendering, while the caller keeps the active group. suppressActivationSelectRef gates the onDidActivePanelChange listener across both activations so selectedId/mode never leave the caller; since a pane's xterm DOM focus follows selectedId and dockview's programmatic activation does not force element.focus(), the caller keeps focus. Verified the mechanism against real dockview-core: after add-active + setActive(caller), the new pane's content element stays connected and isPanelActive/isVisible stay true, while an inactive add leaves it disconnected until activated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…board input) Adding the pane re-parents the caller's grid subtree in the DOM, which blurs its xterm textarea. Because selection never moved, TerminalPanel's focus effect does not re-run to reclaim it, so the caller silently stopped receiving keystrokes until it was clicked. After the add, re-assert the caller's focus with a deferred focusSession (rAF, to beat dockview's post-split focus handling like the click-focus rAF in enterTerminalMode), gated on the caller still being the selected pane in passthrough — the exact condition TerminalPanel uses for isFocused. Verified against real dockview: a directional split blurs a focused textarea in the caller's pane (document.activeElement moves off it), and re-focusing restores it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation Post-/simplify cleanup (no behavior change): - correct two comments that still described the abandoned `inactive` add — the final approach adds the pane active then restores the caller - drop the always-true `restoreActivePanel.id !== newId` guard (newId is freshly generated after capturing api.activePanel, so they never collide) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
orchestrateKill's selection tail always reselected panels[0] after removal, so `dor kill surface:3` (or the header kill button) from another pane yanked the user's selection to the first survivor — the same focus-steal we fixed for ensure, triggered by kill. Gate the tail on a new `wasSelected` param (killPaneImmediately computes selectedId === killedId). Killing the selected pane still moves selection to a survivor; killing a background pane leaves selection where it was. Knock-on: ensure's integration-failure teardown previously inlined disposeSession + removePanel purely to avoid that reselect. With the gate, the never-selected throwaway is focus-neutral under killPaneImmediately too, so the special case collapses back to the shared path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both went through createContentSurface, which selectPane'd the new browser surface — stealing focus from the caller terminal. Opening an embed or screencast alongside your terminal shouldn't yank you out of it (and `dor ab` was inconsistent: reusing an existing session already left focus put, only create moved it). Extract the ensure focus-neutral machinery (now a second call site) into a shared addKeepingCallerFocus(add) helper: add the pane active so dockview renders it, hand the active group back to the caller, suppress the activation listener across both transitions, and re-assert the caller's xterm focus past dockview's post-split blur. createSplitSurface and createContentSurface both use it. createContentSurface's focusNeutral keeps the caller focused except when replacing the caller's own untouched pane, where focus follows the replacement. Verified the split and replace-within paths against real dockview: the caller stays the active panel while the new surface renders (isVisible / isPanelActive / content element connected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Quality-only, no behavior change. The `if (focusNeutral) addKeepingCallerFocus(add);
else add(undefined);` dispatch was repeated verbatim at all three surface-add sites,
and the "settle focus after add" decision was spelled two ways (createSplitSurface
inline vs createContentSurface's settleFocus closure).
- Fold `focusNeutral` into the helper (renamed addKeepingCallerFocus → runSurfaceAdd):
the non-neutral path runs `add(undefined)` and returns; the name no longer
over-promises an invariant it delegates to callbacks.
- Extract a shared `settleFocusAfterAdd(api, caller, newId, selectPane)`. `caller`
presence is the focus-neutral signal (runSurfaceAdd passes it only then), and the
`getPanel(caller.id)` liveness check handles the replace-consumed-caller edge — so
one expression covers split, replace, and non-neutral uniformly.
Three call sites collapse to `runSurfaceAdd(focusNeutral, (caller) => { ... })`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
8beed5d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4d4da582.mouseterm.pages.dev |
| Branch Preview URL: | https://self-harness.mouseterm.pages.dev |
dormouse-bot
reviewed
Jul 5, 2026
dormouse-bot
left a comment
Collaborator
There was a problem hiding this comment.
Nicely scoped, and the spec/probe notes make the focus mechanics easy to follow. One edge-case inconsistency in the replaceUntouchedTerminal + --minimize path — inline.
The rest of the machinery checks out: runSurfaceAdd's suppress → add → reassert-focus sequence is reset in a finally, the empty useCallback deps are safe (focusSession is a module import, the rest are refs), the wasSelected kill gate self-heals correctly against the auto-spawn (selection only clears to null on a selected last-pane kill), and all orchestrateKill call sites (incl. tests) were updated.
…inimize Per PR review: when `dor iframe`/`dor ab --minimize` self-targets an untouched caller pane, settleFocusAfterAdd selects the new surface (selectedType='pane') because the caller was removed — then minimizePane(select:false) turned it into a door without selectDoor, leaving selectedType='pane' at a door id and the selection overlay on a stale rect. When the caller was the replaced pane, let the resulting door take selection (select:true) so selectedType='door' matches; the caller-survives and non-neutral paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dormouse-bot
approved these changes
Jul 5, 2026
…activePanel settleFocusAfterAdd decided selection from whether the captured activePanel survived the add, but activePanel legitimately diverges from the user's selection (door selected while dockview auto-activated a neighbor), so a background `dor iframe --surface <N>` replacing untouched pane N stole the user's door selection. Selection now moves only when the replaced pane was the one the user was selected on; the helper returns whether it selected the new surface, which also deletes the recomputed callerReplaced negation and the divergent minimize select formulas (review findings 2 + 7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract the add-path rAF re-assert into a shared reassertPaneFocus helper with two new guards: document.hasFocus() (a background dor command must never yank cross-frame focus out of the host editor) and a not-in-editable- control check (never yank from the inline-rename input; re-parent blur drops focus to <body>, so a focused control means the user chose it). Give orchestrateKill an onRemoved hook and pass the same helper from killPaneImmediately: removing a background pane can collapse a gridview branch and re-parent + blur the selected pane's xterm, and with the wasSelected gate nothing else heals it (review findings 1 + 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dor ensure --minimize's integration-timeout teardown previously no-oped: the throwaway was already a door with no dockview panel, so the early return left a zombie door with a live PTY in the baseboard (and a still-armed typeCommandWhenPromptReady that late OSC could trigger), despite ensure responding with an error and layout.md #12 claiming teardown. The door branch disposes the session, removes the door, and fires the kill event (review finding 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Spec hoist An unselected last-pane kill grabbed the WorkspaceSelectionOverlay - which renders the surviving selection's ring (e.g. a door) - and shrank it to the corner; only claim the overlay when the killed pane owned the selection. Correct the comment/spec claim that the header kill button can produce an unselected kill (dockview activates the pane on pointerdown first; only dor kill and ensure's teardown hit wasSelected=false). Hoist the duplicated 7-field addPanel spec in createContentSurface (review findings 6, 5, 10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion tag Introduce withProgrammaticActivation, a depth-counted tag that marks add-side dockview mutations so onDidActivePanelChange can tell programmatic activation churn apart from user activation. Drop-in replacement for the focus-neutral boolean (a depth counter survives nested tagged mutations, which a boolean's inner finally would unmute early). The module doc is the single home for the design rationale: the dockview-5.2 synchronous-events assumption, and why removal-side echoes are deliberately never tagged (dockview's activate-the-survivor echo self-heals selection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend withProgrammaticActivation to the remaining add-side sites: selectPane/enterTerminalMode's own trailing setActive (previously a no-op only via the implicit refs-before-setActive ordering) and handleReattach's restore mutations + replace-terminal block (previously absorbed only by the door guard). No behavior change; each echo was already neutralized, the tag makes the neutralization explicit so future activation sources can't forget their gate. Removal-side echoes (kill, minimize, auto-spawn) stay untagged on purpose - dockview's activate-the-survivor echo self-heals selection - with the asymmetry recorded in programmatic-activation.ts and layout.md #12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace orchestrateKill's wasSelected snapshot with a live isSelected reader, evaluated at fade start for the overlay ring and again at removal time for the selection tail. Mid-fade selection moves are now honored by design: navigating away from a dying selected pane no longer yanks selection to panels[0], and navigating onto a dying pane adopts a survivor instead of dangling. Those edges were previously covered by accident by dockview's activate-the-survivor echo, so the removal is now tagged with withProgrammaticActivation (moved to lib/, it is React-free) and the echo muted. Prerequisite for deleting the listener's door guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the null-only selectPane gate with explicit adopt-or-leave policy evaluated at spawn time: adopt the replacement when selection is null or dangling (still naming the removed pane - the 0ms kill timer can beat React's ref flush); leave any valid selection alone, doors included. This was previously enforced by the untagged addPanel echo (passthrough) plus the listener's door guard; the add is now tagged and the policy lives at the spawn site, making the door guard redundant for auto-spawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ser intent Tag minimizePane's removal (the last untagged programmatic mutation), then collapse onDidActivePanelChange to its end state: tagged mutation in flight means the mutation site owns selection policy; anything untagged is a dockview-native user interaction (panel drag, DOM focus landing in a surface) and is adopted through the real dispatchers - enterTerminalMode in passthrough, selectPane in command mode, never a bare setSelectedId that can shear the (kind, id) pair. The selectedType==='door' guard is gone: door survival through auto-spawn now rides the spawn-site policy, and two ratified behavior changes land - dragging a pane while a door is selected moves selection onto it, and an embed focusing itself can pull selection off a door, matching panes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dormouse-bot
approved these changes
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Running a
dorcommand from your terminal shouldn't yank you out of it. This makes the surface-creating commands focus-neutral and fixes an analogous focus-steal indor kill:dor ensure--minimize)dor iframedor ab/agent-browserdor killpanels[0]alwaysdor splitdor send/read/list-panes/list-pane-surfaces/versionwere already focus-neutral.How
runSurfaceAdd(focusNeutral, add)— shared helper behinddor ensure/iframe/ab. dockview renders a pane only once it becomes its group's active panel, so aninactiveadd renders blank; instead the pane is added active and the active group is handed straight back to the caller (settleFocusAfterAdd), with theonDidActivePanelChangelistener suppressed across the transition and the caller's DOM focus re-asserted (the split re-parents the caller's grid subtree, which blurs its xterm textarea). Seedocs/specs/layout.mdcorner case Fix dogfooding on Windows #12.orchestrateKill(..., wasSelected)— the kill selection tail now only moves selection when the killed pane was the selected one;killPaneImmediatelycomputesselectedId === killedId.Testing
kill-animationunit test for the unselected-kill case; full lib suite (851 tests),tsc, andpnpm lint:specsall pass.isVisible/isPanelActive/ connected); a directional split blurs the caller's focused textarea (the keyboard-input bug); the replace-within-then-remove path keeps the caller active while the new surface renders.Notes
renderer:'always'dead-end that the following commit replaces) — squash-on-merge if you'd prefer a single commit.layout.md(corner case Fix dogfooding on Windows #12 + kill "Selection tail"),dor-browser.md,dor/.../ensure.mdhelp snapshot.🤖 Generated with Claude Code