Skip to content

Renderer Bootstrap & App Composition

dazeb edited this page Sep 17, 2026 · 2 revisions

Renderer Bootstrap & App Composition

The renderer has exactly one mount point and one composition root. src/renderer/src/main.tsx owns the browser-level bootstrap: it imports the two global stylesheets, installs window-level error filters, and mounts <App /> into #root. src/renderer/src/App.tsx is the composition root: it fetches the three pieces of top-level state (app version, app settings, workspace projects), derives theme/accent/active-project values, and assembles the toolbar, transient overlays, the React Flow provider, the canvas, and the boot overlay. src/renderer/src/env.d.ts is the compile-time contract that tells the renderer what window.termsprawl looks like; it contains no runtime code and is satisfied in practice by the preload bridge (desktop) or the server shim (web).

Everything below assumes those three files as the ground truth; internals of Canvas, the stores, and the component kit are only described where App's imports reveal the call contract.

Boot sequence

main.tsx mounts immediately — there is no async gate before React starts. App then renders its shell on the first pass and lets three fire-and-forget reads resolve in the background.

flowchart TD
  M["main.tsx: createRoot(#root).render(App)"] --> A["App renders shell (toolbar + stage)"]
  A --> E1["effect: appVersion() -> version"]
  A --> E2["effect: settings.get() -> setSettings + applyTheme"]
  A --> E3["effect: useProjects.load()"]
  E3 --> G{"useProjects.loaded"}
  G -- false --> P["placeholder .canvas div"]
  G -- true --> RF["ReactFlowProvider -> Canvas(cwd, remote, invertWheelZoom)"]
  E3 --> B["useBootOverlay(loaded)"]
  E2 --> BH["useBrowserHome.setHomeUrl(settings.browserHomeUrl)"]
  B --> O["boot-overlay + TesseractSpinner"]
Loading

Key nodes:

  • The three reads are independent. appVersion(), settings.get(), and load() are issued from one effect keyed on the stable load action, and each updates a different piece of state. Nothing waits on anything else, so a slow workspace load cannot delay the theme or the version string.
  • loaded gates the canvas, not the app. Until the workspace store reports loaded, App renders <div className="canvas" /> — a same-sized placeholder — so layout does not jump when the real canvas arrives.
  • The boot overlay is a sibling of the canvas, not a replacement. useBootOverlay(loaded) drives boot.visible / boot.leaving, and the overlay is rendered after the canvas block inside .canvas-stage. The canvas therefore mounts at the exact moment loading finishes and the tesseract cross-fades away on top of it; making the overlay a conditional alternative to the canvas would add startup time.
  • The overlay is scoped to the canvas area. .canvas-stage is the shared positioning context; the toolbar above it stays visible and interactive while booting.

Top-level state and the prop/store split

App holds four pieces of local state — version, settings, error, settingsOpen — and reads loaded, load, activeProjectId, and projects from the workspace store. From those it derives activeCwd, activeRemote, and activeAccent, plus accent (via resolveAccent) and theme (via resolveTheme(settings?.theme ?? 'system')).

Two rules fall out of this shape:

  1. App is a fan-out point for settings, not the owner of domain state. settings.get() resolves once, then the same value is pushed into four different channels: applyTheme(...) mutates the document outside React so CSS variables take effect immediately; useBrowserHome.getState().setHomeUrl(...) copies browserHomeUrl into a store so canvas nodes can read it; settings?.invertWheelZoom and the theme/accent values are passed as props into Canvas and TesseractSpinner; and onSettingsChange={setSettings} lets the settings panel write back so every consumer re-renders. The comment "canvas nodes can't take props" is the architectural rule to remember: anything a node needs globally goes into a store, anything the canvas shell needs goes in as a prop.
  2. Accent and theme resolve in different scopes. Theme is global (AppSettings.theme); accent is per-project (projects.find(p => p.id === activeProjectId)?.settings?.accent). resolveAccent is the "never-purple guard": non-hex or legacy purple values resolve to undefined, the --accent inline CSS variable is omitted, and the stylesheet default applies. The resolved accent feeds both the shell's CSS variable and TesseractSpinner, so the boot spinner and the chrome never disagree. Changing accent plumbing means changing both consumers.

activeCwd / activeRemote are computed from the live project list (.find), so renaming, closing, or switching a project re-passes new props into Canvas on the next render without any extra subscription.

