Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
94 changes: 94 additions & 0 deletions docs/superpowers/plans/2026-09-03-focus-modes-retain-terminals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Workspace: keep terminals alive across Reader, Spotlight and Settings

## 2026-09-04 integration

Retain the merged dispatcher/WebGL/engagement/resize coverage alongside hidden
visibility and dictation tests. Stack this PR on the validated wake fix #776,
preserving both wake-without-readiness and retention-reveal regressions in the
shared host suite. Merge #776 first, then this PR; no live app restart is part
of validation. Earlier standalone integration passed full CI before stacking.

Fixes #752. Refs #745, #749, #103, #390.

## Problem

`MainSurface` renders Settings, `ReaderView` and `SpotlightView` *instead of*
the `GlobalEditorShell → TileTree` subtree. Entering any of them unmounts
every pane; with `agentViewMode = 'terminal'` that disposes every xterm and
detaches every PTY, and leaving re-mounts 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
`agent-pty-attach` / `resize` / `session:screen` IPC. The app already
solves this for Global Editor fullscreen by RETAINING the workspace under
`display:none` and telling the terminal ownership boundary that "mounted"
no longer means "visible" (`GlobalEditorWorkspaceSlot`,
`AgentTerminalOwnerVisibilityProvider`).

## Design

- `RetainedWorkspaceSurface` (app/shell) wraps the shell subtree: when a
takeover surface is active it sets `display:none` on a layout-neutral
wrapper (`display:contents` otherwise), provides
`AgentTerminalOwnerVisibilityProvider visible={false}` so hidden panes
neither claim dimension ownership nor measure, and provides a new
`WorkspaceSurfaceHiddenContext` for anything that must know the subtree
is retained-but-hidden.
- `MainSurface` always renders the retained workspace and renders the
takeover surface (Settings / Reader / Spotlight) alongside it. The
takeover surfaces keep bypassing `GlobalEditorShell` for the layout
reasons documented there; only the shell's mount lifetime changes.
- `TileLeaf` folds `WorkspaceSurfaceHiddenContext` into its Tail-All
visibility mask next to the editor-fullscreen term, so un-hiding is a
genuine false→true transition that re-pins (same reasoning as the
existing mask comment).
- `GlobalEditorShell`'s fullscreen Escape listener ignores keys while the
surface is hidden, so Escape in Reader Mode exits Reader (owned by
`useKeybinds`) instead of also collapsing a fullscreen editor underneath.

- **Visibility composes across shells** (review). `AgentTerminalOwner-
VisibilityProvider` ANDs with its enclosing provider: the editor slot
nests inside the retained surface and provides its own value, and React's
nearest-provider rule would otherwise hide the takeover signal from every
pane whenever the editor is 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.
- **Hidden panes own nothing** (review). `useInteractiveOwnership(focused)`
derives `interactive = focused && visible` and `hidden` from that composed
context — never from the editor store's global flag, which made
Spotlight's own visible leaf refuse Enter/y/n under a fullscreen editor.
TileLeaf feeds it to type-to-focus, paste-to-focus, the bare-Enter submit
target, the dictation hotkey (`enabled` too, so a hidden pane is not even
a fallback target) and the condition outlet; AgentTerminalLeaf gates
dictation and xterm focus the same way; TerminalLeaf (no ownership hook of
its own) resets its sent size when hidden and refits on reveal.
- **Reveal refocus only takes an unowned focus** (`focusIsUnowned`): a
takeover unmounting leaves focus on <body>, which the pane reclaims as the
remount used to; editor-fullscreen exit leaves it in Monaco, which must
keep it.
- Feeds skip the sticky-bottom pin while hidden and re-pin on reveal.

Spotlight mounts its own leaf for the focused session; the hidden tile-
tree leaf keeps its PTY attachment (refcounted in main) and its xterm
keeps parsing while hidden. That parse cost is far below a replay and is
exactly what editor fullscreen already accepts. Hidden feeds keep
rendering; freezing them is a separate structural item.

