Context
apps/desktop/src/renderer/app-shell.tsx is a 1490-line god component: ~40 hook/state
calls, ~20 inline helpers, 2 inline effects, and a 430-line JSX tree (1188-1614)
tangling session-workspace state, live-turn projection, action wiring, onboarding
routing, layout/resize, nav, overlays, and command assembly.
The app-shell-*.ts "split" reduced line count but not wiring: 9 createAppShell*Actions
factories (779/803/439/412/595/827/836/759/288) run unmemoized in render body, each
taking 10-20 deps and re-allocating every render. Only sessionRowActionHandlers is
ref-stabilized (:422). Consequences:
- app-shell.tsx:875 — the settle-fallback effect depends on
settleAssistantStreaming,
a fresh closure each render, so its 1s timer tears down/re-arms every render (many
times/sec while streaming). Same churn class the codebase already fixed for search. - app-shell.tsx:857 — reconcilePersistedMessages runs in a messages-triggered effect
with an object dep (activeLiveTurn); should be event-driven, not effect-reconciled.
Proposed
- Memoize the factories (or convert to hooks using internal refs) so the split becomes
a real boundary and identities are stable. - Carve the detail-panel JSX (1247-1577) into , and extract a settings-
controller and onboarding/seed controller, each owning its own state. - Colocate self-contained slices: expertTeams (609) → Composer-only hook; memoryActive
(199) + refreshMemoryActive (1039) → memory-pill hook.
Acceptance
app-shell orchestrates rather than owns; factories have stable identity; the settle
timer no longer re-arms per render.
Plan (2026-07-17)
Investigation findings:
- Factory closures are not stale (deps are mostly refs/setState); the concrete defect class is identity churn. The only observable harm today: the settle-fallback timer (
app-shell.tsx:920) tears down/re-arms every render because settleAssistantStreaming is a fresh closure. The reconcile effect is already fixed via useEffectEvent. - The JSX seams already exist as cohesive hook results (
useShellChatModel, useAppShellProjectContext, useAppShellComposerAttachments); the JSX flattens them into ~60 props. <ChatWorkspace> must take hook-result objects by owner — flattening would just move the god component one level down as god props. Composer stays mounted across sections (hidden when not on sessions) to preserve drafts, so ChatWorkspace must be always-mounted and receive active/hidden flags. This is the one non-mechanical point of the extraction.
Delivery — three separately revertable PRs, in dependency order:
- Stabilize action factories. Shared
useStableActions(factory, deps) (latest-ref) across all 9 createAppShell*Actions; remove the manual sessionRowActionHandlers ref-mirror special case (superseded path). Settle timer stops re-arming by construction. Contract tests: handler identity stable across renders; fallback timer arms once under render churn. - Colocate self-contained slices.
expertTeams → Composer-owned hook; memoryActive + refreshMemoryActive → memory-pill hook. - Extract
<ChatWorkspace> (ChatView + health notice + interaction slot + Composer + SessionWorkbar) taking hook results by owner, and <OnboardingEmptyState>. Pure code motion, no behavior change.
(2+3 may ship as one PR with two commits to avoid rebase churn on the same JSX region.)
Context
apps/desktop/src/renderer/app-shell.tsx is a 1490-line god component: ~40 hook/state
calls, ~20 inline helpers, 2 inline effects, and a 430-line JSX tree (1188-1614)
tangling session-workspace state, live-turn projection, action wiring, onboarding
routing, layout/resize, nav, overlays, and command assembly.
The app-shell-*.ts "split" reduced line count but not wiring: 9
createAppShell*Actionsfactories (779/803/439/412/595/827/836/759/288) run unmemoized in render body, each
taking 10-20 deps and re-allocating every render. Only sessionRowActionHandlers is
ref-stabilized (:422). Consequences:
settleAssistantStreaming,a fresh closure each render, so its 1s timer tears down/re-arms every render (many
times/sec while streaming). Same churn class the codebase already fixed for search.
with an object dep (activeLiveTurn); should be event-driven, not effect-reconciled.
Proposed
a real boundary and identities are stable.
controller and onboarding/seed controller, each owning its own state.
(199) + refreshMemoryActive (1039) → memory-pill hook.
Acceptance
app-shell orchestrates rather than owns; factories have stable identity; the settle
timer no longer re-arms per render.
Plan (2026-07-17)
Investigation findings:
app-shell.tsx:920) tears down/re-arms every render becausesettleAssistantStreamingis a fresh closure. The reconcile effect is already fixed viauseEffectEvent.useShellChatModel,useAppShellProjectContext,useAppShellComposerAttachments); the JSX flattens them into ~60 props.<ChatWorkspace>must take hook-result objects by owner — flattening would just move the god component one level down as god props.Composerstays mounted across sections (hiddenwhen not on sessions) to preserve drafts, soChatWorkspacemust be always-mounted and receive active/hidden flags. This is the one non-mechanical point of the extraction.Delivery — three separately revertable PRs, in dependency order:
useStableActions(factory, deps)(latest-ref) across all 9createAppShell*Actions; remove the manualsessionRowActionHandlersref-mirror special case (superseded path). Settle timer stops re-arming by construction. Contract tests: handler identity stable across renders; fallback timer arms once under render churn.expertTeams→ Composer-owned hook;memoryActive+refreshMemoryActive→ memory-pill hook.<ChatWorkspace>(ChatView + health notice + interaction slot + Composer + SessionWorkbar) taking hook results by owner, and<OnboardingEmptyState>. Pure code motion, no behavior change.(2+3 may ship as one PR with two commits to avoid rebase churn on the same JSX region.)