Error surface and the ResizeObserver filter

There are two independent layers, and they must stay independent:

  • main.tsx registers window error and unhandledrejection listeners whose only job is event.preventDefault() for benign ResizeObserver loop reports, matched through isResizeObserverNoise from ./ro-noise. This suppresses the DevTools console report.
  • App.tsx registers its own error / unhandledrejection listeners (empty deps, cleaned up on unmount) that set error, rendered as a click-to-dismiss .error-banner.

The comments are explicit that preventDefault does not stop other listeners, so App's handler must re-apply the same predicate or the banner reappears on every node resize. Any new global error reporter (Sentry-like hook, telemetry) must call isResizeObserverNoise too, or it will treat the resize noise as a real failure.

Shell composition

The rendered tree is a .shell div carrying the optional --accent override, containing:

  • .toolbar — brand text plus HelpBadge, TabBar, OrganizeButton (disabled when there is no activeProjectId), CogMenu (whose only job is setSettingsOpen(true)), and the version string. This is the place to add a new global action.
  • Conditional panels — AppSettingsPanel when settingsOpen, and Onboarding only when settings has loaded and ShouldShowOnboarding(settings, <count of non-closed, non-archived projects>) returns true. Dismissal writes onboardedAt through window.termsprawl.settings.set and feeds the result straight back into setSettings.
  • Transient overlays — UpdateToast and AnnouncementBanner render unconditionally and decide internally whether to show.
  • .canvas-stage — ReactFlowProvider wrapping Canvas when loaded, otherwise the placeholder div; then the boot overlay.

ReactFlowProvider is the only app-level context provider, and it must stay inside .canvas-stage so the boot overlay still covers the canvas region. Canvas receives cwd, remote, and invertWheelZoom; everything else it needs comes from stores.

The ambient contract: env.d.ts

env.d.ts declares Window.termsprawl in a declare global block, importing all payload types from @shared/types, @shared/agent-tools, @shared/agent-status, @shared/update-status, and ../../core/chat/types so the renderer namespaces are structurally identical to the shared domain model rather than re-declared locally.

Properties worth knowing before editing:

  • agentTools is optional (agentTools?), unlike the other namespaces. Consumers must feature-detect it; the hint is that it is only wired when the in-process tool server is active.
  • runtime: { kind: 'desktop' | 'server' } is the discriminator the settings panel uses to hide desktop-only surfaces; the Server Edition shim reports 'server'. Any new capability that cannot exist in the browser must be gated on this.
  • The namespaces are the renderer-visible IPC inventory: settings, updates, announcements, workspace, pty, diff, files, agent, contextLinks, git, cloud, github, browser, chat, links, relay, plus the standalone appVersion, openExternal, runtimeInfo. Adding a channel means touching the preload bridge and this file in lockstep; this file alone produces no runtime behavior, so drift is silent until a call site compiles against the wrong shape.
  • Subscription methods return unsubscribe functions (onStatus, onEvent, onData, onExit, onFrame, onStatus, onAgentOpen), which is the pattern every useEffect in the renderer must follow.

Boundaries and extension points

  • Do not add StrictMode. The comment in main.tsx is a hard constraint: a dev double-mount would spawn two PTYs per terminal node. If StrictMode is ever reintroduced, terminal session creation must first become idempotent per node id.
  • document.getElementById('root')! assumes index.html provides #root; that is the only DOM contract in the bootstrap.
  • New toolbar control → add inside .toolbar, mirror OrganizeButton's disabled={!activeProjectId} convention when the action needs a project.
  • New global overlay/toast → render alongside UpdateToast / AnnouncementBanner, and keep it outside .canvas-stage unless it should be covered by the boot overlay.
  • New per-node global fact → follow useBrowserHome: land it in a store in App's settings effect, because canvas nodes cannot receive props.
  • New IPC capability → implement in preload, declare in env.d.ts, then consume. Keep additions backwards-tolerant so the Server Edition shim does not have to match every optional surface at once.

Limitations: only main.tsx, App.tsx, and env.d.ts were available for this page. The internal behavior of Canvas, useProjects, useBootOverlay, ro-noise, and the imported UI components is described only to the extent their call sites in these three files reveal it.

Sources: src/renderer/src/main.tsx, src/renderer/src/App.tsx, src/renderer/src/env.d.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally