Rules for packages/client/* (the browser side of the dsh web GUI) plus its build entry apps/web. They supplement the repo-wide conventions and the package rules. Read the current Web Client architecture, Slots reference, and Conversation reference before changing the corresponding layer.
Packages here are named with the directory prefix: @deepseek-ai/dsh-client-<name>.
The Slots reference owns the current design; these are the rules you must not violate when writing or reviewing client code:
ctx.slots.register({ name, children?, store?, inject? }, Component). There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders 'root'.children object (spec values: kind/scope). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: <domain>.<entry>.<hole> (e.g. 'tool.call.toolview').PropsRuntime<K> (SlotMap: owner params + useSession/sessionId on session scope + global useSessions/useWorkspaces) & PropsRenderSlots<S> (children keys) & PropsStore<H> (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.useSession, useSessions, useWorkspaces, useStore, renderSlot are the five standing seats, plus the use<Name> hooks the renderer binds from provide contributions and inject hooks compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)useMemo), never its own subscription.props.useStore, write props.actions.* — the declared actions are the complete mutation API. Write the store as an exported createXXXStore() factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside apply. Production code never calls the factory or .create() outside apply; tests do (that is the sanctioned zero-machinery path).hooks compartment (bare observables the renderer binds to use<Name>; components never see the sources). The plugin may use only the dependencies named by its inject declaration; there is no wider ctx to reach for.How live data reaches render code, and what UI domains may share:
keyboard.snapshot); render code subscribes.useSyncExternalStore, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject hooks compartment; cross-entry or remount-surviving → a declared store; per-session standard → sessions.provide.use<Name>) → a declared store (useStore/actions) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.Uint8Array: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected hooks compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing accessory/overlay/leftItems/rightItems fields remain until they move to slots).getSnapshot returns the same reference until the fact moves).The /client entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments):
apply / inject (and Config where present), plus store factories consumed type-only by components (ReturnType<typeof createXXXStore>). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.../src/client/xxx.ts from package tests, or the ./src/* subpath where a spec lives outside the package. Never widen the public API to make a test compile.dsh.client.external to obtain them. Shared declarations use import type; behavior crosses packages through injected Cordis services, and UI crosses packages through slots. If neither fits, stop and escalate — do not add an export to unblock yourself. Shared runtime code belongs only in a narrow static owner such as client/store, ui-primitives, or a browser-safe utility package; transport and generated API assemblies keep their explicit infrastructure edges.ctx belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every .tsx under a feature domain — receive all data and callbacks through the four props shares; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — BindingContext and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
The stack has one-way knowledge, documented in the Web Client architecture:
client/connection owns transport generations, api/session-controller/client owns ClientSessions → SessionManager → Session, api/workspace-controller/client owns Workspace state, and client/store owns the snapshot-store engine (defineStore, createSnapshotStore, shallowEqual). Store products are bare observable sources with no hook members.ui-renderer, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, SessionProvider, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency.src/client/, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.Non-negotiables across the layers:
notifyNow is only the direct echo of a user gesture; structural updates use microtask-batched markDirty, while visible streaming chunks use cumulative markFrameDirty. See ../api/session-controller/src/client/sessions/notifier.ts.Npm sections describe installation and development relationships; each build face independently decides what its artifact contains. verify-package-dependencies checks and repairs these rules; verify-client-packages owns Client loading and module requests.
peerDependencies and devDependencies. This includes the static packages because their Node face participates in the same Cordis plugin contract.packages/client/ is always covered; dsh.client marks a Client/Host package outside that directory. Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a ./client export alone does not select dependency policy.dsh.client.inject, and metadata-only peers belong only in devDependencies. Configuration-only entries absent from imports are listed in the dependency policy; Node companion value imports follow the Host rules below.dependencies when its exact module specifier and runtime export appear in safeHostDependencyExports. Exports whose identity or module state must be shared appear in peerRequiredHostExports and keep the whole package edge in matching peerDependencies and devDependencies. The verifier rejects unclassified exports before --fix writes manifests.devDependencies, whether inlined into lib/client.js or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure (decision).dsh.client.external; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies (tsdown.client.ts). Moving a name between npm sections must not silently change bundle contents.files; the repository publint pass checks the exact publication view.Client business code may statically read process.env.DSH_CLIENT_*; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to undefined, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in web/src/platform.ts: PLATFORM_MODULES names shell-seeded React, Cordis, and static Client libraries; PRELOADED_CLIENT_EXTERNALS is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists.
client/store, ui-primitives, ui-slots, or ui-dockkit in package manifests.dsh.client.external is not a feature-plugin dependency mechanism. Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing /client aliases the package row.import type is erased and creates no request.PLATFORM_MODULES supplies an exact static-table key. There is no dsh.client.provide alias protocol.verify-client-packages rejects malformed or redundant requests, missing suppliers, and synchronous request cycles.Three declarations read like dependency edges and none is interchangeable: Cordis service inject, module-graph external, and dsh.client.inject — the informational package-name edges of the new-package checklist.
Cordis service inject |
module graph external |
|
|---|---|---|
| Unit | service name | module specifier |
| Timing | runtime; the fiber waits | materialization; the require handed to a factory is synchronous and cannot wait |
| Unsatisfied | stays PENDING, with no timeout | throws on the spot |
| Who may satisfy it | any plugin providing that service, replaceable | the single module identity, not replaceable |
| Cycles | allowed | rejected |
The seam is loader.internal = modules: cordis reaches plugin code through EntryTree.import, so every module request must be satisfiable before cordis can order activation above it. The modules node half emits rows in topological order, and ClientModuleSystem.import/prefetch recursively registers dynamic provider factories before their consumers materialize. This module order is independent from Cordis activation: a provider that injects services can register first and activate last.
packages/client/web is not a Loader entry. Its static imports seed PLATFORM_MODULES; parser-preloaded dynamic rows remain ordinary Loader entries and ordinary lib/client.js artifacts.
ConversationNodeDefinition and its keyed conversation.chat.node renderer; do not add its event switch or fold to Session, SessionManager, or a central built-in dispatcher. Follow the Conversation reference.match(event) reads only the current SessionEventLike. Every scalar event or packed Assistant run in a multi-input Context carries or independently derives the same stable business id; update folds one Match into State and remains deterministically replayable by logical log seq. Packed rows are update-only, and a Definition that consumes Assistant deltas implements both scalar and chunkrow/* branches without expanding members.buildLocationData(), and consume final Node data or constrained Location hooks.One UI feature = one plugin package (src/client/ browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: contract/ (the only shared API), domain directories that never import a sibling domain, and apply.ts as the single cross-domain assembly point; scripts/verify-client-domain-graph.ts enforces the levels. Registration goes through slots.register in apply — never module-level side effects.
docs/web-styling.md is authoritative. Shared --dsw-* tokens and global sheets live in ui-theme/src/styles/; feature components consume semantic aliases through CSS Modules and clsx, with no literal colors, component library, or Tailwind. Code comments are English.
Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard t seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. pnpm run verify-client-ui-i18n enforces source ownership (decision).
The GUI test structure (three tiers, lane map) is settled in the GUI testing system note; repo-wide policy in docs/testing.md.
pnpm run test:coverage). Genuinely unreachable defensive arms take a /* v8 ignore -- <reason> */ comment with a real reason, never a bare ignore.// @vitest-environment jsdom pragma on the spec's first line; the shared config stays node-env.Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
pnpm run test:gui (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.apps/web, Vite, dsh-host-webserver, connection/handler/SSE) — additionally DSH_SNAPSHOT=replay pnpm run test:web: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without DEEPSEEK_API_KEY) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use DSH_SNAPSHOT=refresh only after confirming an intentional output change, or DSH_SNAPSHOT=record with a key to re-record fixtures.If test:gui is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
Bringing up a new packages/client/<name> plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons):
package.json (@deepseek-ai/dsh-client-<name>, exports ././client/./src/*/./package.json, optional ./invariant only for an independent runtime relationship, dsh.client manifest, files list), tsconfig.json (extends tsconfig.base.client.json, one references entry per workspace dependency), tsdown.config.ts (clientBundle(id, ['lib/types/index.js']), plus lib/types/invariant.js only when published), src/index.ts (empty node-half apply), optional src/invariant.ts, src/css-modules.d.ts when using CSS Modules, and README.md with the Model Experience section and the reason when no invariant is published.tsconfig.client.json aggregate references entry; a dsh.client row in packages/bundle/web-app/cordis.patch.yml; a packages/bundle/web-app/package.json dependency (profile boots resolve bare row names through the healed $DSH_HOME/profiles/node_modules fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). pnpm-workspace.yaml already globs packages/*/*.platform: 'web' always, and the declaration requires a ./client export (the scan throws without one); immediately: true only for stage-one-prefetch infrastructure rows. inject lists package-name dependency edges — they are informational only (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on services, nothing else. A non-baseline external request sequences its dynamic supplier ahead of the consumer — see shared modules.ctx.slots.inject(name, () => ctx.slots.register(...)); it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare slots.register into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.pnpm --filter <pkg> bundle) before probing a live dsh web server — the registry serves lib/client.js, not sources../client export; which non-baseline value imports require dsh.client.external; which Host value imports are ordinary dependencies; which Browser and type inputs are dev-only; and whether files covers every relative runtime import and emitted asset.Check the ui-primitives catalog before writing a control. A plugin cannot import another plugin's component, so ui-primitives is the only place a control can be shared; the catalog states when to reuse, when to promote, and when your own package is the right home.
Compose through register: add the slot to SlotMap, declare it in its parent entry's children, and register your component — see the Slots reference. No other composition route exists.
Type the props as the four shares (PropsRuntime & PropsRenderSlots & PropsStore & inject face) — derive, don't hand-write. Shared/surviving state goes in a createXXXStore() factory declared at register; component-private state stays local.
Component tests feed props directly (createXXXStore().create() for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
Tokens only in CSS; product copy follows the localization rule above; English comments.
pnpm run test:gui green; if the component changes visible assembled output, also run DSH_SNAPSHOT=replay pnpm run test:web.
Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.