Skip to content

fix: stop the launcher flash on pane switch and shorten the terminal activation mask - #572

Open
zordhalo wants to merge 3 commits into
dcouple:mainfrom
zordhalo:fix/pane-switch-stage-and-mask
Open

fix: stop the launcher flash on pane switch and shorten the terminal activation mask#572
zordhalo wants to merge 3 commits into
dcouple:mainfrom
zordhalo:fix/pane-switch-stage-and-mask

Conversation

@zordhalo

@zordhalo zordhalo commented Sep 1, 2026

Copy link
Copy Markdown

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 stage

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; #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.getActivePanel re-ran the identical getSessionPanels query to find a panel already in loadedPanels. 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 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. 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, and reset() 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: lastWidth started 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 terminal

The 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 (handleRefreshTerminal bails 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

  • Bug fix (non-breaking change which fixes an issue)
  • Performance improvement
  • Code refactoring

Checklist

  • I have read the CONTRIBUTING.md guidelines
  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (no doc-facing behaviour changed)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally with pnpm electron-dev

Critical Areas Modified

  • Session output handling (requires explicit permission)
  • Timestamp handling
  • State management/IPC events — SessionView/ProjectView panel + layout reads are now issued in parallel with a cancellation guard; no IPC contracts changed
  • Diff viewer CSS

Testing

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-fix SessionView. tests/electronApiMock.ts gains 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-recovery boundary cases reproduce identically on the pre-branch baseline).

Additional Notes

Rebased onto upstream/main at v2.4.95, so this merges cleanly with no merge commit. The only conflict was in tests/electronApiMock.ts, where this branch and openExternalOutcome appended 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 typecheck and pnpm lint clean, 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant