Frontend Architecture¶
The renderer (packages/app) and the shared UI kit (packages/ui) power every Open Cowork surface — the desktop app, the cloud web workbench, and the chart frame. Because the same React tree ships to all of them, the frontend keeps a deliberate layering so features stay composable, bundles stay tree-shakeable, and module initialization order stays predictable.
This page documents the intended layers, the "core imports nothing upward" rule, and the two automated guardrails that keep the architecture from eroding: the import-cycle gate and the renderer file-size budgets.
Layers¶
The renderer is organized as a stack. Each layer may import from the layers below it, never from the layers above it.
| Layer | Location | Responsibility |
|---|---|---|
| App shell | packages/app/src/App.tsx, index.tsx, browser/ | Boots the tree, wires routing, mounts feature domains. |
| Feature domains | packages/app/src/components/* (chat, agents, threads, studio, capabilities, …) | Self-contained product features. Domains do not import one another's internals. |
| Design system | packages/ui/src | Framework-aware but product-agnostic primitives: Button, Card, Dialog, IconButton, studio primitives. |
| Infra | packages/app/src/lib, stores/, hooks/, browser/cowork-api.ts | Cross-cutting runtime concerns: state stores, the cloud/desktop API bridge, transport. |
| Framework-agnostic core | packages/app/src/helpers/*, app-types.ts, and @open-cowork/shared | Pure functions, types, formatting, and policy helpers with no React and no upward imports. |
Renderer components import shared primitives directly from @open-cowork/ui. The renderer must not recreate a private components/ui barrel over the design system; app-owned wrappers such as components/ui/Toaster.tsx stay as explicit component imports.
The "core imports nothing upward" rule¶
The framework-agnostic core (helpers/, app-types.ts, and @open-cowork/shared) must never import from feature domains, stores, the design system, or the app shell. Core code is the leaf of the dependency graph: it can be reused, unit tested in isolation, and bundled without pulling React into a plain utility.
If a helper needs something from a higher layer, that is a signal the value belongs to the caller — pass it in as an argument rather than reaching upward.
Guardrail: import-cycle gate¶
Circular import chains are the most common cause of fragile initialization order (Cannot access 'X' before initialization), un-tree-shakeable bundles, and confusing refactors. scripts/check-import-cycles.mjs statically scans the first-party relative imports inside the configured SCAN_ROOTS (packages/app/src, packages/ui/src, packages/shared/src) and fails if any circular chain exists. The current cycle count is zero and the gate keeps it there.
Widen note (post-#961 hardening): packages/runtime-host/src was evaluated for inclusion but skipped because it still has a value-import cycle (runtime → runtime-config-builder → custom-agents-utils → runtime-tools → runtime). Break that cycle before ratcheting SCAN_ROOTS.
- Only value imports are considered —
import type/export typeare erased by the compiler and cannot form a runtime cycle. - Cross-package imports (
@open-cowork/*, npm packages) are out of scope here; package layering is enforced separately by the cloud/gateway boundary tests. - The gate runs as part of
pnpm lint(node scripts/check-import-cycles.mjs) and is also asserted bytests/renderer-modularity-boundaries.test.ts, which additionally self-checks that the detector still catches a synthetic cycle.
To break a cycle, extract the shared code into a lower layer (usually a helper), or switch a purely type-level dependency to import type.
Guardrail: renderer file-size budgets¶
Large files concentrate responsibility and resist review. Every source file under packages/app/src has a line budget of 900 lines, enforced by tests/renderer-modularity-boundaries.test.ts. There are currently no renderer exceptions. Any future exception must carry a lower explicit budget and a documented decomposition backlog; it may never silently grow.
JOE-884 decomposed the previous mega-files:
browser/cowork-api.ts→ transport/SSE hub (cowork-api-transport.ts), channels (cowork-api-channels.ts), plus prior admin/custom/transcript/support modulescomponents/layout/Sidebar.tsx→SidebarBranding.tsx+WorkspaceSwitcher.tsx- Desktop
event-message-handlers.ts→ native family inevent-message-native-handlers.ts
Budgets are ratchets: remove an exception when a file falls under the general limit; never raise a budget except to register a new, deliberately documented backlog. New code should land under the general limit from the start.
Where this is enforced¶
scripts/check-import-cycles.mjs— the cycle gate (wired intopnpm lint).tests/renderer-modularity-boundaries.test.ts— file-size budgets, the cycle assertion, and the detector self-check.scripts/lint.mjs— renderer design-system drift gates (raw palette, arbitrary font sizes, icon-button labels, native dialogs).
See Design System for the primitive and token rules that sit alongside these structural guardrails.