fix: stop the launcher flash on pane switch and shorten the terminal activation mask - #572
Open
zordhalo wants to merge 3 commits into
Open
fix: stop the launcher flash on pane switch and shorten the terminal activation mask#572zordhalo wants to merge 3 commits into
zordhalo wants to merge 3 commits into
Conversation
Switching panes inside a repo flashed the "Open" launcher over the stage before the incoming session's terminals mounted. The stage guard treats a layout that has not loaded yet the same as one that is genuinely absent (`!sessionLayout` -> render the launcher), so every first visit to a pane showed a tool menu where the terminal had been. The guard predates the launcher: dcouple#512 replaced a dim "No Active Panel" placeholder with an actionable list, which made a pre-existing flash far more visible. Track which session's layout load has settled and render the launcher only for a session whose layout is known to be absent; a switch still in flight shows the plain stage surface instead. Also drops a redundant read from the switch path. panelApi.getActivePanel re-runs the identical getSessionPanels query to find a panel that is already in `loadedPanels` -- present since the initial release and carried through three rewrites of these lines. The layout read now goes out alongside the panel read rather than head-to-tail, and a cancellation guard keeps a fast A -> B -> A flip from landing a stale response over fresher store state. A failed panel read settles onto the launcher rather than leaving the stage blank. Measured on the real app (Electron, real worktrees): the launcher no longer appears on any switch (was 42 ms on first visit to each pane). Claude-Session: https://claude.ai/code/session_01GRsgQBUj4AKUtxCAWFLsfg
…delay The mask held for a flat 200 ms after paint plus a 150 ms overlay linger. Both were standing in for conditions rather than expressing them: a69c8b5 raised the first from 0 to 200 ms because reflow could continue past the first paint, and the linger existed "so the terminal underneath has finished painting". Wait for that condition instead -- two consecutive frames agreeing on the container width and the rendered grid -- and keep the old values only as ceilings. Two things the timers were quietly guaranteeing are now explicit: - The mask must outlast REFOCUS_DELAYED_REFRESH_MS, not just the first paint. That backstop re-runs the chosen depth, and for a full refresh that is a reset+replay; reset() drops the selection. 200 + 150 > 300 happened to satisfy this, an early settle does not, so the lift is chained to the backstop. terminal-selection-popover failed deterministically on the early-lift version; the new session-switch-stage case pins it directly. - The ceiling has to be a real timer, not a frame budget. Frames stop entirely behind a hidden window, a throttled rAF, or a faked clock, and a frame-counted ceiling stranded the mask up -- terminal-blur-recovery failed 2 runs in 5 before the sampler was raced against a setTimeout. Also stops the width-settle loop paying a mandatory tick. `lastWidth` started at 0, so a first measurement that was already viable read as a changed width and always burned a 50 ms retry. It starts at -1 now and a viable first measurement is confirmed on the next frame. The two-agreeing- measurements invariant from dcouple#233 is unchanged; only the cost of collecting them. This is not yet a win on the clock. Measured per panel, the incoming terminal's mask goes 576 ms -> 675 ms: the width-loop saves ~35 ms, but chaining the lift to the backstop costs slightly more than the fixed delays did, because those delays already landed after it. The mask's floor is REFOCUS_DELAYED_REFRESH_MS and nothing here changes that. The value is the mechanism and the two invariants it makes explicit -- shortening the mask needs the backstop itself to stop being destructive. Activation depth policy is untouched: full masked reset+replay still runs on every panel activation, so neither the v2.4.11 ghosting nor the v2.4.14 shared-atlas corruption can recur. Claude-Session: https://claude.ai/code/session_01GRsgQBUj4AKUtxCAWFLsfg
The activation mask's floor was REFOCUS_DELAYED_REFRESH_MS: the delayed backstop re-runs the full reset+replay, reset() drops the selection, so the mask had to stay up until it landed. Shortening the mask meant making that backstop stop being destructive. It now reset+replays only when it has something to repair, and repaints otherwise. Two things count as something to repair: - The geometry moved since the first refresh rendered. That is the case the backstop was written for -- a refresh that raced layout, or a renderer that attached after it. Unchanged geometry means a re-render cannot discover anything new. - The first refresh never landed. handleRefreshTerminal bails on a mid-layout container, and when it does the backstop is the path that actually populates the buffer -- not a redundant repair at all. It now reports whether it ran so the backstop can tell the difference. This was not obvious from the code and cost a round of wrong assumptions: terminal-selection-popover fails outright without this gate. A live selection also forces the repaint path. A background repair should not destroy what the user is doing, and a user selecting text can already read the screen, so the repair has nothing urgent to fix; the next activation or the ResizeObserver repeats it once the selection is gone. With the backstop non-destructive, the mask lifts on the geometry settle instead of waiting the backstop out. Measured per panel on a session switch: 675 ms -> 332 ms (576 ms before this branch). Activation depth policy is otherwise untouched -- the first refresh on every panel activation is still the full masked reset+replay, so neither the v2.4.11 ghosting nor the v2.4.14 shared-atlas corruption can recur. What changed is the depth of the *backstop* that follows it. Claude-Session: https://claude.ai/code/session_01GRsgQBUj4AKUtxCAWFLsfg
zordhalo
force-pushed
the
fix/pane-switch-stage-and-mask
branch
from
September 1, 2026 01:59
348ad7e to
1fd89c4
Compare
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.
Description
Switching panes inside a repo showed the wrong thing twice over: the "Open" launcher flashed where the terminal had been, and the terminal that arrived sat behind an activation mask longer than it needed to. Three commits, each standing on its own.
1.
fix:stop the Open launcher standing in for a loading pane stageThe stage guard treats a layout that has not loaded yet the same as one that is genuinely absent (
!sessionLayout-> render the launcher), so every first visit to a pane showed a tool menu where the terminal had been. The guard predates the launcher; #512 replaced a dim "No Active Panel" placeholder with an actionable list, which made a pre-existing flash far more visible.Now the code tracks which session's layout load has settled and renders the launcher only for a session whose layout is known to be absent. A switch still in flight shows the plain stage surface. Also drops a redundant read:
panelApi.getActivePanelre-ran the identicalgetSessionPanelsquery to find a panel already inloadedPanels. The layout read now goes out alongside the panel read rather than head-to-tail, with a cancellation guard so a fast A -> B -> A flip cannot land a stale response over fresher store state. A failed panel read settles onto the launcher instead of leaving the stage blank.Measured on the real app (Electron, real worktrees): the launcher no longer appears on any switch (was 42 ms on first visit to each pane).
2.
refactor:lift the terminal activation mask on a settle, not a fixed delayThe mask held for a flat 200 ms after paint plus a 150 ms overlay linger. Both were standing in for conditions rather than expressing them. It now waits for the condition itself — two consecutive frames agreeing on container width and rendered grid — and keeps the old values only as ceilings. Two things the timers were quietly guaranteeing are now explicit: the mask must outlast
REFOCUS_DELAYED_REFRESH_MS(that backstop re-runs a reset+replay, andreset()drops the selection), and the ceiling has to be a real timer rather than a frame budget, since frames stop entirely behind a hidden window or a faked clock.Also stops the width-settle loop paying a mandatory tick:
lastWidthstarted at 0, so a viable first measurement read as a changed width and always burned a 50 ms retry. The two-agreeing-measurements invariant from #233 is unchanged; only the cost of collecting them.This commit alone is not a win on the clock (576 ms -> 675 ms per panel) — it buys the mechanism that the next commit needs.
3.
perf:let the activation backstop repair without resetting the terminalThe mask's floor was
REFOCUS_DELAYED_REFRESH_MS, because the delayed backstop re-ran the full destructive reset+replay. It now reset+replays only when it has something to repair — the geometry moved since the first refresh rendered, or the first refresh never landed (handleRefreshTerminalbails on a mid-layout container, and in that case the backstop is the path that actually populates the buffer) — and repaints otherwise. A live selection also forces the repaint path: a background repair should not destroy what the user is doing.Per panel on a session switch: 675 ms -> 332 ms (576 ms before this branch).
Activation depth policy is untouched
The first refresh on every panel activation is still the full masked reset+replay, so neither the v2.4.11 ghosting nor the v2.4.14 shared-atlas corruption can recur. What changed is the depth of the backstop that follows it.
Type of Change
Checklist
pnpm typecheckandpnpm lintlocallypnpm electron-devCritical Areas Modified
SessionView/ProjectViewpanel + layout reads are now issued in parallel with a cancellation guard; no IPC contracts changedTesting
New spec
tests/session-switch-stage.spec.ts(8 cases) covers the panel/layout race and pins the launcher-flash regression directly, including the selection-preserving backstop case. Verified load-bearing: the new tests fail against the pre-fixSessionView.tests/electronApiMock.tsgains configurable panel/layout load latency and call timestamps so the race is reproducible rather than timing-dependent.Full Playwright suite run: the new specs pass, and the 4 remaining failures are pre-existing flakes unrelated to this branch (
terminal-blur-recoveryboundary cases reproduce identically on the pre-branch baseline).Additional Notes
Rebased onto
upstream/mainat v2.4.95, so this merges cleanly with no merge commit. The only conflict was intests/electronApiMock.ts, where this branch andopenExternalOutcomeappended fields to the same options type — both kept, no logic involved. Verified the rebase preserved the change exactly: the diff against the new base is byte-for-byte identical to the diff against the old one.Re-verified on the new base:
pnpm typecheckandpnpm lintclean, all 8 cases in the new spec passing.The three commits are independently reviewable and the commit messages carry the full reasoning, including the two failed lightening attempts that shaped the final design.