# dsh-generative-ui ## DSH 0.1.7 host compatibility Keep the legacy `settings.installSection` hookup only where that service still exposes it. New hosts own settings through profile `Config`: mark `allowExec` volatile, read the current reference, and rebuild the capability scope on the owning Loader's volatile update. A disabled command route and its prompt/skill documentation must disappear together. Do not change the product default. The shell route uses `execute(spec).result()` on new hosts and `run(spec)` on old hosts, with explicit `onExpiry: "kill"`. Keep the session sandbox policy and per-stream output contract. Cancellation follows response-socket closure, not request-body completion; a response that is already destroyed when the body finishes starts no command at all, since there is nobody left to read the output. Both model requests and repair notices use the producer-owned `dsh-generative-ui` message source; they are not human input. **This CLAUDE.md is the design doc, and `docs/` holds its evidence.** Change this file first, then the code — where they disagree, this file wins and the drift is a bug. The files under `docs/` carry the measurements and narratives behind its rules; where they disagree with this file, this file wins there too. Everything here is written to be read before you act. Evidence, history and per-topic reference live in `docs/` — see the index at the bottom. ## In one sentence A Web plugin for the DeepSeek Harness (dsh): the model writes TSX into the workspace, the Web half **compiles it as it streams and renders it with state preserved** into live UI — inline cards in the chat stream, canvas views as tabs in the host's right Sidebar. The rendering stack is `partial-tsx` + `partial-react` + `@esm.sh/tsx`, the same as `../ui4a-playground`. **That one owns its host; this one is a guest in someone else's** — nearly all the difference lives in §2. ## 0. Toolchain `bun run lint` scans the maintained `src`, `test`, `scripts`, `eval`, `website`, and `types` directories explicitly. Local worktrees and sibling checkouts nested at the repository root must not change its diagnostics. **This package uses bun** (`bun install` / `bun run build`). dsh's pnpm only governs the profile directory — `dsh plugin --profile web add link:` is dsh forwarding to pnpm, and it does not care how we install our own deps as long as `lib/*.js` exists. `package.json`'s `prepare: bun scripts/build.ts` cannot be removed: installing from git gets you source with no `lib/`, and the plugin then silently fails to load. ## 1. Layers | Layer | Where | Job | | --- | --- | --- | | Node half | `src/index.ts` | Host-only capabilities: wasm asset route, fs watching, session events | | Browser half | `src/client/` | Slot registration, conversation node, render runtime | | Render runtime | `src/client/runtime/` | Our own compiler + import-map + host-bridge | One npm package, two exports (`.` and `./client`), declared through `package.json`'s `dsh` field. **Do not split the package** — dsh's model is one package with two halves. ## 2. What the host gives you and what it doesn't Everything here was **measured**, not read out of a doc. Read it before changing anything; it saves a whole round of re-deriving. ### 2.1 The shared platform module table shrank to 7 entries in rc.8 `react` / `react/jsx-runtime` / `react-dom` / `react-dom/client` / `@deepseek-ai/cordis` / `dsh-client-ui-slots` / `dsh-client-ui-primitives` rc.7 also had `dsh-client-web-react` / `dsh-client-ui-attachment` / `dsh-client-schema-form`; rc.8 dropped them (diff `PLATFORM_MODULES` in `@deepseek-ai/dsh-client-web`'s `lib/index.js` across the two versions). **This table shrinks**, so re-check it on every dsh upgrade: listed = resolved at runtime via `require()` to the host's singleton; not listed = bundled into our own output; **listed but absent from the host = the `require()` fails and the whole client dies**. **Re-checking it is now one command: `./scripts/platform-table.sh`** — verified 2026-08-23, the host still exposes exactly the 7 entries `PLATFORM_MODULES` lists. The table is **not in any installed package** (70 `@deepseek-ai` packages here, not one contains the string `"react/jsx-runtime"`, and `dsh-client-web` is not among them); it is compiled into the shell's own frontend bundle, so the script serves the app, fetches `/assets/index-*.js`, and diffs. It exits 1 on a mismatch and **2 when the probe itself cannot read the bundle** — the first version reported "matches" with both sides empty because `rg` was not on the script's PATH. It lives in `scripts/build.ts` as `PLATFORM_MODULES` (`scripts/smoke.ts`'s `PLATFORM` mirrors it — change one and you must change both, or smoke stops catching it). **Neither `scheduler` nor `react-dom/server` is in the table.** The latter is required for preflight, so it has to be inlined, and **pinned to 18** to match the React bridged in. ### 2.2 React is 18.3.1, not 19 Measured on the host: `React 18.3.1` — **re-confirmed 2026-08-23** by fetching the shell's own `/assets/index-*.js` and grepping it: exactly one React version string (`"18.3.1"`), and the React 19-only APIs are all absent from the bundle (`useActionState`, `useOptimistic`, `useEffectEvent`, `use(` → 0 each) while the 18 APIs this plugin leans on are present (`useSyncExternalStore` ×4, `forwardRef`, `renderToString`). Repeat with `./scripts/platform-table.sh`'s fetch step if a dsh upgrade ever makes this look doubtful. Generated code reaches the host's **same** React instance through `host-bridge` — a second copy makes hooks fail silently. Consequences: - When writing components in this project, **React 19-only APIs are all off-limits** (`use()` / `useActionState` / `useOptimistic` / `cache` / `useEffectEvent` / ref-as-prop). Skip the whole `react19-*` section of `vercel-composition-patterns` — we still need `forwardRef`. - `partial-react`'s peer is now `^18.3.0 || ^19.0.0` (widened in 0.0.5; it used to be `^19.0.0` only), and at runtime it only touches APIs 18 already has (measured — see MindLab-Research/macaron-genui-demo#1715). There used to be one spot in `runtime.ts:425` where `Promise` wasn't assignable to 18's `ReactNode`; upstream has since fixed it with a cast. Note that **`skipLibCheck` does nothing here** — that option only skips `.d.ts`, and these two packages ship `.ts` source, so a value import really does compile them. This is why the packages' own source used to fail `tsc`; **`partial-react@0.0.6` fixed it** (see §2.6), so `typecheck` is plain `tsc --noEmit` again and there is nothing left to filter. - `partial-tsx` / `partial-react` use `toSorted` / `findLast` / `toReversed`, so `lib` must be ≥ `ES2023`. ### 2.3 Non-JS assets only reach the browser through your own route The `/plugins` route **hardcodes `/client.js` and `/client.js.map`**; everything else 404s. The static handler **used to** answer unmatched paths with index.html + 200 (not 404), so dropping wasm into its dist "looked like it worked" right up until `instantiateStreaming` reported a baffling magic-word error. **Re-measured 2026-08-23: that is fixed** — `/some/spa/route`, `/assets/missing.js`, `/missing.wasm` and an unmatched path under our own prefix all return **404 with a zero-length body**. The route we serve ourselves is unchanged and still correct: `200 · application/wasm · cache-control: immutable`. The fix is a route from the Node half — `ctx.webServer.register({ kind: 'prefix', path: '/dsh-generative-ui/assets', ... })` — with the browser half hardcoding that URL. Measured: `200 · application/wasm · 2,610,857 B`. Two constraints: the path **must be namespaced by package name** (a duplicate `(kind, path)` throws, and throwing during apply means the whole plugin silently fails to load); and **the Electron shape has no webServer routing**, so desktop needs another answer (or falls back to base64 inlining, +3.3MB). `import.meta` doesn't exist in the CJS output, so upstream's bundler-agnostic `import.meta.resolve(...)` trick is unavailable — the URL has to be a hardcoded constant. ### 2.35 The `minimal` agent preset silences the whole Node half, and the client half survives it A report that "the plugin does nothing" is very often a session on 极简模式: that preset's persona carries `complete: true`, so the resident prompt section is dropped and the `skill` tool is not present at all. The client half is unaffected — it renders a dictated fence whatever the preset. Nothing to fix in the plugin. **Ask which 模式 the session was on before debugging further.** Measured end to end, including how to put the prompt back (it is a preset copy, not a prompt change), in [docs/host-notes.md](docs/host-notes.md). ### 2.4 Slots you may touch and slots you may not | Slot | kind | Use | | --- | --- | --- | | `sidebar.right.pane.tab` | keyed, `scope: 'session'` | **Canvas tab body.** Keyed by the tab definition's `id`, not its `kind` | | `sidebar.right.pane.tab.title` | keyed, `scope: 'session'` | The canvas tab's chip text; omit and the chip keeps the title captured at open | | `conversation.chat.node` | keyed | **Inline cards.** Unregistered kinds degrade gracefully into a JsonBlock | | `shell.overlay` | list | Frame-wide overlay, available if we need it | **The canvas now lives in the right Sidebar as a registered tab type** (§3.6), which is the seam the host actually ships for this: `dsh-client-ui-sidebar-right` owns a tab-type registry, and its own doc says *"a tab type may ship from outside this repository"* with `priority: 'extension'` defined as *"a type from outside the product"* — the band that outranks every viewer the product ships. The `sidebar-files` / `browser` / `terminal` / `documentpreview` packages are all just tab types; so are we. **`details` (the right-hand column) is off-limits — and as of 2026-08-23 the mechanism below can no longer be found.** Checked against rc.8: the shell bundle contains exactly one `kind:"single"` and it belongs to the `root` slot (`declaredBy: "(built-in)"`), while `conversation.details` appears **nowhere in any implementation or in the shell bundle** — only in two READMEs of `dsh-client-ui-conversation`, whose text describes tool presentation dispatching through `conversation.chat.node`, the slot we already use. So the specific chain recorded here cannot be reproduced. Kept as a prohibition anyway: it describes something we deliberately do not do, the priority mechanic that makes a `single` slot evictable is unchanged, and nothing was gained by registering there. **Treat the reasoning below as history, not as current fact.** It's `kind: 'single'` and currently held by ui-conversation's `DetailsPanel`. A dynamically loaded package gets a ctx facade that overrides priority (decrementing into the negatives per install), so **we necessarily beat the shipped 0** — registering silently evicts DetailsPanel, taking the `conversation.details.tool` sub-slot it declares with it, which **breaks tool-call inspection app-wide**. There is no handoff API. Registration must be wrapped in `ctx.slots.inject(name, () => ...)`: these slots are declared by ui-conversation at runtime, and registering early throws `slot "..." is not declared`. ### 2.5 CSS has to mark itself The loader's `claimStyles(id)` runs `document.querySelectorAll('style:not([data-plugin])')` and **claims every match** for whichever plugin is currently materializing. So every `