Not in scope: hoisting xterm instances out of React (the structural fix
tracked in #752's second half and the architecture audit).

## Verification

- `TerminalDimensionOwnership.renderer.test.tsx`: Settings/Reader/
Spotlight keep the pane terminal MOUNTED (mount count stays 1 across
enter/exit) and hidden; dimension ownership still hands to the inline
debug terminal while hidden and returns afterwards.
- `RetainedWorkspaceSurface.renderer.test.tsx`: the real editor slot inside
a hidden surface releases the dimension claim and restores it on reveal.
- `useInteractiveOwnership.renderer.test.tsx`: no shell → interactive;
hidden surface, fullscreen slot, visible slot inside a hidden surface →
not; both visible → interactive, unfocused → not.
- `AgentTerminalLeaf.dimensionOwnership` and `TerminalLeaf.retention`
round trips: one resize on attach, none while hidden, one on reveal.
- Existing Spotlight target-guard test adapted to the retained tree.
- `npx tsc -b`; renderer suites for app/shell, tile-tree, global-editor,
reader, spotlight, feed.
31 changes: 26 additions & 5 deletions src/renderer/src/app/shell/MainSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DispatchLayout } from '@renderer/workspace/dispatch/DispatchLayout'
import { TileTree } from '@renderer/workspace/tile-tree/TileTree'
import { NewAgentPlacementOverlay } from '@renderer/features/workspace/ui/NewAgentPlacementOverlay'
import { usePlacementOverlay } from '@renderer/features/workspace/surfaces/usePlacementOverlay'
import { RetainedWorkspaceSurface } from './RetainedWorkspaceSurface'
import { WelcomeEmpty } from './WelcomeEmpty'

// The main-area mode routing, extracted verbatim from App.tsx (#494).
Expand Down Expand Up @@ -37,6 +38,16 @@ export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void }
? state.tabs.some(tab => tab.id === spotlightTabId)
: false

// Exactly one takeover surface at a time, in the priority order the old
// if/else ladder had. Settings wins over Reader wins over Spotlight.
const takeover = settingsPageOpen
? 'settings'
: workspace.readerMode && readerModeTabExists
? 'reader'
: workspace.spotlight && spotlightTabExists
? 'spotlight'
: null

return (
<>
{/*
Expand All @@ -61,6 +72,15 @@ export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void }
the desired behaviour: focus modes are a temporary
context, not a "close the editor" instruction.

WHY the workspace is RETAINED, not replaced, under a takeover (#752):
the takeover surfaces used to render instead of the shell, which
unmounted every pane — every xterm disposed, every PTY detached,
and on exit every terminal re-attached and replayed its whole
buffer. RetainedWorkspaceSurface keeps the shell mounted under
display:none while a takeover is active, the same way Global
Editor fullscreen retains it, so leaving Reader Mode is a style
change rather than a rebuild.

WHY TileTabs / Dispatch / TileTree stay inside the shell:
These ARE the workspace. The point of the editor overlay
is to be alongside them, so reading code does not require
Expand All @@ -72,19 +92,20 @@ export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void }
positions — survives toggling between Dispatch / TileTree
and Welcome.
*/}
{settingsPageOpen ? (
{takeover === 'settings' ? (
<SettingsPage
onClose={closeSettingsPage}
workspace={workspace}
settings={settings}
onChange={setSettings}
onReset={resetSettings}
/>
) : workspace.readerMode && readerModeTabExists ? (
) : takeover === 'reader' ? (
<ReaderView workspace={workspace} />
) : workspace.spotlight && spotlightTabExists ? (
) : takeover === 'spotlight' ? (
<SpotlightView workspace={workspace} agentViewMode={settings.agentViewMode} />
) : (
) : null}
<RetainedWorkspaceSurface hidden={takeover !== null}>
<GlobalEditorShell workspace={workspace}>
{workspace.tileTabs ? (
<TileTabsView workspace={workspace} agentViewMode={settings.agentViewMode} />
Expand Down Expand Up @@ -129,7 +150,7 @@ export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void }
<WelcomeEmpty onNewTabRequest={onNewTabRequest} />
)}
</GlobalEditorShell>
)}
</RetainedWorkspaceSurface>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import { GlobalEditorWorkspaceSlot } from '@renderer/features/global-editor/ui/GlobalEditorWorkspaceSlot'
import {
AgentTerminalOwnershipProvider,
MountedAgentTerminalOwner,
useAgentTerminalDimensionActive,
useHasAgentTerminalDimensionClaim,
} from '@renderer/workspace/terminal/AgentTerminalOwnership'

import { RetainedWorkspaceSurface } from './RetainedWorkspaceSurface'

