← the Atlas

App.tsx: Restore the Thin Layout Shell

analysis · budding ·implemented

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.ts modules (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:

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.

App.tsx — thin layout shell (frame · canvas <Switch> · dialog mounts)useCanvasMode — surface precedenceuseServerIdentity — info fetch · appTitleDialog open-state — per-dialog, NOT a god-hookuseTerminalSearch — per-terminal findDomain handlers go homeuseActionContext — composes verbs · deferredModalDialog.refocusOnCloseuseTerminalStore + useTerminalCrud (singleton)connecting ▸ down ▸ warming ▸ empty ▸ workspaceuseCommandPalette — real controllercreateDisclosure × 4 — trivial togglesuseDialogStack — focus arbitrationcenterActive ▸ useCanvasArrangetoggleOrCreate ▸ useSubPanel readsreadsmountsreadsdelegateskeys + paletteopen count
After the decomposition: App.tsx composes six named owners on the terminal-store/crud foundation. Only the precedence decision, open-state, and wiring leave — the per-surface layout markup stays.
#Seam → ownerAxis of change it encapsulatesApp.tsx sheds~lines
1useCanvasMode (new, kaval/)Which canvas surface wins, in what order — a pure 5-way total function, finally unit-testable without a DOMshowEmpty, the outer <Show>, the 4 <Switch> conditions → one switch over canvasMode().kind12–20
2Dialog controllersuseCommandPalette + createDisclosure×4 + useDialogStackFive independent dialog visibilities + one shared focus-arbitration concern5 modal signals, withRefocus, handlePaletteOpenChange, the DOM probe55–70
3useTerminalSearch (new, terminal/)Per-active-terminal find-bar visibility (terminal lifecycle, not modal)searchOpen + its on(activeId) reset effect + prop threading8–12
4Handlers go homeuseCanvasArrange.centerActive, useSubPanel.toggleOrCreate, TerminalContent self-readsDomain behavior belongs to the domain that owns the statehandleToggleSubPanel, handleCanvasCenterActive, 6 re-threaded tile props45–60
5useServerIdentity (new)How the server name / theme-color / PWA chrome is fetched + exposedthe info() fetch, the identity signal, the appTitle prop-drill~12
6useActionContext (new, input/) — deferredBinding the key-dispatch + palette surfaces to one wiring objectthe ~40-line ActionContext literal — only if the residual still reads as non-layout0–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:

  1. useTerminalCrud → singleton — the enabling prerequisite; unblocks seam 4. highest risk (instantiation/disposal change — verify owner semantics the way useTerminalStore was).
  2. In parallel, all leaves: useCanvasModeuseServerIdentityModalDialog.refocusOnClose + useDialogStack ∥ the __koluSimulateAlert fold-in.
  3. Dialog controllers (useCommandPalette + createDisclosure×4, DOM-probe → useDialogStack) ∥ useTerminalSearch (its own commit — highest behavior risk in seam 2, crosses the terminal/ boundary).
  4. Handlers go home (centerActive, toggleOrCreate, TerminalContent prop-shedding).
  5. 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

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.