The Web UI Plugin Architecture
Client-side Cordis, slots, SessionRuntime, and conversation projections
Conclusion: the Web UI is a second governed plugin tree; React is only the last mile
The DeepSeek Harness browser is not a set of React components bundled directly into the page by the Host. The Host Cordis tree first derives a client-plugin graph from the current Loader composition, serves bundles with content revisions, and writes the manifest into HTML. The page then creates an independent browser Cordis tree and loads plugins from that same graph. Business state remains in the React-free ConnectionController, SessionManager, Session, SlotRegistry, and ConversationNodeAssembler; web-react binds bare observables into hooks, composes terminal props, and a single root Slot finally enters React.
This architecture separates “how code reaches the browser,” “when a plugin is authorized to contribute UI,” “what must be refetched after disconnect,” and “when one session's view state dies” into distinct protocols. Its value goes beyond pluggability: dynamic packages can declare placement, state, and capabilities without teaching the Shell a business roster. The cost is that boot, authorization, scope, and replay boundaries must agree; when they do not, the system chooses a loud failure over a quietly half-valid tree.
1. Two Cordis trees: the Host decides reachable code; the Browser decides live objects
The Web bundle's Host configuration lists ordinary Host services—API proxy, runner, and webserver—alongside the browser-plugin roster carrying dsh.client. Modules is explicitly dual-faced: its Node half scans the Host Loader tree and publishes the graph, while its browser half becomes the in-page module table. Connection, runtime, theme, locale, layout, conversation, and tool are all independent client entries in that graph.WEBUI-DUAL-ROSTER
Host Cordis tree
Loader entries ──scan dsh.client──► manifest + /plugins bundles
│ │
└── webserver / API / SSE ▼
window.__DSH_BOOT__
│
Browser Cordis tree ▼
Context + Loader ◄── ClientModuleSystem ◄── manifest
│
└── connection → runtime → UI plugins → root Slot → React
On the Host, ClientModuleRegistry subscribes to Loader fiber changes and uses one scan path for both initial activation and incremental change. It accepts only entries that still have a fiber, are not disabled, and declare a Web client, registers the /plugins route, and injects the current graph through an index tap.WEBUI-HOST-GRAPH The page parses that graph, creates its own Context, and then starts the browser Loader. It does not serialize the Host context into the page: the Host delivers composition facts and the Browser reconstructs lifecycle.WEBUI-BROWSER-CONTEXT
The trust boundary is therefore layered. The Host decides which packages the page can obtain; the browser Cordis fibers then decide whether their dependencies are satisfied, whether they activate, and when they unload. The manifest is a composition protocol, not a remote mirror of the live service container.
2. Manifest and two-stage boot: code arrival is not plugin activation
Each graph row carries an id, bundle url, content rev, informational inject edges, and optional immediately. Entry order explicitly has no activation semantics; Cordis fiber inject waiting determines the real dependency order. After row-field shape validation at the browser boundary, the manifest splits into a module view and a plugin view.WEBUI-MANIFEST-CONTRACT
| Stage | Action | Commit condition |
|---|---|---|
| Module face | Prefetch immediately rows in parallel; script execution only registers factories | Every prefetch attempt has settled; entry import reports failures explicitly |
| Plugin face | Mount Loader and inject the module system first, then create entries concurrently | Loader quiesced |
| UI gate | Sweep every entry's root fiber | Switch once from loading to the full UI only when all are ACTIVE |
Boot waits for the immediate-tier barrier, then creates the modules entry, manifest entries, and the shell-owned app-shell entry. An individual prefetch failure is swallowed only at that optimization layer; entry import then retries it and surfaces the outcome. After loader.await(), boot still sweeps every fiber: no fiber means import failure, PENDING reports missing services, and FAILED retains the failed state. Any entry that did not activate keeps the loading page in place as the error surface instead of exposing a partial UI.WEBUI-BOOT-SETTLE
3. Dynamic module table and HMR: replace code first, then replace the fiber
ClientModuleSystem uses a lazy CJS table. An external classic script calling window.__ModuleLoader__.load only records its factory; the first import or synchronous require executes that factory and caches its exports, dependency edges, and injected styles. Synchronous require may hit only a platform seed, shell static, cache, or registered factory; it cannot start a network request. Cycles and unknown modules fail loudly.WEBUI-LAZY-MODULES
The Host HMR half watches every graph bundle with a 500ms default stat poll, updates the graph revision only after a real content-hash change, and broadcasts rebuilt over the /plugins/events SSE endpoint; it also sends a graph snapshot on connect.WEBUI-HMR-HOST The Browser serializes reloads. invalidate → prefetch obtains the new factory while the old fiber still serves, then it removes the registry runtime first, drains the old fiber, removes owned styles, clears entry.fiber, and finally calls entry.refresh() to materialize and apply again. Failures do not roll back the old bundle: an early prefetch failure leaves the old fiber serving, while an apply failure leaves a FAILED fiber.WEBUI-HMR-SWAP
The HMR consistency unit is a plugin fiber, not a React component. Replacing a service provider therefore cascades naturally through Cordis activation epochs without a second HMR dependency graph. React-local state inside the reloaded plugin is correspondingly not preserved.
4. The Shell is thin: it installs the renderer and renders one root
The app-shell entry waits for slots, sessions, and layout, installs the Slot renderer supplied by web-react, and exposes an identity-stable renderApp closure.WEBUI-SHELL-INSTALL Apart from the session-title projection, the real assembly makes a single ctx.slots.renderSlot('root', {}) call. AppFrame, sidebar, conversation, details, and overlays are all formed by subsequent registrations.WEBUI-SHELL-ROOT
The Shell neither enumerates business pages nor imports every UI package. It knows the boot state, the renderer contract, and the root Slot; the Host graph supplies the surface roster, and Slot declarations supply the tree shape.
5. React-free object layer: connection, sessions, and projections exist before components subscribe
The runtime plugin first creates SlotRegistry, Conversation event/view registries, SessionRuntime, and WorkspaceRuntime, then starts the connection streams through their sole consumer. Mux frames enter sessions, Host frames enter both sessions and workspaces, generic remote events pass to remote dispatch, and connection establishment or loss calls only object-layer methods.WEBUI-RUNTIME-WIRING
WebSocket / HTTP
│
ConnectionController physical generations, backoff, frame pump
│
SessionManager instance directory, cold buffers, list, projections
│
Session contiguous seq window, pending, Conversation snapshots
│
bare getSnapshot/subscribe sources
│
web-react useSyncExternalStore selector hooks
│
components
The runtime explicitly owns the snapshot-store engine and exports bare observables; React binding does not flow back into the object layer.WEBUI-RUNTIME-STORE-OWNER The same Session can therefore serve non-React observers, the Slot renderer, and tests, while business state such as connection generations and history repair remains independent of component mount and unmount.
6. WebSocket reconnect: rebuild a generation instead of pretending to continue the old streams
Each ConnectionController generation opens mux and host downlinks while concurrently calling host.describe. describe must succeed; stream readiness normally waits for both onOpen callbacks, but the 3s carrier-open anti-wedge guard also permits progress on timeout. After readiness converges, a stream end or stream/error aborts the whole generation, enters jittered exponential backoff, and creates a new generation. Business-sink exceptions are isolated from the pump. However, host.describe itself has no generation signal or timeout: if it hangs forever, even an ended stream leaves control flow stuck inside the handshake Promise.all instead of reaching shared reconnect.WEBUI-CONNECTION-GENERATION
When a generation dies, the runtime first clears pending interactions that were valid only for that connection. When the new generation is ready, it refreshes Session and Workspace baselines and emits connection/reset.WEBUI-RUNTIME-RECONNECT SessionManager also reloads the session list and opened subagent catalogs, then calls resync() on every resident Session.WEBUI-MANAGER-RECONNECT A cold resident that has never opened no-ops on this path.WEBUI-SESSION-RESYNC
refreshList() is single-flight. Live mutations that arrive after the request starts are replayed over its response baseline, so a refetch cannot simply overwrite state already observed during reconnection with an older list.WEBUI-LIST-REFETCH
7. Cold sessions, refetch, and seq-gap repair are three distinct paths
SessionManager does not instantiate a Session for every mux frame. Ordinary events for an uninstantiated session are dropped because history will backfill them on open(). The pending buffer that must replay into a Session retains only approvals, questions, and the latest queue snapshot—facts history cannot recover. Projections and jobs instead land in manager-level mirrors without creating a Session.WEBUI-COLD-SESSION-DISPATCH A Session is created lazily when its scope is actually resolved, then its pending buffer is replayed.WEBUI-LAZY-SESSION
| Situation | Authority | Handling |
|---|---|---|
| Cold session never opened | history + list baseline | Maintain no live window; backfill a full page on open |
| Previously opened resident session after reconnect | new-generation baseline | Clear the old window and run generation-guarded resync() |
seq > tail + 1 in an open window | history tail | Buffer new frames and repull; keep the old UI window without a loading flash |
A Session's first open pulls the history tail. If session/subscribed.lastSeq is already beyond the tail and the live buffer did not cover the gap, it pulls once more. Window installation deduplicates only by seq and then stitches the buffer. A live hole enters repairGap(): later frames are buffered, the tail is fetched again, and the shared installation path runs. A failure never appends a sequence with a hole directly into the UI.WEBUI-SESSION-GAP-REPAIR
8. SlotRegistry: a children declaration is structure, specification, and render authorization
SlotMap defines kind, scope, and owner props for every location. A registration's children table not only records each child Slot's runtime specification; it also declares that this entry is the sole subject authorized to render those keys.WEBUI-SLOT-TYPE-CONTRACT SlotCore declares only root a priori. An undeclared target, duplicate child declaration, missing kind parameter, or shared store handle crossing scopes throws at load time.WEBUI-SLOT-CORE-COMMIT Once committed, the entry disposer recursively collapses its entire declared subtree.WEBUI-SLOT-CASCADE
ctx.slots.inject(key, callback) lets independently activating contributors wait for a declaration. It runs synchronously when the declaration already exists, disposes in reverse when that declaration collapses, and runs again under a new epoch after redeclaration. The controller and callback effect belong to the caller's fiber, so unloading a contributing plugin cancels either a pending wait or its active registrations.WEBUI-SLOT-DECL-INJECT
ui-layout's one registration into root simultaneously declares sidebar, conversation, details, and shell.overlay, while seating a layout store and business inject.WEBUI-ROOT-DECLARATION Layout is therefore not a hard-coded import tree; it is the first registrant that owns authorization for those child locations.
9. Four props shares: each ownership fact has one source
| Share | Source | What the component receives |
|---|---|---|
PropsRuntime | SlotMap owner/key/scope/inject plus the parent registration's child spec | Parent owner props, slot-level inject, global kit, and scope-dependent session kit |
PropsRenderSlots | This registration's children | renderSlot narrowed to declared keys, plus SessionProvider when needed |
PropsStore | The registration's store handle | useStore selector and the complete actions set with draft stripped |
InjectFace | The registrant inject return | Business callbacks; the hooks compartment becomes useXxx |
The runtime share derives scope and owner types from SlotMap; the child-render and business-inject shares meet it in final ComposedProps.WEBUI-FOUR-SHARES The store contract makes its actions the sole write set. Components never receive instance-level set/update; they read through useStore and write through baked actions.WEBUI-STORE-CONTRACT
10. web-react is the binding terminal, not the business runtime
bindSnapshotSelector() is the client stack's uniform general-purpose selector-hook constructor. It turns any getSnapshot/subscribe source into a useSyncExternalStoreWithSelector hook while capturing source methods stably.WEBUI-REACT-BINDING When rendering an entry, the renderer synthesizes global/session hooks, the projection hook, the store pair, authorized renderSlot, SessionProvider, and locale t. What the runtime supplies across this composition boundary remains a bare source.WEBUI-RENDER-KIT
A retained renderSlot closure also checks on every call that the entry remains in the ledger and that its children declared the target key. A stale closure after unload or an unauthorized key raises a dedicated error.WEBUI-STALE-AUTHORIZATION
React owns subscription consistency and component isolation; SlotCore owns composition facts; Session and Store own state facts. Keeping hooks at the terminal means HMR, tests, and non-React consumers need not emulate component lifecycle to keep business objects correct.
11. UI-state versus business-state ownership follows lifecycle, not folders
| State class | Actual owner | Examples | Reset / lifetime boundary |
|---|---|---|---|
| Connection/business facts | ConnectionController, SessionManager, Session | generation, list, pending, event window, projections | Connection generation, Session scope, or runtime fiber ends |
| Cross-component session UI | Declared Slot store | selected call, draft, active view, inspect target | Corresponding session scope is pruned |
| Ephemeral local UI | Component | Whether one Tool row is expanded | That component unmounts or reloads |
| Render input known by the parent | Owner props | Frozen Tool block, cwd, openFile/inspect callback | Parent projection replaces it |
| Durable user preference | Domain service + SettingsScope | locale, theme, busy-enter, and similar settings | Host mode follows document revisions; memory mode lasts only for the page/runtime |
The conversation chat store is created as a handle during apply, with selection, draft, view, and inspect in its initial state, plus one declared action set and persistence key.WEBUI-CHAT-STORE In contrast, ToolRow's expanded flag is explicitly component-local useState and never contaminates a Session snapshot or Slot store.WEBUI-TOOL-LOCAL-STATE
12. Session scope: selection stages, resolution creates lazily, and prune reclaims one axis
SessionRuntime jointly owns the list projection, persisted selection, SessionManager, scope map, and current provide bundle. A provider-roster change rebuilds that bundle even when the id is stable.WEBUI-SESSION-RUNTIME dsh.sessions.current restores a selection candidate only at boot, while the manager retains the live in-memory selection; only a selected id still present in list items or backed by a retained subagent address projects as current.WEBUI-SELECTION-ADDRESSABILITY A masked gap clears the persisted cell so a reload stays empty, but the same process can resurface its selection when the row returns.WEBUI-SESSION-SELECTION
followCurrent() moves the stage to current and triggers an idempotent history open. Only resolve() lazily creates the Cordis Agent scope, Session object, binding, and bare provide sources. When a session is no longer eligible and has left the stage, one teardown disposes the scope fiber, unbinds the Session, clears session-keyed Slot stores, and removes the in-memory manager instance. If it is removed while staged, teardown waits until the stage moves and preserves a frozen scope meanwhile. The Host Session log remains the authoritative source from which a later open can backfill.WEBUI-SESSION-SCOPE-LIFECYCLE
A Slot store instance is cached by handle × scope key: root uses one fixed key, while session scope uses the session id. Scope pruning also clears the matching persisted value so a dead session leaves no orphan UI state.WEBUI-STORE-SCOPE-LIFECYCLE web-react's root provider follows current; strict SessionProvider remounts the whole session subtree with key={sessionId}, preventing local component state from leaking across sessions.WEBUI-SESSION-PROVIDER
13. Conversation Nodes: fold the log into business Contexts, then project multiple views
A Conversation Definition's match(event) may inspect only the current event and returns a stable business id plus a start/update role. start/update fold State, buildLocationData may publish owned data onto a Turn or Step, and buildViewNode emits the final Node for one target.WEBUI-CONVERSATION-DEFINITION
contiguous SessionEvent window
│ match(event) → {kind, id, role}
▼
Context(kind:id): start + ordered updates + State
│ │
│ └── Location index: Session / Turn / Step
▼
target-specific stable Node ──► chat / trajectory / other view builders
replaceWindow is reserved for a full open, resync, or gap repair and for a low-frequency registry rebuild. An ordinary append does not scan old Contexts: it updates any location boundary, matches just that input, and replays only Contexts affected by dependency changes. Prepend collects only fresh events from the older page, refreshes matches whose locations changed, and replays the affected closure.WEBUI-CONVERSATION-INCREMENTAL Multiple Definitions may accept the same event; the fallback runs only when no ordinary Definition matched and claimed that same target. Context keys are kind + id. Duplicate starts, or an update that precedes its start in log order, fail loudly.WEBUI-CONVERSATION-FOLD A Definition returning an unstable view key or wrong target also fails.WEBUI-CONVERSATION-VIEW-VALIDATION
The Location index preserves object references for unchanged Turns and Steps during rebuild and returns the seq set whose locations changed. A live non-boundary append records only the current coordinates, while a boundary append revisits only seqs owned by that Turn.WEBUI-LOCATION-INDEX Historical paging can therefore supply an earlier start or predecessor without changing every visible Node's identity.
14. Tool presentation: runtime owns recursive topology; wire names dispatch atomic appearance
ui-tool first occupies conversation.chat.node under the tool-call key and declares the session-scoped keyed child tool.call.toolview in the same registration. It then mounts shell, read, mutation, search, web, todo, question, and other views independently.WEBUI-TOOL-REGISTRATION An atomic view's owner payload contains only call id, wire tool name, a frozen running/settled block, cwd, a Host-bound openFile callback, and a local inspect callback; the framework still injects the session-scoped standard kit, and the registrant may add its own inject share.WEBUI-TOOLVIEW-CONTRACTWEBUI-FOUR-SHARES It does not own traversal of the root/subcall topology or re-pair events.
ToolCallTree sends both the root call and every level of subCalls through the same keyed dispatch, with the wire tool name as entryKey. An unregistered name falls back to GenericToolCard.WEBUI-TOOL-DISPATCH The generic renderer selects terminal/read/diff/search/web cards.WEBUI-GENERIC-TOOLVIEW Its pure model derives variant, lifecycle, summary, path, and input/output from the frozen slice. A matching specialized keyed registrant replaces this fallback instead of wrapping a second layer around it.WEBUI-TOOL-MODEL
The minimum surface for extending a Tool UI is registering an atomic renderer for its wire name. Session-event pairing, Code Mode child-call topology, and ChatFlow placement remain owned by upstream projections, so each business plugin need not implement its own transcript parser.
15. Locale, Theme, and Settings: features own preferences; the Shell only provides containers
| Domain | State owner | Presentation owner | Synchronization |
|---|---|---|---|
| Settings transport | One SettingsScope per namespace | Each feature's own setting row | Background read; serialized one-field write with expectedRevision; reset/event refetch |
| Locale | LocaleRuntime active locale and dictionary registry | Slot renderer synthesizes t | Snapshot revision; active namespace → zh namespace → active common → zh common → key |
| Theme | ThemeRuntime preference, system resolution, and token layers | ui-layout ThemePresenter | theme/change; pure DOM projection outside React |
| Settings shell | Slot ledgers and shell-local navigation projection | ui-settings-general | Subscribes to both Slot version and locale revision |
SettingsScope serializes reads and writes per namespace, with writes carrying the latest revision. A refusal or transport failure causes only the latest write to trigger a recovery read. A stale read may still advance revision/base/user metadata, but it does not publish a decoded value or ready status. Binder subscribes to invalidations on the caller's context before starting the first nonblocking read. A non-loopback browser enters memory mode and never calls privileged settings RPC.WEBUI-SETTINGS-SCOPE
LocaleRuntime derives a provisional choice from the browser language and later adopts the Host selection. Typed registration requires zh and en together for a namespace.WEBUI-LOCALE-OWNER The service installs itself into SlotRegistry as the LocaleFace and registers its Language row in General.WEBUI-LOCALE-INSTALL ThemeRuntime owns light/dark/system, the OS media query, and token override layers but never touches the DOM.WEBUI-THEME-OWNER ui-layout reads its initial getter and listens to theme/change.WEBUI-THEME-PRESENTER ThemePresenter writes only color-scheme, the dark attribute, owned CSS variables, and its own meta element.WEBUI-THEME-DOM
ui-settings-general occupies sidebar.settings, then declares trigger, header, action, close, section, and onboarding children. Navigation rows derive from the Slot ledger, and copy freshness follows locale revision as well. Each feature only registers its own row or section into those locations.WEBUI-SETTINGS-SHELL
16. Known gaps: Developer Preview architecture boundaries are still moving
The root README labels the current release a Developer Preview and explicitly warns of compatibility-breaking changes.WEBUI-DEVELOPER-PREVIEW
| Gap | Current behavior | Practical effect |
|---|---|---|
Hung host.describe can wedge reconnect | The stream-open wait is bounded, but the concurrent unary describe call has no generation timeout or signal | A dead stream cannot advance to backoff until describe settles |
| No progressive rendering during boot | If any entry is not ACTIVE, the page remains on loading/error | Strong consistency, but one plugin failure prevents whole-page availability |
| No HMR rollback | Import/apply failure leaves a fiberless or FAILED entry | A later rebuild must heal it; plugin-local React state is lost |
| The general client unload chain remains a stub | loader.unload is unimplemented; HMR uses a dedicated registry-first swap | Development-time hot replacement does not establish a general arbitrary-plugin unload capabilityWEBUI-UNLOAD-LIMIT |
| Remote browser settings are not durable | Non-loopback SettingsScope uses memory mode | Locale/theme can change their local runtime but lose the choice on refresh; settings surfaces that require a Host scope remain unavailable |
| Session scope is a single-stage model | One list.current drives a single staged occupant; masked or cleared gaps retain the previous occupant until the next open moves the stage | Multiple panes or concurrent observers require teardown to expand from one occupant to real observer ownershipWEBUI-SINGLE-STAGE-LIMIT |
| The Tool-details entry point is incomplete | openDetails exists but has no caller in the assembled UI | The selected call's raw details are unreachable in the current product compositionWEBUI-DETAILS-LIMIT |
17. Verification scope for this chapter
Every repository claim is pinned to commit 47f943859bef60e4160492346772ded9b24f765a. This review ran only 15 existing test files directly related to the chapter's control flows; all 321 cases passed. It did not run a full build, the full GUI suite, E2E, performance, or stress tests.
pnpm exec vitest run \
packages/client/modules/tests/loader.client.spec.ts \
packages/client/hmr/tests/node-half.client.spec.ts \
packages/client/connection/tests/connection.client.spec.ts \
packages/client/runtime/tests/manager.client.spec.ts \
packages/client/runtime/tests/session.client.spec.ts \
packages/client/runtime/tests/sessions-service.client.spec.ts \
packages/client/runtime/tests/conversation-assembler.client.spec.ts \
packages/client/ui-slots/tests/core.client.spec.ts \
packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx \
packages/client/web-react/tests/session-provider.client.spec.tsx \
packages/client/ui-tool/tests/toolview-slot.client.spec.tsx \
packages/client/ui-settings/tests/settings-scope.client.spec.ts \
packages/client/locale/tests/locale.client.spec.ts \
packages/client/ui-theme/tests/theme.client.spec.ts \
packages/client/ui-layout/tests/theme-presenter.client.spec.ts
Test Files 15 passed (15)
Tests 321 passed (321)
Duration 9.03s
The SessionProvider fail-loud case emitted an expected stderr stack while asserting its error boundary. The command still exited 0 with no failed case.
The loader and HMR tests pin factory arrival, materialization, invalidation, graph watching, and disposal.WEBUI-MODULE-TESTSWEBUI-HMR-TESTS Connection tests pin generations; manager and Session tests pin refetch, stitching, and gap repair, while sessions-service tests pin lazy scope creation and deferred staged teardown.WEBUI-CONNECTION-TESTSWEBUI-MANAGER-TESTSWEBUI-SESSION-TESTSWEBUI-SESSION-SCOPE-TESTS Slot/web-react tests pin the declaration gate, lifecycle cascade, real uSES binding, and keyed remount.WEBUI-SLOT-TESTSWEBUI-REACT-BINDING-TESTSWEBUI-WEB-REACT-TESTS Conversation tests pin append/prepend and location replay.WEBUI-CONVERSATION-TESTSWEBUI-LOCATION-TESTS Tool, Settings, Locale, and Theme tests pin keyed fallback, preference ownership, and the DOM-projection boundary.WEBUI-TOOL-TESTSWEBUI-SETTINGS-TESTSWEBUI-PREFERENCE-TESTSWEBUI-THEME-TESTSWEBUI-THEME-PRESENTER-TESTS
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.