// #752 review: the editor slot nests INSIDE the retained surface and provides
// its own visibility value. If the two did not compose, a hidden workspace
// would keep every pane's dimension claim and the whole retention design
// would be dead in the production tree. This renders the real slot.

afterEach(() => cleanup())

function Probe() {
const claimed = useHasAgentTerminalDimensionClaim('s1')
const active = useAgentTerminalDimensionActive()
return <div data-testid="probe" data-claimed={String(claimed)} data-active={String(active)} />
}

function tree(hidden: boolean) {
return (
<AgentTerminalOwnershipProvider>
<RetainedWorkspaceSurface hidden={hidden}>
<GlobalEditorWorkspaceSlot open editorFullscreen={false} splitWorkspaceWidth="60%">
<MountedAgentTerminalOwner sessionId="s1">
<Probe />
</MountedAgentTerminalOwner>
</GlobalEditorWorkspaceSlot>
</RetainedWorkspaceSurface>
</AgentTerminalOwnershipProvider>
)
}

describe('RetainedWorkspaceSurface visibility composition', () => {
it('releases the dimension claim through the editor slot while hidden and restores it on reveal', () => {
const view = render(tree(true))
expect(screen.getByTestId('probe').dataset.claimed).toBe('false')
expect(screen.getByTestId('probe').dataset.active).toBe('false')

view.rerender(tree(false))
expect(screen.getByTestId('probe').dataset.claimed).toBe('true')
})

it('keeps the claim while visible even though the slot provides its own value', () => {
render(tree(false))
expect(screen.getByTestId('probe').dataset.claimed).toBe('true')
})
})
60 changes: 60 additions & 0 deletions src/renderer/src/app/shell/RetainedWorkspaceSurface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createContext, useContext } from 'react'
import type { ReactNode } from 'react'

import { AgentTerminalOwnerVisibilityProvider } from '@renderer/workspace/terminal/AgentTerminalOwnership'

// The workspace subtree that survives focus-takeover surfaces (#752).
//
// WHY the workspace is hidden rather than unmounted while Settings, Reader
// Mode or Spotlight own the screen: every pane in the tile tree owns live
// state that is expensive or impossible to rebuild — an xterm instance with
// its PTY attachment and scrollback, a feed's scroll position, Monaco
// buffers. MainSurface used to render the takeover surface INSTEAD of the
// shell, so entering Reader Mode with terminal panes disposed every xterm
// and leaving re-attached every PTY and replayed up to 512 KiB into each
// brand-new terminal: a multi-second freeze and lost scrollback per toggle.
// Global Editor fullscreen had the same problem and solved it by retaining
// the subtree under display:none (GlobalEditorWorkspaceSlot); this is the
// same pattern one level up, for the surfaces that bypass the shell.
//
// WHY `display: contents` when visible: the wrapper must be layout-neutral so
// the shell's own flex sizing is unchanged; a hidden wrapper is `display:
// none`, which removes the subtree from layout (scrollHeight 0, no paint) but
// keeps every component mounted.
//
// WHY the terminal visibility provider sits here: "mounted" no longer means
// "can measure a viewport". MountedAgentTerminalOwner reads this to withhold
// the dimension claim while hidden, exactly as it does for editor fullscreen,
// so a hidden pane neither fights the debug inline terminal nor sends a
// zero-sized resize to the PTY.
//
// WHY a separate hidden context: some consumers need "is my subtree retained
// but off-screen" for reasons unrelated to terminals — TileLeaf's Tail-All
// mask must turn false while hidden so that un-hiding is a real false→true
// transition that re-pins the feed (see the mask comment in TileLeaf).
export const WorkspaceSurfaceHiddenContext = createContext(false)

export function useWorkspaceSurfaceHidden(): boolean {
return useContext(WorkspaceSurfaceHiddenContext)
}

export function RetainedWorkspaceSurface({
hidden,
children,
}: {
hidden: boolean
children: ReactNode
}) {
return (
<WorkspaceSurfaceHiddenContext.Provider value={hidden}>
<AgentTerminalOwnerVisibilityProvider visible={!hidden}>
<div
data-testid="retained-workspace-surface"
style={hidden ? { display: 'none' } : { display: 'contents' }}
>
{children}
</div>
</AgentTerminalOwnerVisibilityProvider>
</WorkspaceSurfaceHiddenContext.Provider>
)
}
Loading