21 KiB
Agent Note: Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer
Status: implemented
English | 中文
Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the layering and RPC protocol RFC; this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots.
Problem
Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries.
Decision
Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via useSyncExternalStore (uSES below).
┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐
│ sessions/agents/SessionLog │ │ client cordis root ctx │
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│
│ webserver: │ │ ├ immediately entries: connection/runtime/ │
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │
│ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │
│ │ │ │ conversation/trajectory(fetch bundle,按需) │
└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │
│ └ session scope ×N(观看驱动,惰性建) │
│ React: loading 页 → settled → 整 UI 一次成型 │
└────────────────────────────────────────────────────┘
The client cordis tree and the loading chain
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the client plugin loading RFC. The load-bearing facts for this document: the browser boots the same vendored @cordisjs/plugin-loader as the host with a client module system (ctx.modules, packages/client/modules) filling its internal seam; every unit with product behavior is an entry in the host-authored __DSH_BOOT__ graph — every production plugin package (infrastructure included) carries the dshClient declaration and arrives as a fetched ./client tsdown closure bundle, immediately rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute window.__ModuleLoader__.load({ id, factory }) and their require is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as <style data-plugin="<id>"> at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts rebuilt SSE frames, and the client-hmr plugin swaps one fiber per frame. The settled flip (loader.await() + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
Type universes stay split at the aggregate level — tsconfig.host.json is the host program and tsconfig.client.json the client program, both referenced by the solution root tsconfig.json — because both sides merge cordis Context under the same keys (sessions, loader) with different services; client packages consume the wire vocabulary through pure type subpaths (@deepseek-ai/dsh-session/types and kin) so no host augmentation rides into the client program.
The slot system: how the page composes
The slot system has its own RFC — the slot system standard — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only 'root'; a plugin composes UI through a single register call that occupies a slot, declares+authorizes its child slots (children spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (PropsRuntime<K> / PropsRenderSlots<S> / PropsStore<H> / inject), each from its single source of truth. SlotMap declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
Implementation homes: registry core and the props-share types in packages/client/ui-slots, outlet/renderer/uSES bridge in packages/client/web-react.
Services and scope addressing
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: ctx.connection (api client + stream handles), ctx.slots (registry wrapper emitting slots/changed, render entry, renderer install seam), ctx.sessions (list store, current-session state, scope tree), ctx.loader, ctx.theme, ctx.i18n, ctx.layout (cross-plugin view navigation), ctx.conversation (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the slot system standard.
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the 'conversation.view' list slot ui-conversation declares, tab metadata rides the registration options (id/order/label), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today 'conversation.chat.toolview' (keyed/session), declared by the chat entry's children table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via entryKey: toolName with GenericToolCard as the call-site fallback; the owner payload is the uniform ToolRowOwnerProps (callId/toolName/block/openDetails), and ToolRowProps composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row), with inject: ['slots', 'conversation'] as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — useSessions reading parentId — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline <domain>.<entry>.<hole>, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
Scope addressing mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (scopeOf(ctx)). Inside a session scope, ctx.conversation.send('hi', 'queue') targets that session; cross-session calls re-target by switching ctx (ctx.sessions.scope(id)!.conversation.send(...)); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
The data object layer (packages/client/runtime/src/client/sessions/)
Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable):
mux/host 帧(ConnectionController 泵入,sinks 注入)
│
▼
SessionManager.handleMuxEnvelope / handleHostEnvelope
│ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
▼
Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
│ FoldAdapter PartialAccumulator
│ (→ nodes) (→ partial)
▼
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
- Session (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations:
prompt/cancel(RPC passthrough; failures land in the snapshot'spromptError),open(pull the tail history page, idempotent),loadOlder(upward paging, reentry-guarded),resync(reconnect = clear the window and rerun open). Subscription:subscribe/getSnapshot(always the cached reference) —implements ObservableSnapshot<ConversationSnapshot>, withuseSelector = bindSnapshotSelector(this)attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch:session/eventframes dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once ifsubscribed.lastSeqoutruns the window tail. - ConversationSnapshot (conversation.ts): the immutable snapshot contract —
nodes(folded, surface-ordered),partial,runningCalls,pending,running,removed,openState,hasMore,promptErrorand kin. Reference discipline (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references. - SessionManager (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question
requestedframes are the exception — they never land in history, so they buffer inpendingBuffersand replay on instantiation. - Notifier (notifier.ts): two channels chosen by change source.
markDirty()(default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying.notifyNow()(only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - FoldAdapter / PartialAccumulator: the fold reuses the core SurfaceManager (
@deepseek-ai/dsh-session/surface), padding sentinel events so a paged window starting at seq > 0 satisfies the core'sseq === indexassertion; a cross-window replace degrades to a tolerant linear scan and setsfoldDegraded. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks intoAssistantBlock[], a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. - ConnectionController (in
packages/client/connection): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild:onConnected→ list refresh + per-open-session resync. The object layer faces onlyIApiClient; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory.
The React face (packages/client/web-react)
The glue package is the whole ctx↔React boundary; components stay framework-free.
- The snapshot store engine lives in the runtime package (zustand vanilla with draft-based updates,
flush: 'sync'by default with opt-in'raf'batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported fromruntime's./clientmain entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only throughdefineStoredeclarations per the slot system standard. web-react composes every hook at the binding site (bindSnapshotSelector, per-source cached) from the one data contract React consumes:ObservableSnapshot<T>(getSnapshot/subscribe) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue. bindSnapshotSelector(source): binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults toObject.iswithshallowEqualopt-in per call.useInvoke(fn): wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes.- Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with
Object.isorshallowEqual;React.memoshallow. Deep comparison is banned everywhere.
Directory shape
Twelve packages/client/* packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus apps/web — the vite application, a thin main over the shell's boot export. Plugin packages keep their browser half under src/client/; every build artifact lands in lib/ — the node half as lib/index.js/lib/invariant.js, the browser bundle as lib/client.js (the shared tsdown client preset emits both; there is no dist/ directory, and exports["./client"] points at ./lib/client.js). Dependency direction: ui-slots ← web-react ← runtime ← ui-* (peers) ← web, with ui-primitives/ui-theme/i18n as zero-dependency side paths.
A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:
src/client/
contract/ the only shared face between domains (types + composed props shares)
service.ts cross-domain orchestration (imports contract only)
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
chat/ domain: the chat view
toolviews/ domain: sample tool-row registrants (third-party posture)
apply.ts the ONLY file allowed to import across domains (assembly point)
index.ts thin re-export shell (contract + apply + components)
Domain implementation files never import a sibling domain — shared surfaces route through contract/ (e.g. the toolviews samples take ToolRowProps from the contract, never chat internals). scripts/verify-client-domain-graph.ts enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
How to develop
- A new UI feature = a new plugin package: declare
dshClient(+injecttopology) in package.json, write the browser half undersrc/client/(apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - A new slot: see the slot system standard RFC — merge the contract into
SlotMap, declare it in the parent entry'schildren, render through the auto-injectedrenderSlotprop. Never export components globally. - Consuming a new frame type: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a
ConversationSnapshotfield with the reference discipline kept. - Where does this state live: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store (slot system standard).
- Notification channel: frame-driven/async =
markDirtybatching; direct user-gesture echo whose controlled input needs the same tick =notifyNow.
Consequences
Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.
Alternatives considered
| Rejected | One-line reason |
|---|---|
| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back (toolview dissolution) |
| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |