Skip to content

perf(workspace): keep terminals alive across Reader, Spotlight and Settings - #757

Merged
Juliusolsson05 merged 8 commits into
mainfrom
perf/focus-modes-retain-terminals
Sep 5, 2026
Merged

Juliusolsson05 merged 8 commits into
mainfrom
perf/focus-modes-retain-terminals

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Problem

With agentViewMode = 'terminal', toggling Reader Mode (or Spotlight, or opening Settings) blanked every pane and re-loaded every terminal on exit: MainSurface rendered the takeover surface instead of GlobalEditorShell → TileTree, so React unmounted every AgentTerminalLeaf (xterm disposed, PTY detached) and on exit mounted the tree from scratch — N × (attach + up to 512 KiB replay + parse + fit/resize handshake) on the renderer thread, a multi-second freeze, lost scrollback, and a burst of attach/resize/screen IPC. Owner's report and trace in #752.

Behaviour

  • RetainedWorkspaceSurface (new, src/renderer/src/app/shell/) keeps the shell subtree mounted while a takeover surface is active: display:none on a wrapper that is display:contents (layout-neutral) otherwise, AgentTerminalOwnerVisibilityProvider visible={false} so hidden panes release their dimension claim and never measure, and a WorkspaceSurfaceHiddenContext for consumers that need "retained but off-screen".
  • MainSurface renders exactly one takeover surface (Settings > Reader > Spotlight, same priority as before) alongside the retained workspace instead of replacing it. The takeover surfaces still bypass the shell for the layout reasons documented there; only the shell's mount lifetime changes.
  • TileLeaf adds the hidden flag to its Tail-All visibility mask next to the editor-fullscreen term, so leaving a takeover is a real false→true transition that re-pins the feed.
  • GlobalEditorShell's fullscreen Escape listener stands down while the surface is hidden, so Escape in Reader Mode exits Reader (owned by useKeybinds) rather than also collapsing a fullscreen editor underneath.

