# dsh-generative-ui **This CLAUDE.md is the design doc.** Change it first, then the code — where they disagree, this file wins and the drift is a bug. ## 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 in the side panel. 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 **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 Two user-supplied transcripts, same machine, same plugin, different preset: | | `standard` | `minimal` | | --- | --- | --- | | system prompt | 27524 chars | **45 chars** | | tools | 25, including `skill` | **2** (`bash`, `str_replace_editor`) | | `ui4a` in the request header | 6 | **0** | | `canvas` in the request header | 8 | **0** | `config/agent-presets/minimal/agent.cordis.yml` says why, in its own comment: the persona carries `complete: true`, and *"the persona is the complete system prompt, so global identity, Web orientation, tool guidance, and **later assembly listeners cannot add prompt text**"*. Our resident section is exactly a later assembly listener (`ctx.systemPrompt.section()`), so it is dropped. The skill is unreachable for a second, independent reason: the `skill` TOOL comes from the preset's own `tool-skill` row, which `standard` has and `minimal` does not. **The client half is unaffected, and that is measured, not inferred.** `src/client/index.ts` injects only `slots` and `sessions` — both from the host composition, not the preset — and `claimInlineFences` matches on rendered CONTENT (§3.5), never on a language tag or a prompt. Verified in a real `dsh web` switched to 极简模式: asked to echo a four-backtick `ui4a/tsx` block verbatim, the reply rendered as a card (a11y tree showed `heading` + `paragraph`, not a code block), and the canvas launcher was still in the corner. So under `minimal` the model does not know the format exists, but a user who dictates one still gets a rendered card. **Putting it back is a preset copy, not a prompt change.** The instinct is to write the triggers into `SKILL_DESCRIPTION` so the model loads the skill itself — but under `minimal` there is no `skill` TOOL to call, so no description reaches anything. Both blocks have to be lifted, and both live in the composition. Measured end to end: cp -R /config/agent-presets/minimal ~/.dsh/.agent-presets/minimal-ui4a # in agent.cordis.yml: delete `complete: true`, append two rows — - id: skill-filesystem name: '@deepseek-ai/dsh-skill-filesystem' - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' `$DSH_HOME/.agent-presets` is the user root (`includeUserRoot` defaults true) and does not exist until the first local preset is written. The new entry appeared in the composer's preset menu on the next `dsh web`, and a session on it answered `帮我算下等额本息月供` with **a card**: the transcript shows `上下文注入 · skill-catalog` and `Skill · generative-ui`, the input was **29.1K tokens** where plain `minimal` would be a few hundred, and the reply rendered a mortgage panel with the right figure (4,890.17), three stat blocks and three live inputs. dsh ships a `创造模式` preset whose whole purpose is authoring these, which is the supported path for a user; the copy above is the same thing done by hand. Nothing to fix in the plugin: `minimal` is doing exactly what it says. Worth knowing before debugging a report that "the plugin does nothing" — ask which 模式 the session was on first. ### 2.4 Slots you may touch and slots you may not | Slot | kind | Use | | --- | --- | --- | | `conversation.chat.node` | keyed | **Inline cards.** Unregistered kinds degrade gracefully into a JsonBlock | | `conversation.view` | list | **Canvas view tab**, alongside Conversation / Trace | | `shell.overlay` | list | Frame-wide overlay, available if we need it | **`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 `