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
105 changes: 105 additions & 0 deletions docs/superpowers/plans/2026-09-05-pane-runtime-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Per-pane runtime subscriptions

Status: implemented; final checks/PR review in progress. Refs #763. Base: main 5d641845.

## Invariants and scope

Runtime-only updates and composer draft changes must not execute App or walk
unrelated tile trees. Layout/controller state remains React-owned; do not freeze
action closures. Commands and IPC must read the current store synchronously.
Session observers retain layout-effect ordering for lifecycle observations,
picker invalidation and autosave. Reader, headers and inspection surfaces must
continue subscribing to the state they paint. No lifecycle/readiness policy,
terminal mounting/resize ownership, screen transport or diagnostics policy change.

## Steps

1. Remove runtime-map and invisible draft-version invalidations from the root
controller. Keep an immediately current imperative runtime read boundary.
2. Mount per-session runtime observers below the controller; keep autosave
invalidation separate from pane painting. Preserve provider observation
chronology, timers and teardown behavior.
3. Add per-session subscribed leaf boundaries and layout-only context access;
migrate Reader, headers and other runtime consumers explicitly. Keep broad
inspection surfaces reactive until narrowed individually, rather than
silently rendering stale state.
4. Add deterministic render-count/freshness regressions, run relevant renderer
suites, full typecheck and current CI. Record before/after counts rather than
infer a CPU percentage. Open a complete PR; never merge without approval.

## Coordination

#808 independently fixes worktree projection/replay waste. #762 and #767 remain
subsequent independent increments; A6 owns #802–805. Older mixed experimental
worktrees are not edited or committed wholesale. Preserve root package-lock.json.

## Evidence and review boundaries

`runtimeIsolation.renderer.test.tsx` mounts the real workspace controller,
store, helpers, draft/autosave path and subscribed tile boundaries. It stubs
provider paint and external boot/event ingress so it cannot start live agents.
The control arm restores only the old root runtime-map subscription. For 100
committed single-session updates: controller and unrelated-pane renders are
100/100 in the control and 0/0 in the optimized arm; the affected pane paints
100 times in both. This is a deterministic invalidation measurement, not an
app-wide CPU or typing-latency claim.

Additional regressions cover synchronous draft reads before React commit,
debounced persistence and clear/undo, session replacement subscription routing,
fresh focus actions, late rendered-lease cleanup in Terminal mode, and exactly
once lifecycle publication before passive visibility with the retired run ID.

Review caught two cases beyond the earlier experiment: fresh inline arrays
would defeat a memo boundary, so leaf props are explicit; removing root runtime
renders also removes incidental lease cleanup, so hygiene now subscribes only
to picker/lease signals. Tab counts and related headers select painted status
values, while Reader subscribes to its chosen runtime.

Broad inspection/context consumers intentionally remain reactive; narrowing
individual debug/modal subscriptions further is not silently approximated.
No transport or backend processing changed. Local Node 25 exposes an incomplete
native localStorage without a backing file; renderer checks run with
NODE_OPTIONS=--no-experimental-webstorage so happy-dom owns storage, matching the
Node 24 CI environment. The initial run exposed this environment issue and four
outdated store/context mocks; production guards were not weakened to hide them.

Verification: full renderer run passed 115 files / 499 tests before adding the
last three regression cases; all six targeted isolation cases now pass. Final
full run: 501 passed, one timeout in the unchanged lazy-prose dynamic-import
test (existing #700), under substantially increased machine load (146.97 s
suite versus 24.52 s earlier). No retry policy, timeout, or test assertion was
weakened. Full typecheck, test contract and checked-in fixture privacy gates
passed during implementation; final typecheck and public CI tracked in the PR.

## Independent review resolution (heads cccae6e1 / a96f00a2)

Both reviewers approved; one latent coupling fixed in this increment. PaneHeader
shares the phone bundle, whose stub store (appStateHooks.ts) has NO
`workspaceRuntimes` key; the new related-status store read was safe only because
SessionView passes an empty chip list. The read is now optional-chained with a
WHY comment, and a dedicated renderer regression mounts the two phone shapes
(empty chips; keyless store with chips) plus the desktop store path. Also
adopted from review: `getRuntime`'s shared fallback runtime documents its
never-mutate invariant; `useFeedDebugPersist` no longer takes a render-time
snapshot it ignored; the draft-version signal types honestly as `() => void`
instead of a React state setter whose argument was discarded. The one remaining
runtime-derived root invalidation is the picker/lease shallow subscription in
useRenderedLeaseHygiene — it fires only on user commands, is no-op guarded, and
is the previous universal behavior; kept deliberately.