Design decisions

  • Retention over a terminal registry. Hoisting xterm instances out of React (a per-session registry reparented into whichever host shows the session) is the structural fix, now its own issue (perf(terminal): own xterm instances outside the React tree (per-session terminal registry) #760); this PR is the one-day version that uses the pattern the code already trusts for editor fullscreen.
  • Visibility composes across shells (AgentTerminalOwnerVisibilityProvider ANDs with its parent). Review found the editor slot nests inside the retained surface and provided its own value, so React's nearest-provider rule hid the takeover signal from every pane whenever the editor was not fullscreen — retained panes kept their dimension claim, measured a display:none box, left the PTY at Spotlight's size on exit and kept the inline debug terminal disabled. Now a subtree is visible only if every enclosing shell says so.
  • Hidden panes own nothing. useInteractiveOwnership(focused) derives interactive/hidden from the composed visibility context — scoped to the subtree, never the editor store's global flag, which made Spotlight's own visible leaf refuse Enter/y/n under a fullscreen editor. A retained TileLeaf is still the workspace-focused one, so every document-level input router (type-to-focus, paste-to-focus, bare-Enter submit target, dictation hotkey and fallback registration, condition outlet) is gated on it; typing "yes" + Enter in Reader Mode no longer submits through an invisible composer. Caret / xterm refocus effects re-run on reveal but only take an unowned focus (focusIsUnowned): a takeover unmounting leaves focus on <body>, editor-fullscreen exit leaves it in Monaco, which keeps it. TerminalLeaf (shell panes) had no ownership hook; it now resets its sent size when hidden and refits on reveal. Feeds skip the sticky-bottom pin while hidden and re-pin on reveal.
  • Spotlight keeps mounting its own leaf for the focused session. The hidden tile-tree leaf keeps its refcounted PTY attachment and its xterm keeps parsing while hidden — far below a replay, and what editor fullscreen already accepts. data-pane-id is therefore duplicated under Spotlight; lookups take the first in DOM order, which is the takeover surface because MainSurface renders it before the retained workspace (documented at the attribute).
  • Hidden feeds keep rendering. Freezing off-screen feeds is a separate structural item.

Linked issues

Fixes #752. Refs #760 (structural follow-up), #745, #749, #103, #390.

Verification

  • RetainedWorkspaceSurface.renderer.test.tsx (2, real GlobalEditorWorkspaceSlot inside the surface): the dimension claim is released while hidden and restored on reveal; kept while visible even though the slot provides its own value.
  • AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx (+1) and new TerminalLeaf.retention.renderer.test.tsx: a retained pane under the real slot sends exactly one resize on attach, none while hidden, and exactly one more on reveal at the same size.
  • new useInteractiveOwnership.renderer.test.tsx (5): no shell → interactive; hidden surface, fullscreen slot, visible slot inside a hidden surface → not; both visible → interactive, unfocused → not.
  • TerminalDimensionOwnership.renderer.test.tsx (3): the pane terminal stays mounted and hidden under Settings while dimension ownership still hands to the inline debug terminal and returns afterwards (mount count 1, unmounts 0); Reader Mode round trip keeps every pane mounted; Spotlight target-guard adapted to the retained tree.
  • --project renderer for app/, workspace/tile-tree/, features/global-editor/, features/reader/, features/spotlight/, features/feed/ — 20 files, 60 tests passed; npx tsc -b — clean.

Limitations / follow-ups

  • Hidden panes still receive agent-pty-data and session:screen and hidden feeds still re-render on every runtimes replacement; suspending delivery/rendering for off-screen sessions is the next structural step (perf(terminal): own xterm instances outside the React tree (per-session terminal registry) #760 covers the terminal half).
  • With the editor fullscreen AND Reader open, Escape does nothing: useKeybinds skips Reader-exit while the fullscreen editor owns the workspace (pre-existing) and the shell's own Escape listener now stands down while hidden. Reader still exits via its toggle command.
  • No renderer test drives a real TileLeaf under Reader Mode; the derivation that gates every router is pinned by useInteractiveOwnership.renderer.test.tsx across the four shell combinations, and the wiring into each router is a grep-level review item.
  • LazyEntry's retention observer collapses rows under display:none and re-mounts the viewport rows on reveal (same as editor fullscreen today); "a style change rather than a rebuild" holds for terminals, which is what perf(workspace): reader mode, spotlight and settings unmount every terminal pane and replay all PTYs on exit #752 is about.
  • Not verified in the running app yet; the assertions rest on the mount-count and resize-count contracts the tests pin.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa

Current integration verification (2026-09-04)

Integrated merged main feb6690, preserving both retained-visibility behavior and the newer keyed PTY/WebGL/input tests. Head d9b7848 passes both full host CI gates; 20 focused renderer tests pass. Earlier hidden-shell dictation review feedback is fixed in the current head. This retains hidden trees; it does not freeze their React work or implement a global terminal registry. Ready for owner merge confirmation.

Stacked merge order

Final head 878fc37 includes #776, with both wake and retained-terminal regressions preserved. This PR now targets fix/wake-readiness-no-kill; merge #776 first, then this PR into main after GitHub retargeting is confirmed. Fifteen combined wake/host tests pass. Both full host CI gates pass on final stacked head 878fc37 (run 33938163643). Ready for owner merge confirmation.

Juliusolsson05 and others added 3 commits September 3, 2026 16:48
…d Settings

Refs #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…tings instead of unmounting it

MainSurface rendered the focus-takeover surfaces instead of the
GlobalEditorShell subtree, so entering Reader Mode with terminal panes
disposed every xterm and detached every PTY, and leaving re-attached and
replayed up to 512 KiB into each brand-new terminal — a multi-second
freeze and lost scrollback per toggle. Global Editor fullscreen already
retains the workspace under display:none and tells the terminal ownership
boundary that mounted no longer means visible; RetainedWorkspaceSurface
applies the same pattern one level up for the surfaces that bypass the
shell, with a layout-neutral display:contents wrapper when visible.

TileLeaf folds the new hidden flag into its Tail-All mask so un-hiding is
a genuine false→true transition that re-pins, and the shell's fullscreen
Escape listener stands down while hidden so Escape in Reader Mode exits
Reader rather than also collapsing a fullscreen editor underneath.

Fixes #752
Refs #745, #749

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…ship

Review of #757 found the retention design shadowed in the production
tree: the Global Editor slot nests inside the retained surface and
provided its own terminal-visibility value, so React's nearest-provider
rule hid the "Reader/Spotlight/Settings took the screen" signal from every
pane whenever the editor was not fullscreen. Retained panes kept their
dimension claim, measured a display:none box, left the PTY at Spotlight's
size on exit and kept the inline debug terminal disabled. Visibility now
composes: a subtree is visible only if every enclosing shell says so.

The retained focused TileLeaf also kept every document-level input
router — type-to-focus, paste-to-focus, the bare-Enter submit target, the
dictation hotkey, the condition outlet — so typing "yes" + Enter in Reader
Mode submitted a prompt through an invisible composer. Input ownership is
now focused AND visible, and the caret/xterm refocus effects re-run on
reveal, which the remount used to do for free.

Shell panes (TerminalLeaf) had no ownership hook at all and would have
kept Spotlight's PTY size after exit; they now reset their sent size when
hidden and refit on reveal. Feeds skip the sticky-bottom pin while hidden
and re-pin on reveal. Comments that described the takeovers as unmounting
the workspace are corrected.

Refs #752, #760

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
@Juliusolsson05
Juliusolsson05 force-pushed the perf/focus-modes-retain-terminals branch from 1fc8369 to a2ea689 Compare September 3, 2026 23:50
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review blockers addressed in a2ea689 (branch rebased onto main):

  • B1 visibility shadowing: AgentTerminalOwnerVisibilityProvider now composes with its parent (AND), so the editor slot can no longer hide the takeover signal. New RetainedWorkspaceSurface.renderer.test.tsx renders the real GlobalEditorWorkspaceSlot inside a hidden surface (claim released, restored on reveal); the leaf ownership suite gained a round trip asserting exactly one resize on attach, none hidden, one on reveal.
  • B2 hidden input ownership: TileLeaf gates type-to-focus, paste-to-focus, the bare-Enter submit target, dictation, the condition outlet and the caret focus on focused && visible; AgentTerminalLeaf gates dictation and xterm focus on dimensionActive and refocuses on reveal.
  • B3 shell panes: TerminalLeaf tracks visibility, resets its sent size when hidden and refits on reveal; hidden panes never measure.
  • Should-fixes: Feed re-pins sticky-bottom on reveal; stale comments corrected; structural registry split into perf(terminal): own xterm instances outside the React tree (per-session terminal registry) #760; limitations documented in the body.

Re-review requested.

…ontext and keep Monaco's focus on reveal

Re-review of #757: deriving "hidden" from the editor store's global
editorFullscreen flag made Spotlight's own visible leaf — rendered beside
the shell — refuse Enter/y/n on an approval strip whenever the editor was
fullscreen underneath. useInteractiveOwnership now derives interactive and
hidden from the composed visibility context, which is scoped to the
subtree, and TileLeaf's store read is gone.

Reveal refocus (caret, xterm) now only takes an unowned focus: a takeover
surface unmounting leaves focus on <body>, which the pane reclaims as the
remount used to, but editor-fullscreen exit leaves it in Monaco, which
must keep it. Hidden panes also unregister as dictation fallback targets
(enabled, not just focused). TerminalLeaf gains the same retention round-
trip test as the agent leaf, and the ownership derivation is pinned across
the four shell combinations.

Refs #752, #760

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Re-review B4 and should-fixes addressed in the latest commit:

  • B4: useInteractiveOwnership(focused) derives interactive/hidden from the composed visibility context; the useGlobalEditorStore read in TileLeaf is gone. Pinned by useInteractiveOwnership.renderer.test.tsx (no shell → interactive; hidden surface / fullscreen slot / visible slot inside hidden surface → not; both visible → interactive).
  • Reveal refocus only takes an unowned focus (focusIsUnowned), so editor-fullscreen exit leaves the caret in Monaco.
  • Dictation fallback: hidden panes pass enabled: … && !hidden, so they unregister as fallback targets; unfocused visible panes stay registered.
  • TerminalLeaf retention round trip added (TerminalLeaf.retention.renderer.test.tsx).
  • Plan updated with the composing-visibility and "hidden owns nothing" decisions; PR body counts updated (20 files / 60 tests).

@Juliusolsson05 Juliusolsson05 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review round 4 (re-run after the pause; worktree HEAD 07521e0).

VERDICT: REQUEST-CHANGES

Rounds 1-3 findings are all correctly and completely fixed (B1 visibility shadowing — provider now ANDs with parent, pinned by real-slot test; B2 hidden-pane keyboard — TileLeaf and AgentTerminalLeaf fully gate type-to-focus/paste/Enter/dictation/condition/caret; B3 shell pane size/subscription — reset, rAF cancel, refit on reveal, no hidden measurement, pinned by retention test; B4 global store read gone). Z-index/pointer-events, teardown, and memory limitations all check out.

One remaining B2-class hole:

  1. BLOCKER — Hidden focused shell pane still owns the dictation hotkey. TerminalLeaf.tsx:80-87 registers useComposerDictation({ enabled: dictationEnabled, focused, ... }) with no visibility gate, while both siblings gate it (TileLeaf.tsx:662-667: enabled: … && !workspaceHidden, focused: interactive; AgentTerminalLeaf.tsx:100-101: enabled: … && ownerVisible, focused: focused && dimensionActive). The dictation registry prefers the focused target (useComposerDictation.ts:1236-1239, 1255-1290), the hotkey is a native global helper, and the retained tree keeps focusedSessionId on the shell pane during a takeover — so pressing the dictation key in Reader/Spotlight/Settings transcribes straight into the invisible shell's PTY. Fix: mirror the siblings — enabled: dictationEnabled && ownerVisible, focused: focused && ownerVisible.
  2. NIT — ungated focus on attach (AgentTerminalLeaf.tsx:361) runs regardless of visibility; benign today (focus() on display:none is a no-op) but inconsistent with TerminalLeaf.tsx:380.
  3. NIT — hidden-pane HTML-trace MutationObserver keeps recording (TileLeaf.tsx:747-779 gates on htmlDebugPanelOpen && focused but not visibility). Debug-only cost.
  4. NIT — stray space in import list (AgentTerminalLeaf.tsx:19).

Commands: 5 new/changed test files 14/14 PASS; feed/global-editor/reader/spotlight/shell/tile-tree suites 20 files 60/60 PASS; tsc -b clean.

TerminalLeaf registered useComposerDictation with raw enabled/focused,
so a focused shell pane retained under display:none kept claiming the
global dictation hotkey while Reader, Spotlight or Settings owned the
screen — the transcript went straight into the invisible pane's PTY.
Both sibling leaves already gate on visibility; this was the one leaf
kind that didn't, the last B2-class hole from round 3.

Addresses the round-4 review blocker.
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Round-4 blocker fixed (7f6bb76): TerminalLeaf now gates both dictation flags on ownerVisible (enabled: dictationEnabled && ownerVisible, focused: focused && ownerVisible), mirroring TileLeaf and AgentTerminalLeaf — the visibility hook moved above the registration so hook order allows it. New TerminalLeaf.dictationOwnership.renderer.test.tsx pins the contract across a visible → hidden → visible cycle with focused held true the whole time. Nits 2 (ungated focus-on-attach) and 4 (stray import space) also addressed in the same commit; nit 3 (hidden-pane HTML-trace observer) left as debug-only cost, noted for a follow-up. Verification: retention + new dictation suites 2 files / all green; npm run typecheck clean.

…fixes

Preserve keyed PTY delivery, WebGL lifecycle, input engagement and resize coverage while retaining visibility ownership. Twenty focused renderer tests pass after resolving the overlap.

Refs #752
…epair

Preserve both overlapping regression suites and stack on #776 so the integration is tested before either main merge.

Refs #752
@Juliusolsson05
Juliusolsson05 changed the base branch from main to fix/wake-readiness-no-kill September 5, 2026 02:07
Base automatically changed from fix/wake-readiness-no-kill to main September 5, 2026 02:58
@Juliusolsson05
Juliusolsson05 merged commit 2cb739c into main Sep 5, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the perf/focus-modes-retain-terminals branch September 5, 2026 03:05
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.

perf(workspace): reader mode, spotlight and settings unmount every terminal pane and replay all PTYs on exit

1 participant