Skip to content

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 (runtimeruntime-config-buildercustom-agents-utilsruntime-toolsruntime). Break that cycle before ratcheting SCAN_ROOTS.

  • Only value imports are considered — import type / export type are 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 by tests/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 modules
  • components/layout/Sidebar.tsxSidebarBranding.tsx + WorkspaceSwitcher.tsx
  • Desktop event-message-handlers.ts → native family in event-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 into pnpm 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.