## Post-review main merge and merged-tree CI (head c8126f48 + origin/main)

Reviewer confirmation landed for heads cccae6e1/a96f00a2 and 29e2dc81/c8126f48,
then pushing the fix head exposed what the stale worktree base hid: origin/main
had advanced past merge-base 5d641845 (external operator toolkit #812, MCP tool
policy #818) while this branch was in review, and those PRs added new control
tests that call the pre-isolation hook signatures. The merged-tree quality-gate
caught exactly two TS errors (control.renderer.test.tsx's third argument to
useDraftActions; preferences.renderer.test.tsx's 3-arg useWorkspaceHelpers).
origin/main was merged into this branch (no production conflicts), and the two
tests were adapted: the draft harness now passes a no-op bumpDraftChanges (it
reads drafts imperatively via inspectAgentDraft), and the preferences harness
passes only (setRuntimes, refs) since useWorkspaceHelpers now reads runtimes
through refs and toggles via the store updater. No production code changed in
this step. Merged-tree verification: forced typecheck clean, full renderer run
123 files / 523 tests green (Node 24, two workers), worktree suites unaffected.
1 change: 1 addition & 0 deletions src/renderer/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default function App() {

return (
<WorkspaceProvider workspace={workspace}>
{workspace.runtimeServices}
<div className="relative h-screen flex flex-col bg-canvas text-ink font-code min-h-0">
<SetupGate />
<RestoreBanner />
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/app/shell/MainSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAppStore } from '@renderer/app-state/hooks'
import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
import { useWorkspaceLayoutContext } from '@renderer/workspace/WorkspaceContext'
import { SettingsPage } from '@renderer/features/settings/ui/SettingsPage'
import { ReaderView } from '@renderer/features/reader/ui/ReaderView'
import { SpotlightView } from '@renderer/features/spotlight/ui/SpotlightView'
Expand All @@ -21,7 +21,7 @@ import { WelcomeEmpty } from './WelcomeEmpty'
// clickable escape hatch. Otherwise the main area renders null
// and the app looks bricked.
export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void }) {
const workspace = useWorkspaceContext()
const workspace = useWorkspaceLayoutContext()
const settings = useAppStore(state => state.settings)
const setSettings = useAppStore(state => state.setSettings)
const resetSettings = useAppStore(state => state.resetSettings)
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/app/shell/RestoreBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'

import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
import { useWorkspaceLayoutContext } from '@renderer/workspace/WorkspaceContext'

// WHY render this above TabBar instead of as a toast:
//
Expand All @@ -23,7 +23,7 @@ import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
const COLLAPSE_AFTER_MS = 60_000

export function RestoreBanner() {
const workspace = useWorkspaceContext()
const workspace = useWorkspaceLayoutContext()
const message: string | null =
workspace.restoreStatus === 'partial-restore'
? 'Workspace partially restored — autosave is disabled to protect your saved state. Restart Agent Code after fixing the underlying spawn or proxy issue.'
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/app/shell/SettingsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { SystemPerfHeader } from '@renderer/features/system-perf/ui/SystemPerfHe
import { UsageHeaderIndicator } from '@renderer/features/usage/ui/UsageHeaderIndicator'
import { useAppStore } from '@renderer/app-state/hooks'
import { useCaffeinateStore } from '@renderer/features/caffeinate/store'
import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
import { useWorkspaceLayoutContext } from '@renderer/workspace/WorkspaceContext'

// Settings bar — compact row under tabs holding app chrome.
// (Extracted verbatim from App.tsx by #494.)
export function SettingsBar() {
const workspace = useWorkspaceContext()
const workspace = useWorkspaceLayoutContext()
const settings = useAppStore(state => state.settings)
const setSettings = useAppStore(state => state.setSettings)
const performancePanelOpen = useAppStore(state => state.performancePanelOpen)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('@renderer/app-state/hooks', () => ({

vi.mock('@renderer/workspace/WorkspaceContext', () => ({
useWorkspaceContext: () => harness.workspace,
useWorkspaceLayoutContext: () => harness.workspace,
}))

vi.mock('@renderer/features/workspace/surfaces/usePlacementOverlay', () => ({
Expand Down Expand Up @@ -95,6 +96,7 @@ describe('terminal dimension ownership across main-surface takeovers', () => {
root: { type: 'leaf', sessionId: 'session-1' },
}
harness.appState = {
workspaceRuntimes: {},
debugPanelOpen: true,
feedDebugPanelOpen: false,
proxyDebugPanelOpen: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@renderer/app-state/store'
Expand All @@ -17,8 +16,12 @@ it('inserts dynamic project context into the named agent without following focus
sessions: { target: { kind: 'claude', cwd: '/target', providerSessionId: 'native-target' }, other: { kind: 'codex', cwd: '/other', providerSessionId: 'native-other' } }, detachedSessions: {}, buried: [],
}, workspaceRuntimes: { target: emptyRuntime(), other: { ...emptyRuntime(), draftInput: 'Other human draft' } } })
const mounted = renderHook(() => {
const [, setVersion] = useState(0), setRuntimes = useAppStore.getState().setWorkspaceRuntimes
return { ...useDraftActions(setRuntimes, (id, patch) => setRuntimes(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })), setVersion), restoreStatus: 'fresh' }
const setRuntimes = useAppStore.getState().setWorkspaceRuntimes
// The production wiring passes draftChanges.bump, which forces draft
// surfaces to re-read through a React version state. This harness reads
// drafts imperatively via inspectAgentDraft, so a stable no-op bump is
// behaviorally faithful to the old setVersion argument.
return { ...useDraftActions(setRuntimes, (id, patch) => setRuntimes(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })), () => {}), restoreStatus: 'fresh' }
})
const resolveTranscriptPaths = vi.fn(async requests => requests.map((request: object) => ({ ...request, transcriptPath: '/recorded/source.jsonl', exists: true })))
window.api = { ...originalApi, resolveTranscriptPaths }
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/features/reader/ui/ReaderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { extractAssistantInProgress } from '@shared/parsers/extractAssistant'
import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind'
import { assistantUuidsWithText, extractAssistantByUuid } from '@renderer/lib/copyAssistant'
import { resolveTabSessions } from '@renderer/workspace/queries'
import { useSessionRuntime } from '@renderer/workspace/useSessionRuntime'
import { dispatchSessionIdsForTab } from '@renderer/workspace/dispatch/dispatchSelectors'
import type { SessionId, Workspace } from '@renderer/workspace/workspaceStore'
import { PaneToast } from '@renderer/workspace/tile-tree/TileLeaf/PaneToast'
Expand Down Expand Up @@ -135,7 +136,7 @@ function ReaderBody({
sessionId: SessionId
sessionIds: SessionId[]
}) {
const runtime = workspace.getRuntime(sessionId)
const runtime = useSessionRuntime(workspace, sessionId)
const meta = workspace.state.sessions[sessionId]
// Use the pane's actual provider for the screen extractor rather than
// the old `=== 'codex' ? 'codex' : 'claude'` negation, which collapsed
Expand Down
25 changes: 17 additions & 8 deletions src/renderer/src/workspace/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { createContext, useContext } from 'react'
import type { ReactNode } from 'react'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { useAppStore } from '@renderer/app-state/hooks'

// WHY this exists (issue #494): App.tsx was the only owner of the
// useWorkspace() hook value and therefore had to mount every feature
// surface itself, prop-drilling `workspace` into ~15 components. This
// context lets surface wrappers (app/surfaces/registry.tsx) be
// self-contained files that App never has to know about.
//
// Re-render semantics — deliberately unchanged from prop drilling: the
// context value is the object useWorkspace() returns, which has a fresh
// identity on every App render, so every consumer re-renders whenever
// App does. That is exactly what the prop-drilled components already
// did. Do NOT try to memoize the workspace object here to "optimize" —
// its methods close over current state and a stale snapshot is a
// correctness bug, not a perf win.
// Layout/controller changes still publish a new context because actions may
// close over that state. Runtime traffic no longer renders the controller:
// panes subscribe by session, while useWorkspaceContext below explicitly keeps
// broad inspection surfaces reactive. Do not freeze action closures or assume
// an imperative getRuntime read constitutes a React subscription.
//
// NOTE for the remote client: this file must stay Electron-free (it is —
// pure React). The phone bundle never mounts WorkspaceProvider, so
Expand All @@ -32,7 +31,7 @@ export function WorkspaceProvider({
return <WorkspaceContext.Provider value={workspace}>{children}</WorkspaceContext.Provider>
}

export function useWorkspaceContext(): Workspace {
export function useWorkspaceLayoutContext(): Workspace {
const workspace = useContext(WorkspaceContext)
if (!workspace) {
// Loud failure beats a silent null: a surface rendered outside the
Expand All @@ -41,3 +40,13 @@ export function useWorkspaceContext(): Workspace {
}
return workspace
}

// Broad inspection surfaces (debug, palette, multi-agent modals) explicitly
// retain their reactive runtime view. Layout shells instead use the layout
// hook above; individual panes subscribe by session id. Do not freeze action
// closures: the provider still updates whenever controller/layout state changes.
export function useWorkspaceContext(): Workspace {
const workspace = useWorkspaceLayoutContext()
const runtimes = useAppStore(state => state.workspaceRuntimes)
return { ...workspace, runtimes }
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ it('reads actual composer edits, protects concurrent text, and uses the existing
const mounted = renderHook(() => {
const [version, setVersion] = useState(0)
const setRuntimes = useAppStore.getState().setWorkspaceRuntimes
const actions = useDraftActions(setRuntimes, (id, patch) => setRuntimes(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })), setVersion)
const actions = useDraftActions(setRuntimes, (id, patch) => setRuntimes(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })), () => setVersion(v => v + 1))
return { ...actions, version, restoreStatus: 'fresh' }
})
const capabilities = draftControlCapabilities(() => mounted.result.current as unknown as Workspace)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ it('uses real follow owners, preserves lanes and other agents, and reports Tail
useAppStore.setState({ tailAllMode: true, workspaceState: { ...original.workspaceState, sessions: { first: { kind: 'claude', cwd: '/trial' }, second: { kind: 'codex', cwd: '/trial' } } }, workspaceRuntimes: { first: { ...emptyRuntime(), tailMode: true }, second: { ...emptyRuntime(), tailMode: true } } })
const layout = useAppStore.getState().workspaceState
const refs = makeRefs(layout)
const mounted = renderHook(() => useWorkspaceHelpers(useAppStore.getState().workspaceRuntimes, useAppStore.getState().setWorkspaceRuntimes, refs))
// useWorkspaceHelpers reads runtimes through refs and toggles via the
// setRuntimes updater, so the harness only needs the setter after the
// runtime-isolation refactor (dropped the render-time runtimes parameter).
const mounted = renderHook(() => useWorkspaceHelpers(useAppStore.getState().setWorkspaceRuntimes, refs))
const caps = preferenceControlCapabilities(() => ({ ...mounted.result.current, restoreStatus: 'fresh' }) as unknown as Workspace)
const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context)
const before = await invoke('views.preferencesRead', { sessionId: 'first' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { WorkspaceState } from '@renderer/workspace/types'
import { asRecord } from '@shared/lib/asRecord'

const appState = vi.hoisted(() => ({
workspaceRuntimes: {},
dispatchListRatio: 0.25,
openNewAgentForProject: vi.fn(),
setDispatchListRatio: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Workspace } from '@renderer/workspace/workspaceStore'
// unit test shipped as a no-op because the component undid it on render.

const appState = vi.hoisted(() => ({
workspaceRuntimes: {},
dispatchListRatio: 0.25,
openNewAgentForProject: vi.fn(),
setDispatchListRatio: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { Workspace } from '@renderer/workspace/workspaceStore'
// lane on the next render. A unit test cannot see that; only mounting can.

const appState = vi.hoisted(() => ({
workspaceRuntimes: {},
dispatchListRatio: 0.25,
openNewAgentForProject: vi.fn(),
setDispatchListRatio: vi.fn(),
Expand Down
Loading