dsh-generative-ui 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +17 -4
  2. package/lib/client.js +484 -98
  3. package/lib/client.js.map +19 -17
  4. package/lib/index.js +523 -34
  5. package/lib/types/card-failure.d.ts +88 -0
  6. package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
  7. package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
  8. package/lib/types/client/canvas/collect.d.ts +45 -0
  9. package/lib/types/client/canvas/index.d.ts +43 -0
  10. package/lib/types/client/canvas/mount.d.ts +30 -0
  11. package/lib/types/client/canvas/panel-css.d.ts +1 -0
  12. package/lib/types/client/canvas/read.d.ts +12 -0
  13. package/lib/types/client/canvas/subpages.d.ts +20 -0
  14. package/lib/types/client/canvas/useDismissable.d.ts +15 -0
  15. package/lib/types/client/index.d.ts +20 -0
  16. package/lib/types/client/runtime/GenUISurface.d.ts +189 -0
  17. package/lib/types/client/runtime/bindings.d.ts +143 -0
  18. package/lib/types/client/runtime/compiler.d.ts +35 -0
  19. package/lib/types/client/runtime/inline-fence.d.ts +50 -0
  20. package/lib/types/client/runtime/observe.d.ts +30 -0
  21. package/lib/types/client/runtime/register.d.ts +2 -0
  22. package/lib/types/client/runtime/registry.d.ts +7 -0
  23. package/lib/types/client/runtime/report-error.d.ts +64 -0
  24. package/lib/types/client/runtime/segments.d.ts +18 -0
  25. package/lib/types/client/runtime/state.d.ts +18 -0
  26. package/lib/types/client/runtime/uno-config.d.ts +16 -0
  27. package/lib/types/client/runtime/uno.d.ts +50 -0
  28. package/lib/types/client/session.d.ts +26 -0
  29. package/lib/types/contract-assets.d.ts +51 -0
  30. package/lib/types/contract.d.ts +56 -0
  31. package/lib/types/index.d.ts +298 -0
  32. package/lib/types/prompt.d.ts +13 -0
  33. package/lib/types/skill.d.ts +27 -0
  34. package/package.json +8 -4
  35. package/src/card-failure.ts +106 -0
  36. package/src/client/canvas/CanvasPanel.tsx +10 -4
  37. package/src/client/canvas/index.ts +1 -1
  38. package/src/client/index.ts +22 -9
  39. package/src/client/runtime/GenUISurface.tsx +172 -48
  40. package/src/client/runtime/bindings.ts +3 -2
  41. package/src/client/runtime/compiler.ts +24 -2
  42. package/src/client/runtime/inline-fence.ts +185 -8
  43. package/src/client/runtime/register.ts +16 -4
  44. package/src/client/runtime/report-error.ts +89 -34
  45. package/src/client/runtime/state.ts +13 -1
  46. package/src/client/runtime/uno-config.ts +13 -0
  47. package/src/client/session.ts +10 -3
  48. package/src/contract-assets.ts +11 -0
  49. package/src/index.ts +113 -10
  50. package/src/prompt.ts +154 -10
  51. package/src/skill.ts +296 -22
  52. package/types/fs.d.ts +15 -1
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Host half — serves the @esm.sh/tsx wasm the browser half compiles TSX with.
3
+ *
4
+ * The shell's /plugins route hard-codes the `/client.js` and `/client.js.map`
5
+ * suffixes and 404s everything else, and dsh-host-frontend-static owns the sole
6
+ * fallback seat (and answers misses with index.html + 200, so dropping the wasm
7
+ * there would fail as a confusing magic-word error). A plugin-owned webServer
8
+ * route is the way to ship bytes; dsh-latex-tools serves MathJax the same way.
9
+ * @module dsh-generative-ui
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import type { Context } from "@deepseek-ai/cordis";
13
+ import z from "@deepseek-ai/schemastery";
14
+ import { CardFailures } from "./card-failure.ts";
15
+ /**
16
+ * The one field of the assembling agent this plugin reads: its id, which IS the session id.
17
+ *
18
+ * Declared here rather than by importing `@deepseek-ai/dsh-agent`, which owns the real
19
+ * augmentation. That package also augments cordis `Context` with the HOST's `sessions` service,
20
+ * and the augmentation is global — pulling it in retyped `ctx.sessions` inside `src/client/`,
21
+ * where the session store is the browser runtime's and has `getSnapshot`/`binding` instead.
22
+ * Six type errors in files this change does not touch. One optional field is the whole
23
+ * dependency, so it is cheaper to state it than to import the package that carries it.
24
+ */
25
+ declare module "@deepseek-ai/dsh-system-prompt" {
26
+ interface AssembleContext {
27
+ agent?: {
28
+ readonly id: string;
29
+ };
30
+ }
31
+ }
32
+ export declare const name = "dsh-generative-ui";
33
+ export declare const inject: string[];
34
+ /** The settings section this plugin owns; the key under `dsh-generative-ui:` in settings.yaml. */
35
+ export declare const SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
36
+ /**
37
+ * Plugin settings. A schemastery schema, not a TypeScript type: the host validates the
38
+ * `settings.yaml` section against it and builds the settings UI from it, so a plain interface
39
+ * would be a switch nobody can find and nobody can check.
40
+ *
41
+ * `allowExec` is ON by default, and the trade it makes is worth stating rather than assuming.
42
+ *
43
+ * What it is NOT: an escape from the fence. The route resolves `ctx.sandboxPolicy` for the session
44
+ * and hands it to `ctx.shell`, so a card's command opens nothing the agent's own bash has not
45
+ * already opened, in a workdir pinned to a live session's workspace, killed after 15s and on the
46
+ * reader closing the page.
47
+ *
48
+ * What it IS: the approval layer does not reach here. `approval.request()` needs an open turn and
49
+ * an agent, and a card fires on a reader's keystroke long after its turn ended — so the per-command
50
+ * fence is the sandbox policy alone. Under `workspace-write` a card can therefore delete inside the
51
+ * workspace with nobody agreeing to it command by command. The prompt answers that where it can
52
+ * ("observe, never change"; anything destructive belongs in a `sendMessage` the user agrees to),
53
+ * which is guidance, not a fence.
54
+ *
55
+ * Turned on because the capability it gates is the ordinary case, not the exotic one: search
56
+ * (`fd`, `rg`) that no `$dsh/fs` call expresses, `lint` / `check` / test runs whose output IS the
57
+ * card, `git log`, and the twenty-`readdir` walks a single `ls -R` replaces. Off, a model reasoning
58
+ * from a five-capability set writes those as file-by-file loops or does not write the card at all.
59
+ */
60
+ export declare const Config: z<Schemastery.ObjectS<{
61
+ allowExec: z<boolean, boolean>;
62
+ }>, Schemastery.ObjectT<{
63
+ allowExec: z<boolean, boolean>;
64
+ }>>;
65
+ export type Config = ReturnType<typeof Config>;
66
+ export { ASSET_PREFIX, WASM_PATH } from "./contract-assets.ts";
67
+ /**
68
+ * An absolute path to one of the package's import maps, or undefined when it is not there.
69
+ *
70
+ * `existsSync` is the point. `fileURLToPath` only rejects a malformed URL — it happily returns a
71
+ * path to a file that does not exist, which is what this used to do: installed in a shape where
72
+ * the package root is not two levels up, the skill was handed a path that resolves to nothing
73
+ * and told the model to pass it to `-i`. The failure then surfaces as `genui check` reporting
74
+ * `Cannot find module "$dsh/fs"` on correct code, and the model "fixes" imports that were right.
75
+ */
76
+ export declare const resolvedMap: (relative: string, importMetaUrl: string) => string | undefined;
77
+ /** Exported for `test/routes.test.ts`: a prefix route that stops checking its pathname serves the whole prefix. */
78
+ export declare function serveAsset(req: IncomingMessage, res: ServerResponse, file: string): Promise<void>;
79
+ /**
80
+ * Serves one canvas file's current contents, or — with no `id` — the ids of every canvas
81
+ * in the workspace.
82
+ *
83
+ * The client could reconstruct a canvas from `write` tool arguments alone, and does while
84
+ * a write streams — but a model routinely follows a write with several `edit` calls, whose
85
+ * arguments carry a patch rather than the file. Reading the file is the only source that
86
+ * stays correct across every way it can change, including edits made outside the agent.
87
+ *
88
+ * Confined to the canvas directory by construction — the id is a path segment and the path
89
+ * is built from the contract — and to a live session's own workspace by the `cwd` check.
90
+ *
91
+ * That check is the security boundary, not a formality. This route answers any page the
92
+ * user has open: a simple GET triggers no preflight, so without it `?cwd=/anywhere` turns
93
+ * the plugin into a file-existence oracle for the whole disk. The client only ever sends
94
+ * the cwd it read off the current session, so matching against live sessions costs nothing.
95
+ */
96
+ /** Exported for `test/routes.test.ts`: the listing is the launcher's only source of truth and had no test. */
97
+ export declare function serveCanvas(liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
98
+ /** Context shape for the filesystem route; see the SessionStoreCtx note on why it is local. */
99
+ type FsCtx = {
100
+ fs: {
101
+ resolve: (path: string, opts?: {
102
+ cwd?: string;
103
+ }) => Promise<FsTargetLike>;
104
+ readText: (target: FsTargetLike) => Promise<string>;
105
+ readBytes: (target: FsTargetLike, signal: AbortSignal | undefined, maxBytes: number) => Promise<Uint8Array>;
106
+ listDir: (target: FsTargetLike) => Promise<{
107
+ name: string;
108
+ type?: string;
109
+ size?: number;
110
+ }[]>;
111
+ writeText: (target: FsTargetLike, content: string, expected?: undefined, signal?: AbortSignal, policy?: unknown) => Promise<unknown>;
112
+ };
113
+ sandboxPolicy: {
114
+ resolve: (request?: {
115
+ session?: unknown;
116
+ }) => unknown;
117
+ };
118
+ sessions: {
119
+ list: () => readonly {
120
+ id?: string;
121
+ header: {
122
+ cwd?: string;
123
+ };
124
+ }[];
125
+ };
126
+ };
127
+ type FsTargetLike = {
128
+ targetKey: unknown;
129
+ displayPath: string;
130
+ };
131
+ /**
132
+ * Reads, lists, and writes on behalf of a generated card.
133
+ *
134
+ * Everything goes through the host's `ctx.fs` carrying the session's own
135
+ * `ctx.sandboxPolicy`, so **a card may do exactly what the session may do** — under
136
+ * `read-only` the write is refused by the same fence that refuses the model's, with the
137
+ * same structured denial. Inventing a narrower boundary here would mean a second policy to
138
+ * keep in sync with the one the user actually sees in the composer.
139
+ *
140
+ * The `cwd` allowlist is still required, for the reason the canvas route documents: any page
141
+ * the user has open can call this, so without it the workspace is not the workspace.
142
+ */
143
+ /** Exported for `test/fs-route.test.ts`. */
144
+ export declare function serveFs(ctx: FsCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
145
+ /** Context shape for the shell route. `resolve` fills the executor's own defaults and caps. */
146
+ type ExecCtx = {
147
+ shell: {
148
+ resolve: (request: {
149
+ command: string;
150
+ workdir?: string;
151
+ timeoutMs?: number;
152
+ sandboxPolicy?: unknown;
153
+ signal?: AbortSignal;
154
+ }) => unknown;
155
+ run: (spec: unknown) => Promise<{
156
+ exitCode: number | null;
157
+ signal?: string | null;
158
+ timedOut?: boolean;
159
+ stdout: {
160
+ text: string;
161
+ truncated: boolean;
162
+ };
163
+ stderr: {
164
+ text: string;
165
+ truncated: boolean;
166
+ };
167
+ }>;
168
+ };
169
+ sandboxPolicy: {
170
+ resolve: (request?: {
171
+ session?: unknown;
172
+ }) => unknown;
173
+ };
174
+ sessions: {
175
+ list: () => readonly {
176
+ id?: string;
177
+ header: {
178
+ cwd?: string;
179
+ };
180
+ }[];
181
+ };
182
+ };
183
+ /**
184
+ * Runs one command on behalf of a generated card.
185
+ *
186
+ * The whole point is that a card can answer questions only a command can answer — git
187
+ * history, a test run, ripgrep across a big tree — without us re-implementing each one as a
188
+ * route. It carries the session's own sandbox policy, so this opens no door the model's own
189
+ * bash tool does not already have open, and a read-only session gets a read-only shell.
190
+ *
191
+ * A non-zero exit is a RESULT, not an error: a card wants to show `git status` failing in a
192
+ * non-repo as much as it wants to show it succeeding. Only infrastructure failures reject.
193
+ *
194
+ * **Why this does not go through `ctx.approval`, which is the seam for "may this action
195
+ * proceed?".** It is the right question and dsh's own `tool-bash` asks it — but the service
196
+ * cannot answer it here. `approval.request()` takes an `agent` and throws outright when the
197
+ * session has no open turn: *"approval.request() outside an open turn … Ask from inside the turn
198
+ * that needs the decision."* A card's command is the opposite of that — it fires on the reader's
199
+ * keystroke, long after the turn that wrote the card ended, with no agent on whose behalf to ask.
200
+ * `ctx.userQuestions.ask()` DOES work outside a turn (its `agent` is optional), so a per-command
201
+ * prompt is buildable; what stops it is that a card runs one command per keystroke, and a dialog
202
+ * per keystroke is not a safety feature. The setting is therefore about whether the CAPABILITY
203
+ * exists, and the per-command fence remains the session's own sandbox policy, which this passes
204
+ * through unchanged. Anything genuinely destructive belongs in `sendMessage`, where the user's
205
+ * next turn — and with it the whole approval machinery — is what runs it.
206
+ */
207
+ /** Exported for `test/exec-route.test.ts`. */
208
+ export declare function serveExec(ctx: ExecCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
209
+ /** Context shape for the search route. Only the two methods it calls, so a fake in a test is small. */
210
+ type WebCtx = {
211
+ web: {
212
+ search: (request: {
213
+ query: string;
214
+ maxResults?: number;
215
+ }, signal?: AbortSignal) => Promise<{
216
+ content?: string;
217
+ sources: readonly {
218
+ url: string;
219
+ title?: string;
220
+ snippet?: string;
221
+ publishedAt?: string;
222
+ }[];
223
+ truncated: boolean;
224
+ }>;
225
+ };
226
+ };
227
+ /**
228
+ * Runs one web search on behalf of a generated card.
229
+ *
230
+ * A card that wants live information — a price, a release date, what a package exports — otherwise
231
+ * has nothing: `fetch` from inside the surface is not the shape (no credentials, no CORS, no
232
+ * provider selection), and routing the question through `$dsh/ai` asks a model to recall rather
233
+ * than to look. `ctx.web` already owns provider selection, the result shape, and the truncation
234
+ * bound, so this forwards and does not re-decide any of it.
235
+ *
236
+ * SEARCH ONLY — see `WEB_SEARCH_PATH` for why `fetch` is not forwarded.
237
+ *
238
+ * `WebError` carries a `code` and the seam's own contract calls that set OPEN: a provider may
239
+ * raise a code this build has never seen. So the error is passed through as text rather than
240
+ * matched on, and the card decides what to show.
241
+ */
242
+ /** Exported for `test/web-search-route.test.ts`. */
243
+ export declare function serveWebSearch(ctx: WebCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
244
+ /** Context shape for the two services the AI route needs; see the SessionStoreCtx note. */
245
+ type LlmCtx = {
246
+ llm: {
247
+ stream: (options: {
248
+ provider: string;
249
+ model: string;
250
+ messages: readonly unknown[];
251
+ system?: string;
252
+ signal?: AbortSignal;
253
+ }) => AsyncIterable<{
254
+ type: string;
255
+ text?: string;
256
+ reason?: {
257
+ kind: string;
258
+ failure?: {
259
+ message?: string;
260
+ };
261
+ };
262
+ }>;
263
+ };
264
+ agentDefaultModel: {
265
+ currentSelection: () => {
266
+ provider: string;
267
+ model: string;
268
+ };
269
+ };
270
+ };
271
+ /**
272
+ * Streams one model call on behalf of a generated card.
273
+ *
274
+ * The card cannot call a provider itself — it has no credentials and should never be given
275
+ * any. `ctx.llm` already owns the adapter registry, the retry policy and the keys, and
276
+ * `agentDefaultModel` owns which model the app is set to, so this route is a forwarder:
277
+ * it converts a small JSON request into `llm.stream` and pipes the text deltas back.
278
+ *
279
+ * Same `cwd` allowlist as the canvas route, and for the same reason: any page the user has
280
+ * open can POST here, so without it this is an open model proxy for anything on the machine.
281
+ */
282
+ /** Exported for `test/ai-route.test.ts`. */
283
+ export declare function serveAi(ctx: LlmCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
284
+ export declare function apply(ctx: Context, config?: Config): void;
285
+ /**
286
+ * Record or clear one session's failing card, and wake the model when the failure is news.
287
+ *
288
+ * `POST ?session=<id>` with `{message, phase}` to set it and `{}` to clear it. The detail never
289
+ * comes back as a chat message — it becomes the `ui4a:card-failure` runtime context, which the
290
+ * assembly re-reads each step. See `card-failure.ts` for why the two halves are split.
291
+ *
292
+ * The wake is looked up per request rather than captured: the agent registry is scoped, so the
293
+ * function it hands out can go away while this route stays up, and a stale capture would call
294
+ * into a disposed fiber.
295
+ *
296
+ * Exported for `test/card-error-route.test.ts`.
297
+ */
298
+ export declare function serveCardError(failures: CardFailures, wake: () => ((session: string) => void) | null, req: IncomingMessage, res: ServerResponse): Promise<void>;
@@ -0,0 +1,13 @@
1
+ export declare const PROMPT_SECTION_NAME = "dsh-generative-ui:inline";
2
+ /** After tool guidance (100–199): this describes an output format, not the harness identity. */
3
+ export declare const PROMPT_SECTION_ORDER = 210;
4
+ /**
5
+ * The section, built for the capabilities this host actually exposes.
6
+ *
7
+ * `allowExec` is not cosmetic here. The closed-set sentence below ("these are the whole set") is
8
+ * load-bearing — it is what stops the model reasoning its way to a plausible sixth import — so it
9
+ * has to name the set that EXISTS. Documenting `$dsh/exec` on a host where the route is not
10
+ * registered teaches the model to write cards whose import fails, and a failed import takes the
11
+ * whole module down: the reader gets a blank card with nothing on screen naming the cause.
12
+ */
13
+ export declare const inlinePrompt: (allowExec?: boolean) => string;
@@ -0,0 +1,27 @@
1
+ export declare const SKILL_NAME = "generative-ui";
2
+ export declare const SKILL_DESCRIPTION = "How to decide between an inline ui4a/tsx block, a canvas file, and plain prose \u2014 and how to lay one out so it reads. Load it **before you decide**, not after \u2014 including when your first instinct is that prose is enough. Most of the questions that should have been an interface do not ask for one.";
3
+ /**
4
+ * The skill body.
5
+ *
6
+ * A function of the import-map path because that path is only known at runtime — the plugin
7
+ * lives wherever the profile installed it, and the model runs the checker from the workspace.
8
+ * Without the map, `check` reports `Cannot find module "$dsh/chat"` on every card that uses
9
+ * one, and a false error is worse than no check: the model goes and "fixes" it.
10
+ */
11
+ /**
12
+ * The paragraph about which import map serves which command.
13
+ *
14
+ * Built here rather than inline: nesting one template interpolation inside another inside the
15
+ * body is how this file broke twice, and the two maps have genuinely different lifetimes —
16
+ * the type one may exist while the stub one does not.
17
+ */
18
+ /** Exported for `test/skill.test.ts`: three states, and this file has broken on them twice. */
19
+ export declare function mapNotes(typesMap: string | undefined, standaloneMap: string | undefined): string;
20
+ /**
21
+ * The skill, for the capabilities this host exposes.
22
+ *
23
+ * With commands off the whole `## Running a command` section is cut rather than softened: it is
24
+ * ~90 lines that all assume `bash()` exists, and half a section describing a capability the host
25
+ * does not have is worse than none — the model reads the surviving half as permission.
26
+ */
27
+ export declare const skillBody: (typesMap: string | undefined, standaloneMap: string | undefined, allowExec?: boolean) => string;
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "dsh-generative-ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Generative UI for DeepSeek Harness — the agent writes TSX, dsh web renders it live, inline in chat and in a canvas panel",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "lib/index.js",
8
8
  "exports": {
9
9
  ".": {
10
+ "types": "./lib/types/index.d.ts",
10
11
  "default": "./lib/index.js"
11
12
  },
12
13
  "./client": {
14
+ "types": "./lib/types/client/index.d.ts",
13
15
  "default": "./lib/client.js"
14
16
  },
15
17
  "./package.json": "./package.json"
@@ -51,8 +53,8 @@
51
53
  }
52
54
  },
53
55
  "scripts": {
54
- "build": "bun scripts/gen-standalone.ts && bun scripts/build.ts",
55
- "prepare": "bun scripts/gen-standalone.ts && bun scripts/build.ts",
56
+ "build": "bun scripts/gen-standalone.ts && bun scripts/build.ts && tsc -p tsconfig.types.json",
57
+ "prepare": "bun scripts/gen-standalone.ts && bun scripts/build.ts && tsc -p tsconfig.types.json",
56
58
  "typecheck": "tsc --noEmit",
57
59
  "test": "bun test",
58
60
  "loads": "sh scripts/loads.sh",
@@ -92,6 +94,7 @@
92
94
  "react": "^18.2.0"
93
95
  },
94
96
  "devDependencies": {
97
+ "@babel/parser": "^8.0.4",
95
98
  "@deepseek-ai/cordis": "^4.0.1",
96
99
  "@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
97
100
  "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
@@ -107,10 +110,11 @@
107
110
  "@types/node": "^26.3.0",
108
111
  "@types/react": "~18.3.31",
109
112
  "@types/react-dom": "~18.3.7",
113
+ "@types/scheduler": "^0.26.0",
110
114
  "micromatch": "^4.0.8",
111
115
  "minimatch": "^10.2.6",
112
116
  "motion": "^13.1.1",
113
- "oxfmt": "^0.64.0",
117
+ "oxfmt": "^0.66.0",
114
118
  "oxlint": "^1.80.0",
115
119
  "picomatch": "^4.0.7",
116
120
  "pkg-pr-new": "^0.0.88",
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What the model is told about a card that will not render.
3
+ *
4
+ * Split in two, deliberately, because the two halves want opposite lifetimes:
5
+ *
6
+ * - **The detail is state.** It goes out as a runtime-context snapshot, re-evaluated on every
7
+ * assembly and superseded by the next one. A card that gets fixed simply stops being mentioned.
8
+ * As a chat message it was the opposite — permanent, and still there three turns after the card
9
+ * started working.
10
+ * - **The nudge is an event.** One short line through `followup`, whose only job is to open a turn
11
+ * so the model looks at the detail now rather than whenever the user next types.
12
+ *
13
+ * Neither is a user-role message any more, and that removes the paragraph the old body had to
14
+ * carry: *"This was sent by the renderer, not by the user — nobody typed it, so do not apologise
15
+ * or address it as a request."* That existed because the report arrived wearing the user's face.
16
+ * A `kind: "plugin"` source does not, so the model is told what happened and nothing else — the
17
+ * disclaimer cost more tokens than the error it was wrapped around.
18
+ */
19
+
20
+ /** A card failure as the browser half reported it. */
21
+ export type CardFailure = { readonly message: string; readonly phase: string };
22
+
23
+ /** The runtime-context section name. Stable: it is how a later snapshot supersedes an earlier one. */
24
+ export const CARD_FAILURE_CONTEXT = "ui4a:card-failure";
25
+
26
+ /**
27
+ * Ordered after the plugin's own guidance so the model reads how cards work before it reads that
28
+ * one is broken.
29
+ */
30
+ export const CARD_FAILURE_CONTEXT_ORDER = 250;
31
+
32
+ /**
33
+ * The detail, as the model sees it.
34
+ *
35
+ * It says it is current state rather than an event on purpose: the same text is re-delivered on
36
+ * every step while the card stays broken, and a model that reads it as a fresh report tries to
37
+ * fix the card again on each one.
38
+ */
39
+ export const failureText = (failure: CardFailure) =>
40
+ `A ui4a card in this session is not rendering. It failed at the ${failure.phase} step:\n\n${failure.message}\n\nThis is current state, not a new event — it is re-read every step and disappears once the card renders. If the error names the correct usage (the available exports, for instance), fix the card and send it again. If it does not, look it up before you change anything.`;
41
+
42
+ /**
43
+ * The one line that opens a turn. The detail is already in context; this only asks for attention.
44
+ *
45
+ * The second sentence is not padding — it closes a race the split design makes possible. The nudge
46
+ * is an event and cannot be recalled once queued; the detail is state and is re-evaluated when the
47
+ * turn assembles. A card that recovers in between (a retry succeeding is the common way) clears
48
+ * the state, so the turn opens with "go read the runtime context" pointing at a context that says
49
+ * nothing about a card.
50
+ *
51
+ * Seen in a real session: two notices, and the SECOND one had no card-failure section anywhere in
52
+ * the turn that followed it. Without a defined outcome for that, the model has been told something
53
+ * is broken, cannot find it, and goes looking — in that session it rewrote a card that was fine.
54
+ */
55
+ export const WAKE_TEXT =
56
+ "A ui4a card you wrote is not rendering — the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
57
+
58
+ /** Shown as the context row's label in the transcript, so a reader can see what fired without opening it. */
59
+ export const WAKE_SUMMARY = "ui4a card failed to render";
60
+
61
+ /**
62
+ * The current failing card per session.
63
+ *
64
+ * Per session rather than per card: the nudge exists to get one turn spent on the problem, and a
65
+ * reply that breaks three cards does not want three turns. The newest failure wins because it is
66
+ * the one the model just wrote.
67
+ */
68
+ export class CardFailures {
69
+ private readonly bySession = new Map<string, CardFailure>();
70
+
71
+ /**
72
+ * Record a failure.
73
+ *
74
+ * @returns whether the session went from HEALTHY to failing — the caller wakes the model only
75
+ * then. Dedup lives here rather than in the browser half because that half is reloaded by every
76
+ * navigation, and a dedup set that resets on reload wakes the model again for a card it already
77
+ * knows about.
78
+ *
79
+ * **Not "is this a different message".** That was the first version, and it is wrong for a
80
+ * nudge whose entire payload is *go read the context*: two different failures produce two
81
+ * BYTE-IDENTICAL notices, so a reader watching a long turn sees the same sentence queued twice
82
+ * and the model gets two turns pointed at one current-state snapshot. Measured on a real
83
+ * session — three genuinely different failures (`@react-three/fiber` unresolvable, `maps is not
84
+ * defined`, `@react-three/cannon` unresolvable) over eighty minutes, each "news" by the old
85
+ * test, and the third's notice was still sitting unsent in the composer beside the second.
86
+ *
87
+ * While a failure is already recorded the model is either about to look or has looked, and the
88
+ * detail it will read is re-evaluated every step anyway — so a changed message needs no second
89
+ * nudge. `clear()` on recovery is what re-arms this.
90
+ */
91
+ set(session: string, failure: CardFailure): boolean {
92
+ const wasHealthy = !this.bySession.has(session);
93
+ this.bySession.set(session, failure);
94
+ return wasHealthy;
95
+ }
96
+
97
+ clear(session: string): void {
98
+ this.bySession.delete(session);
99
+ }
100
+
101
+ /** The context text for one session; empty contributes nothing to the assembly. */
102
+ text(session: string | undefined): string {
103
+ const failure = session === undefined ? undefined : this.bySession.get(session);
104
+ return failure === undefined ? "" : failureText(failure);
105
+ }
106
+ }
@@ -28,7 +28,7 @@ export type CanvasPanelProps = {
28
28
  /** A canvas that failed to compile; see `runtime/report-error.ts`. */
29
29
  onCardError?: (message: string, phase: string) => void;
30
30
  /** A canvas that painted; see `runtime/report-error.ts`. */
31
- onCardRendered?: () => void;
31
+ onCardRendered?: (restored: boolean) => void;
32
32
  };
33
33
 
34
34
  /**
@@ -228,9 +228,15 @@ export function CanvasPanel({ canvases, offerable, cwd, onOpen, onClose, onWidth
228
228
  {active === undefined ? (
229
229
  <div className="dgu-empty">写入 ui4a/canvases/&lt;id&gt;.ui4a.tsx 后,画布会出现在这里</div>
230
230
  ) : (
231
- // A canvas arrives as whole files, so recompiles replace rather than extend —
232
- // preserving state would make an edited canvas silently keep the old render.
233
- <GenUISurface key={active.id} code={resolved} streaming={active.streaming} preserveState={false} onError={(error, phase) => onCardError?.(error.message, phase)} onRendered={onCardRendered} />
231
+ // `preserveState` was false here, on the theory that a whole-file replace could
232
+ // "silently keep the old render". It cannot: both of partial-react's reuse branches
233
+ // (`runtime.ts:317` and `:404`) require `updateMode === "push"`, and a canvas arrives
234
+ // whole, so it always runs in `"render"` — measured, the edit lands either way.
235
+ // What preserving buys is the state: `:426` calls the generated component from inside
236
+ // a stable slot wrapper, so its hooks stay on the same fiber across a recompile, and
237
+ // `:424` keys the error boundary off the hook signature instead of the render round.
238
+ // Without it every edit remounted the tree and a running timer went back to zero.
239
+ <GenUISurface key={active.id} code={resolved} streaming={active.streaming} onError={(error, phase) => onCardError?.(error.message, phase)} onRendered={onCardRendered} />
234
240
  )}
235
241
  </div>
236
242
  </div>
@@ -25,7 +25,7 @@ export type CanvasHostOptions = {
25
25
  */
26
26
  onCardError?: (message: string, phase: string) => void;
27
27
  /** A canvas that painted; cancels a deferred error report the next frame made untrue. */
28
- onCardRendered?: () => void;
28
+ onCardRendered?: (restored: boolean) => void;
29
29
  };
30
30
 
31
31
  const EMPTY: ReadonlySet<string> = new Set();
@@ -8,7 +8,7 @@ import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
8
8
  import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
9
9
  import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
10
10
  import { GenUISurface } from "./runtime/GenUISurface.tsx";
11
- import { cardRendered, reportCardError } from "./runtime/report-error.ts";
11
+ import { cancelPendingReport, cardRendered, reportCardError } from "./runtime/report-error.ts";
12
12
  import { disposeCompiler } from "./runtime/compiler.ts";
13
13
  import { dropSharedCompiler } from "./runtime/GenUISurface.tsx";
14
14
  import { disposeRegistry } from "./runtime/registry.ts";
@@ -20,6 +20,7 @@ import { chatNodes, perNode, type ChatNodeView } from "./session.ts";
20
20
  import { mountCanvasHost } from "./canvas/index.ts";
21
21
  import { toolCallsOf, type CallBlock, type ToolCallView } from "./canvas/collect.ts";
22
22
  import { canvasIdOf } from "../contract.ts";
23
+ import { CARD_ERROR_PATH } from "../contract-assets.ts";
23
24
 
24
25
  export const inject = ["sessions"];
25
26
 
@@ -84,6 +85,11 @@ export function apply(ctx: ClientContext): void {
84
85
  // its URL is revoked (the module graph holds it), so this only reclaims URLs nothing can
85
86
  // reach any more. Without it every HMR round leaks one per registered specifier.
86
87
  ctx.effect(() => disposeRegistry, "dsh-generative-ui: blob module URLs");
88
+ // An error report waits a second before it is sent (see `SETTLE_MS`), and an unload inside that
89
+ // second leaves the timer holding a closure over a conversation that is being torn down. There
90
+ // is nothing to flush — a report nobody will read is not worth delivering — so cancelling is
91
+ // the whole disposer, and `cardRendered` already is one.
92
+ ctx.effect(() => () => cancelPendingReport(), "dsh-generative-ui: pending error report");
87
93
  // The wasm half of the same problem: ~16MB per instance, one per HMR round, and upstream
88
94
  // offers no dispose — dropping the reference is all there is (see `disposeCompiler`).
89
95
  ctx.effect(
@@ -129,14 +135,19 @@ export function apply(ctx: ClientContext): void {
129
135
  });
130
136
  // A card that fails to compile used to be a red panel the reader saw and the model never did.
131
137
  // `onError` fires only for a failure that survived settling and retries, so this is the real
132
- // ones — see `report-error.ts` for why it is once per message and why it says it is automatic.
133
- const sendToModel = (text: string) => {
138
+ // ones — see `report-error.ts` for why it is once per message and why it waits a beat.
139
+ //
140
+ // A route rather than `conversation.send`: the detail belongs in the model's CONTEXT, which is
141
+ // assembled host-side, and a chat message could never be taken back once the card was fixed.
142
+ // `card-failure.ts` has the rest.
143
+ const sendToModel = (report: { message: string; phase: string } | null) => {
134
144
  const id = currentSession();
135
- const session = id === undefined ? undefined : ctx.sessions.scope(id);
136
- if (session === undefined) return;
137
- session.inject(["conversation"], (addressed) => {
138
- void addressed.conversation.send(text).catch((error: unknown) => console.error("[dsh-generative-ui] card error report failed", error));
139
- });
145
+ if (id === undefined) return;
146
+ void fetch(`${CARD_ERROR_PATH}?session=${encodeURIComponent(id)}`, {
147
+ method: "POST",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify(report ?? {}),
150
+ }).catch((error: unknown) => console.error("[dsh-generative-ui] card error report failed", error));
140
151
  };
141
152
 
142
153
  // Mounted inside the effect, not beside it: `mountCanvasHost` reaches for MutationObserver
@@ -180,7 +191,9 @@ export function apply(ctx: ClientContext): void {
180
191
  () =>
181
192
  claimInlineFences({
182
193
  segments,
183
- render: ({ code, streaming }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase), onRendered: cardRendered }),
194
+ // The SAME gate on both callbacks. A card that may not report a failure may not retract
195
+ // one either — see `cardRendered`.
196
+ render: ({ code, streaming, last }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase, last), onRendered: (restored) => cardRendered(restored, last) }),
184
197
  }),
185
198
  "dsh-generative-ui: inline fences",
186
199
  );