App.tsx: Restore the Thin Layout Shell
A plan to decompose the 785-line App.tsx kitchen-sink into six volatility-aligned seams — pressure-tested by the lowy + hickey lenses before a line of code.
The convention is explicit and the code drifted from it:
State per domain: Extract shared state into
useXxx.tsmodules (singleton pattern)… Keep App.tsx as a thin layout shell. —.claude/rules/solidjs.md
packages/client/src/App.tsx is supposed to be that shell. At 785 lines it has
become the catch-all every feature lands a little wiring in. This is the
plan-of-record for #1340 — a
behavior-preserving decomposition back to layout-composition-only. The issue
suggested one cluster per PR; per the maintainer’s call this lands the whole
decomposition in one branch, sequenced as ordered, individually-bisectable
commits.
① The drift — App.tsx became the catch-all
The shell is supposed to mount things. Instead it owns them. Five kinds of non-layout weight have accreted, each pulling in a different direction:
- Six overlay/dialog open-state signals —
paletteOpen,shortcutsHelpOpen,aboutOpen,welcomeOpen,diagnosticInfoOpen, plussearchOpen(which isn’t even a modal — it’s per-terminal find visibility, mis-clustered by mere co-location). - The canvas-surface precedence — an outer
<Show>gate plus a four-arm<Switch>whose arm order carries correctness (the #1034 “empty-canvas lie” and the F3 warming-window race both edited that order), readable only by rendering. - The full
ActionContextassembly (≈40 lines) — a fan-in that references every other domain’s writer: store, crud, theme, sub-panel, right-panel, posture, dock, recorder, overlays. - A prop-drilled server-identity fetch —
client.server.info()+ anidentitysignal +appTitle()threaded into the watermark, the About dialog,<Title>,<Meta>, andMobileTileView. - Two imperative escapes —
window.__koluSimulateAlert = …(an e2e bridge App neither produces nor consumes) and adocument.querySelector("[data-corvu-dialog-content]…")DOM probe standing in for state App already owns reactively.
A thin shell is not an empty file. Its legitimate, irreducible job stays:
the root container + safe-area + visual-viewport frame, the document title/meta,
the dialog mount points, the chrome/overlay siblings, the canvas <Switch>’s
per-surface layout markup, and the desktop <Resizable> split (whose
load-bearing comments encode the full-viewport-width invariant ChromeBar leans
on). That floor is ~330 lines of genuine layout. The goal is to shed everything
that isn’t that.
② Six seams, six axes of change
Each seam encapsulates one volatility — one reason-to-change — behind a stable
interface, mirroring an established singleton (useIntentEditor for reactive
dialog controllers via createSharedRoot; useSubPanel for per-terminal keyed
state). The shell then composes named owners instead of holding their state.
| # | Seam → owner | Axis of change it encapsulates | App.tsx sheds | ~lines |
|---|---|---|---|---|
| 1 | useCanvasMode (new, kaval/) | Which canvas surface wins, in what order — a pure 5-way total function, finally unit-testable without a DOM | showEmpty, the outer <Show>, the 4 <Switch> conditions → one switch over canvasMode().kind | 12–20 |
| 2 | Dialog controllers — useCommandPalette + createDisclosure×4 + useDialogStack | Five independent dialog visibilities + one shared focus-arbitration concern | 5 modal signals, withRefocus, handlePaletteOpenChange, the DOM probe | 55–70 |
| 3 | useTerminalSearch (new, terminal/) | Per-active-terminal find-bar visibility (terminal lifecycle, not modal) | searchOpen + its on(activeId) reset effect + prop threading | 8–12 |
| 4 | Handlers go home — useCanvasArrange.centerActive, useSubPanel.toggleOrCreate, TerminalContent self-reads | Domain behavior belongs to the domain that owns the state | handleToggleSubPanel, handleCanvasCenterActive, 6 re-threaded tile props | 45–60 |
| 5 | useServerIdentity (new) | How the server name / theme-color / PWA chrome is fetched + exposed | the info() fetch, the identity signal, the appTitle prop-drill | ~12 |
| 6 | useActionContext (new, input/) — deferred | Binding the key-dispatch + palette surfaces to one wiring object | the ~40-line ActionContext literal — only if the residual still reads as non-layout | 0–45 |
Two cross-cutting moves enable the table. useTerminalCrud is promoted from a
{store}-factory to a createSharedRoot singleton (mirroring the shipped
useIntentEditor de-deps) so TerminalContent/TileTitleActions can read it
directly instead of receiving crud-derived closures — this is the load-bearing
prerequisite for seam 4. And the __koluSimulateAlert test-hook folds into its
producer (useTerminalAlerts), which already owns the window/navigator test
surface.
③ What the review gauntlet changed
The plan you’re reading already survived the structural debate it would otherwise face in review. Three corrections matter, because each is a place the “fewer-lines-in-one-file” instinct would have made the code worse:
The fourth change is an addition: Lowy caught that the server-identity fetch
(seam 5) was a real volatility the five-cluster framing missed entirely — the one
stray fetch never migrated to the useXxx pattern, prop-drilled through the shell.
A small bonus rides seam 1: delete the dead daemonDown() export (zero live
importers) while rewriting that region.
④ Order, guardrails, and the behavior bar
Sequence (commit order within the one branch). The seams aren’t peers in the dependency graph; this order keeps every commit behavior-preserving and bisectable:
useTerminalCrud→ singleton — the enabling prerequisite; unblocks seam 4. highest risk (instantiation/disposal change — verify owner semantics the wayuseTerminalStorewas).- In parallel, all leaves:
useCanvasMode∥useServerIdentity∥ModalDialog.refocusOnClose+useDialogStack∥ the__koluSimulateAlertfold-in. - Dialog controllers (
useCommandPalette+createDisclosure×4, DOM-probe →useDialogStack) ∥useTerminalSearch(its own commit — highest behavior risk in seam 2, crosses theterminal/boundary). - Handlers go home (
centerActive,toggleOrCreate,TerminalContentprop-shedding). useActionContext— last, consuming verbs, if it earns its module.
The behavior bar. This is a refactor with no UI change, so the e2e suite is
the guard — every removed line’s effect is already exercised: kaval-daemon.feature
(the down-beats-empty / warming-beats-empty precedence, seam 1), command-palette
keyboard-shortcuts+welcome(dialog open/close + refocus, seam 2),terminal.featurefind-in-terminal (seam 3),terminal/sub-terminal/kill(seam 4),activity-alerts(the test-hook fold-in). The one genuinely new test iscanvasModeResolver.test.ts— asserting the tier precedence (connecting ▸ down ▸ warming ▸ empty ▸ workspace) and the down/warming payloads without rendering. That precedence is pulled into a dependency-freeresolveCanvasMode(facts)(theuseCanvasModeaccessor just gathers the live daemon/session facts and delegates), so the decision is exercised as a pure function — no daemon-status subscription to mount — which is the whole point of lifting it out of the JSX.
Implemented in
#1347 — App.tsx landed at ~530 lines / 3 reactive
primitives, and the app-shell-stays-thin code-police rule + App.shell.test.ts
budget now keep it there.
Method · 9 subagents over a 2-phase workflow: 5 cluster mappers + an idiom/sequencing
pass → independent Lowy + Hickey structural lenses → reconciled synthesis. Source:
packages/client/src, branch refactor/app-thin-shell, 2026-06-13.