# Notes from building a DSH client plugin
Three failures shaped this plugin. Each one produced **no visible error** — the
plugin reported `active` in the loader while doing nothing at all, or worked,
or worked slowly — and each one is generalizable to any DSH client plugin.
Everything below is quoted from the shipped bundles in the profile, so the
conclusions are traceable rather than inferred.
---
## 1. A slot entry that throws once is silently abdicated
### Symptom
The host loader reported the plugin as `active`. The feature did nothing. No
error surfaced anywhere in the UI.
### How it was found
The slots Inspect provider returns the live occupant list for one exact slot:
```json
{"id": "slash-menu", "order": 0, "active": true}
{"id": "command-popup", "order": 1, "active": true}
{"id": "feedback-dialog", "order": 2, "active": true}
{"id": "selection-quote", "order": 30, "active": false}
```
The registration was there, and `active` was false. `active` has exactly one
source. In `dsh-client-ui-renderer`'s slot core:
```js
entriesOfSlot(slot) {
// ...
for (const entry of record.entries) {
if (this.abdicated.has(entry)) continue; // ← the only way to be inactive
const key = kind === "keyed" ? entry.options.key
: kind === "list" ? entry.options.id
: undefined;
if (!seen.has(key)) { seen.add(key); survivors.push(entry); }
}
return survivors;
}
```
and `abdicated` is written in exactly one place:
```js
reportEntryError(slot, entry, error, { abdicate }) {
if (abdicate) {
if (this.abdicated.has(entry)) return;
this.abdicated.add(entry);
// ... mark dirty so the outlet re-renders onto the next survivor
}
// ... notify error listeners
}
```
which the per-entry boundary calls on **any** render error:
```js
var SlotErrorBoundary = class extends react.Component {
static getDerivedStateFromError(error) {
if (error instanceof SlotAssemblyError) throw error; // assembly failures still crash loud
return { failed: true };
}
componentDidCatch(error) {
console.error(`slot entry crashed in '${this.props.slotKey}':`, error);
this.props.onEntryError(error); // → abdicated.add(entry)
}
render() {
if (this.state.failed) return jsx("div", { "data-slot-error": this.props.slotKey });
return this.props.children;
}
};
```
### The consequence
**One throw during render and the entry is skipped forever.** The registration
survives, the loader says `active`, the DOM gets an empty
`
` that `display: contents` keeps invisible, and the user
sees a feature that simply does not work.
### The specific throw
Standard hooks such as `useInput` are bound through a selector hook that the
renderer invokes **during render**:
```js
function bindSnapshotSelector(source) {
const subscribe = (fn) => source.subscribe(fn);
const getSnapshot = () => source.getSnapshot();
return function useSelector(sel, eq) {
return useSyncExternalStoreWithSelector(subscribe, getSnapshot, undefined, sel, eq);
};
}
```
So a selector that dereferences an absent snapshot —
```js
const draft = useInput(state => state.draft) // TypeError when state is undefined
```
— throws inside render and takes the entry with it. (The bundled
`use-sync-external-store` copy makes this worse in the shim path: its change
check is `try { return !is(inst.value, getSnapshot()) } catch { return true }`,
so a throwing selector reads as "changed forever". React 18 uses its own
`useSyncExternalStore` instead, where the same throw surfaces once, straight to
the boundary.)
### What to do
- Write every selector defensively: `state => state?.x ?? fallback`. It costs
nothing.
- **Install your own error boundary inside the slot's.** The slot's boundary is
outside yours and will abdicate; yours can report the failure on screen and
keep the entry alive. This plugin's `SelectionQuoteGuard` does exactly that,
and it is the reason the real cause was ever found.
---
## 2. Platform-seed module exports drift between shell builds
### Symptom
After the boundary was added, the on-screen report gave the real error:
```
dsh-selection-quote crashed
Error: Minified React error #130; ...
at bi (dsh-app://app/assets/index-5SrrfWpU.js:56:49799)
```
React #130 is *"Element type is invalid: expected a string ... or a
class/function but got: undefined"*. The stack's asset name was the important
part — **that file is not the one being read while developing.**
### What is actually served
For the desktop app, `app.asar/lib/main.js` resolves the frontend like this:
```js
return serveWebDocument(
request,
join(resources.dsh, "node_modules", "@deepseek-ai", "dsh-web-frontend", "dist"),
);
```
`resources.dsh` is `…/app.asar/dsh`, so the desktop app serves the dist **bundled
inside `app.asar`** — not the copy published in
`$DSH_HOME/profiles/node_modules/@deepseek-ai/dsh-web-frontend`. They are
different builds, and the two files tell the story:
| | desktop (actually running) | profile copy |
|---|---|---|
| asset | `index-5SrrfWpU.js` | `index-BKQ_L1z6.js` |
| icon naming | `IconArchiveOutlineRegular` / `…Medium` | `IconArchiveOutline16` / `…20` |
| `IconPlusOutline16` | **absent** | present |
The seed table itself is identical in both — the same nine words:
```js
function seed() {
return {
react, "react/jsx-runtime": …, "react-dom": …, "react-dom/client": …,
"@deepseek-ai/cordis": …,
"@deepseek-ai/dsh-client-store": …,
"@deepseek-ai/dsh-client-ui-slots": …,
"@deepseek-ai/dsh-client-ui-primitives": …, // ← present, but renamed inside
"@deepseek-ai/dsh-client-ui-dockkit": …,
};
}
```
So `require("@deepseek-ai/dsh-client-ui-primitives")` resolved fine, and the
**named export** did not exist:
```js
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives';
// → undefined
jsx(undefined, {}) // → React #130 → entry abdicated (see §1)
```
### What to do
- **Treat every platform-seed module except React as having an unstable
surface.** `react`, `react-dom` and `react/jsx-runtime` are stable APIs; a
component library shipped inside the shell is not.
- Draw what you need yourself. This plugin's 14px glyph is an inline SVG, and
the bundle now requires nothing but React —
`require("react")`, `require("react-dom")`, `require("react/jsx-runtime")`.
There is a regression test asserting exactly that list.
- When a client plugin misbehaves, **read the asset name in the stack trace and
make sure you are reading that file.** Developing against a different copy of
the shell than the one running is a trap that costs hours.
---
## 3. A capture-phase scroll listener that measures forces a layout per event
### Symptom
After selecting a passage, the action pill took a second or two to appear.
### Cause
Two things compounded:
1. Probing was deferred through `requestAnimationFrame`, putting at least a
frame — and, when the page was busy, whatever the frame queue was doing —
between the end of the gesture and the pill.
2. The `scroll` listener was registered on `document` **in the capture phase**,
and every event re-measured the whole selection with
`range.getBoundingClientRect()`, which **forces layout**. One scroll event,
one forced layout, across the entire document — and drag-selecting with
autoscroll fires them continuously. None of that work could ever be visible:
the pill is deliberately withdrawn while a drag is in progress.
### What to do
- **Measure synchronously in the event that reports the change.** A
`selectionchange` reports an already-committed selection; there is nothing to
wait for.
- **Add `mouseup` / `keyup` as a direct path to the end of a gesture**, so the
pill does not depend on when the browser decides to fire `selectionchange`.
- **Let `scroll` / `resize` only reposition.** If no pill is showing, return
immediately; during a drag the hit is already `null`, so an entire drag costs
zero measurements.
- **Deduplicate the result.** If the measured text and box are unchanged, do not
write state at all.
---
## 4. A transcript selection never clears on its own
### Symptom
After selecting a passage, pressing anywhere else in the transcript left the blue
highlight in place — permanently. The floating pill stayed with it, so the whole
feature looked like a stuck overlay.
### Cause
Chromium collapses an existing selection on mousedown **only when the press lands
on a target that allows selection**. The transcript's own chrome — the padding
around a message, its row wrapper, the hover action strip — opts out, so a press
there leaves the selection exactly as it was. Nothing in the plugin held it; the
browser simply had no instruction to drop it.
This is the one failure in this document that is not the plugin's fault, and it
still has to be the plugin's problem: the plugin is what makes people select
transcript text at all, so it is where the annoyance surfaces.
### What to do
Dismiss the selection on a primary press that lands outside your own surface,
with the two guards that keep native gestures working:
```js
if (event.button !== 0 || event.shiftKey) return; // extend / context menu
if (selectionInTranscript()) window.getSelection()?.removeAllRanges();
```
- `button !== 0` keeps the secondary press, which is how the context menu offers
**Copy** — clearing there would destroy the thing being copied.
- `shiftKey` keeps shift-click, which *extends* a selection; clearing first would
destroy exactly what the user is extending.
- `selectionInTranscript()` is a cheap walk (`closest('[data-chat-flow]')`), not
a measurement — this runs on every press and must not force layout (see §3).
---
## Note: `dsh plugin add` mounts a bundle for you
`dsh plugin --profile add ` does two things when the installed
package declares `dsh.bundle.patch`: it installs the package with pnpm, **and** it
appends the package name to the profile's `dsh.profile.bundles`. It does not
mention the second part anywhere in its output.
That matters because the obvious documentation — "install it, then add the name
to the bundle list" — produces a duplicate entry that mounts the same layer
twice. Check the list rather than appending to it.
## Checklist for a new DSH client plugin
1. **Mount in the right scope.** Session-scoped standard props (`inputActions`,
`useInput`) only reach Session-scoped slots. A root-scoped seat can draw a
button but cannot act on the composer.
2. **Write defensive selectors** — `state => state?.x ?? fallback`.
3. **Ship your own error boundary**, inside the slot renderer's. Otherwise the
first throw is a silent, permanent abdication.
4. **Require nothing but React** from the platform seed; inline everything else.
5. **Never bind high-frequency document listeners that measure.** Coalesce, and
only measure when something is on screen.
6. **Check `active` on the slot occupant list** when a feature "does nothing" —
it distinguishes "never registered" from "registered and abdicated".
7. **Set the profile's `DIAGNOSTIC`-style switch before guessing.** Every
failure above was found by making the plugin report its own state on screen.