dsh-generative-ui 0.0.2 → 0.0.4

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.
@@ -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();
@@ -7,8 +7,9 @@ import { createElement } from "react";
7
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
+ import type {} from "@deepseek-ai/dsh-client-locale/client";
10
11
  import { GenUISurface } from "./runtime/GenUISurface.tsx";
11
- import { cardRendered, reportCardError } from "./runtime/report-error.ts";
12
+ import { cancelPendingReport, cardRendered, reportCardError } from "./runtime/report-error.ts";
12
13
  import { disposeCompiler } from "./runtime/compiler.ts";
13
14
  import { dropSharedCompiler } from "./runtime/GenUISurface.tsx";
14
15
  import { disposeRegistry } from "./runtime/registry.ts";
@@ -20,8 +21,9 @@ import { chatNodes, perNode, type ChatNodeView } from "./session.ts";
20
21
  import { mountCanvasHost } from "./canvas/index.ts";
21
22
  import { toolCallsOf, type CallBlock, type ToolCallView } from "./canvas/collect.ts";
22
23
  import { canvasIdOf } from "../contract.ts";
24
+ import { CARD_ERROR_PATH } from "../contract-assets.ts";
23
25
 
24
- export const inject = ["sessions"];
26
+ export const inject = ["sessions", "locale"];
25
27
 
26
28
  /** Re-exported so `bun run smoke` can build the synthesized blob modules and parse them. */
27
29
  export { localImports };
@@ -84,6 +86,11 @@ export function apply(ctx: ClientContext): void {
84
86
  // its URL is revoked (the module graph holds it), so this only reclaims URLs nothing can
85
87
  // reach any more. Without it every HMR round leaks one per registered specifier.
86
88
  ctx.effect(() => disposeRegistry, "dsh-generative-ui: blob module URLs");
89
+ // An error report waits a second before it is sent (see `SETTLE_MS`), and an unload inside that
90
+ // second leaves the timer holding a closure over a conversation that is being torn down. There
91
+ // is nothing to flush — a report nobody will read is not worth delivering — so cancelling is
92
+ // the whole disposer, and `cardRendered` already is one.
93
+ ctx.effect(() => () => cancelPendingReport(), "dsh-generative-ui: pending error report");
87
94
  // The wasm half of the same problem: ~16MB per instance, one per HMR round, and upstream
88
95
  // offers no dispose — dropping the reference is all there is (see `disposeCompiler`).
89
96
  ctx.effect(
@@ -129,14 +136,19 @@ export function apply(ctx: ClientContext): void {
129
136
  });
130
137
  // A card that fails to compile used to be a red panel the reader saw and the model never did.
131
138
  // `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) => {
139
+ // ones — see `report-error.ts` for why it is once per message and why it waits a beat.
140
+ //
141
+ // A route rather than `conversation.send`: the detail belongs in the model's CONTEXT, which is
142
+ // assembled host-side, and a chat message could never be taken back once the card was fixed.
143
+ // `card-failure.ts` has the rest.
144
+ const sendToModel = (report: { message: string; phase: string } | null) => {
134
145
  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
- });
146
+ if (id === undefined) return;
147
+ void fetch(`${CARD_ERROR_PATH}?session=${encodeURIComponent(id)}`, {
148
+ method: "POST",
149
+ headers: { "content-type": "application/json" },
150
+ body: JSON.stringify(report ?? {}),
151
+ }).catch((error: unknown) => console.error("[dsh-generative-ui] card error report failed", error));
140
152
  };
141
153
 
142
154
  // Mounted inside the effect, not beside it: `mountCanvasHost` reaches for MutationObserver
@@ -180,7 +192,11 @@ export function apply(ctx: ClientContext): void {
180
192
  () =>
181
193
  claimInlineFences({
182
194
  segments,
183
- render: ({ code, streaming }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase), onRendered: cardRendered }),
195
+ t: ctx.locale.bind("common"),
196
+ subscribeLocale: (refresh) => ctx.locale.subscribe(refresh),
197
+ // The SAME gate on both callbacks. A card that may not report a failure may not retract
198
+ // one either — see `cardRendered`.
199
+ render: ({ code, streaming, last }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase, last), onRendered: (restored) => cardRendered(restored, last) }),
184
200
  }),
185
201
  "dsh-generative-ui: inline fences",
186
202
  );
@@ -16,16 +16,18 @@ export type GenUISurfaceProps = {
16
16
  /** True while `code` is still a prefix, so partial frames get normalized before compiling. */
17
17
  streaming?: boolean;
18
18
  /**
19
- * Keep React state across recompiles. Right for a growing stream, where each frame is
20
- * the previous one plus more text. Wrong for a whole-file replacement: the renderer
21
- * decides reuse from the hook signature, so a rewrite that keeps the same hooks — an
22
- * edited canvas usually does — is silently dropped rather than rendered.
19
+ * Keep React state across recompiles, by rendering through partial-react's stable slot
20
+ * wrapper so hooks stay on the same fiber. Off, every recompile renders a freshly
21
+ * compiled function type, which React remounts a running timer restarts at zero.
22
+ *
23
+ * It does NOT risk dropping an edit: the renderer's two reuse branches only fire in
24
+ * `push` mode, so a whole-file replacement always renders.
23
25
  */
24
26
  preserveState?: boolean;
25
- /** Real compile diagnostics. Transient streaming frames are filtered out — see TRANSIENT below. */
27
+ /** Real compile diagnostics. A streaming frame never reaches this — see `errorAction`. */
26
28
  onError?: (error: Error, phase: "transform" | "compile" | "render") => void;
27
29
  /** Fires whenever a frame actually painted. Use it to clear a previously shown error. */
28
- onRendered?: () => void;
30
+ onRendered?: (restored: boolean) => void;
29
31
  className?: string;
30
32
  };
31
33
 
@@ -58,15 +60,6 @@ export const compiler = () => {
58
60
  return sharedCompiler;
59
61
  };
60
62
 
61
- /**
62
- * Mid-stream frames legitimately fail: a prefix that has not reached `export default`
63
- * yet, or a half-written expression. partial-react treats these as transient and keeps
64
- * the last good frame, so surfacing them would just make the UI flash errors while the
65
- * model types. Only a failure that survives settling is the caller's business.
66
- */
67
- /** Exported for `test/transient.test.ts`: this decides whether the reader sees an error. */
68
- export const TRANSIENT = /No default export found|Unexpected (end of|eof)/i;
69
-
70
63
  /**
71
64
  * A dependency that failed to arrive, not code that is wrong. esm.sh cold-starts and the
72
65
  * network drops, and the symptom is identical to a broken component — a blank surface — so
@@ -83,16 +76,6 @@ const MAX_RETRIES = 3;
83
76
  * each of which sends the reader somewhere different when it is wrong. Exported for
84
77
  * `test/retry.test.ts`.
85
78
  */
86
- /**
87
- * Whether a mid-stream error is the stream not being finished yet.
88
- *
89
- * Both patterns come from the parse stages — `No default export found` is thrown inside
90
- * `importCompiledComponent` (compile), and an unexpected EOF is the transform rejecting a
91
- * prefix. A card whose own render throws a message that happens to match is a real error, so
92
- * the phase is part of the question rather than the message alone.
93
- */
94
- export const isUnfinishedFrame = (message: string, phase: string, streaming: boolean) => streaming && phase !== "render" && TRANSIENT.test(message);
95
-
96
79
  export const shouldRetry = (message: string, phase: string, streaming: boolean, attempts: number) => phase === "compile" && !streaming && TRANSIENT_LOAD.test(message) && attempts < MAX_RETRIES;
97
80
  /**
98
81
  * What to do with a frame, given what the surface already holds.
@@ -152,6 +135,21 @@ export const deliver = (renderer: RendererCalls, delivery: Delivery): boolean =>
152
135
  */
153
136
  export const importSignature = (code: string) => [...code.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]).join(" ");
154
137
 
138
+ /**
139
+ * Whether a failure suppressed during streaming still has to be told to someone.
140
+ *
141
+ * The hole this closes: `errorAction` ignores every streaming frame (a truncated one is not a
142
+ * broken card), and `deliveryFor` answers `nothing` when the settled frame is byte-identical to
143
+ * the last streamed one. Both are right on their own, and together they mean a card that really
144
+ * is broken recompiles never and reports never.
145
+ *
146
+ * Pure, and exported, because it is three conditions and each one is a distinct bug when wrong:
147
+ * without `!streaming` it fires mid-stream and undoes the fix it belongs to; without `stranded`
148
+ * it reports nothing; without the `reportedFor` guard a settled card re-rendered by every later
149
+ * frame of the transcript reports on each one.
150
+ */
151
+ export const reportStranded = (streaming: boolean, stranded: unknown, code: string, reportedFor: string) => !streaming && stranded !== null && reportedFor !== code;
152
+
155
153
  /** Only what `deliver` touches — the real renderer has far more. */
156
154
  export type RendererCalls = {
157
155
  render: (code: string) => void;
@@ -166,20 +164,34 @@ export type RendererCalls = {
166
164
  * - `retry` a dependency failed to arrive, and busting the import URLs is the fix
167
165
  * - `report` tell the reader
168
166
  *
169
- * Only a SETTLED surface retries: while streaming, the next frame re-delivers on its own, and a
170
- * retry there would replace the growing buffer with a stale prefix. Compile phase only — a failed
171
- * dependency import is reported there (`importCompiledComponent` runs inside the compile `catch`,
172
- * `partial-react/src/runtime.ts:338`), whereas the same message from the RENDER phase is the
173
- * card's own `fetch` throwing inside its body, where re-importing changes nothing and costs three
174
- * retries and 2.4 seconds of blank surface before the reader is told anything.
167
+ * **A streaming frame is never reported, whatever it says.** This used to test the message
168
+ * against `TRANSIENT` (`No default export found`, unexpected EOF) and report anything else, on
169
+ * the theory that a truncated frame fails to parse. It does not have to: a cut that lands
170
+ * mid-identifier leaves valid syntax and throws at module evaluation instead. Measured on one
171
+ * real session five consecutive reports, five regenerations, and every final card was fine:
172
+ * `Mouse is not defined` from a card whose only such name is `MousePointer2`, `type is not
173
+ * defined` from `type ToolGroup =`, and `icon is not defined` from a card containing no `icon` at
174
+ * all. The frame, not the card, was broken. There is no message that distinguishes the two, so
175
+ * the phase does it: only a settled surface has anything worth saying about.
175
176
  */
176
177
  export const errorAction = (message: string, phase: string, streaming: boolean, attempts: number): "ignore" | "retry" | "report" => {
177
- if (isUnfinishedFrame(message, phase, streaming)) return "ignore";
178
+ if (streaming) return "ignore";
178
179
  return shouldRetry(message, phase, streaming, attempts) ? "retry" : "report";
179
180
  };
180
181
 
181
- /** 0.4s / 0.8s / 1.2s covers an esm.sh cold start; past that the package itself is the problem. */
182
- const RETRY_BACKOFF_MS = 400;
182
+ /**
183
+ * 0.5s / 2s / 8s. **Linear 0.4/0.8/1.2 did not cover a cold start** — it spends 2.4 seconds
184
+ * total, and a first request for a package esm.sh has never built waits on that build: measured
185
+ * 2.27s cold against 0.50s warm for the same URL, so all three attempts landed inside one
186
+ * unfinished build and the reader got `failed to fetch dynamically imported module` on a package
187
+ * that resolves fine a second later. Seen in a real session on `@headlessui/react`, twice in a
188
+ * row, on a card the model had been asked to write with it.
189
+ *
190
+ * Backing off ×4 spends 10.5s across the same three attempts, which covers a cold build with room
191
+ * and still gives up fast enough that a genuinely missing package does not hang the card.
192
+ */
193
+ const RETRY_BACKOFF_MS = 500;
194
+ const RETRY_FACTOR = 4;
183
195
 
184
196
  /**
185
197
  * What `onError` DOES with the three outcomes, separated from where they come from. The decision
@@ -196,17 +208,29 @@ const RETRY_BACKOFF_MS = 400;
196
208
  *
197
209
  * - **stale** — a later frame's probe won the race. Applying this one reverts the map to an
198
210
  * older import set, and the newer frame's packages go missing.
199
- * - **redeliver** — a settled surface has no next frame. `setImportMap` only stores; it
200
- * schedules nothing, so without a re-render the card stays blank for good.
201
- * - **store** while streaming, the very next frame applies the map. Re-delivering here instead
202
- * would replace the buffer with whatever prefix was current when the probe fired, truncating
203
- * the stream mid-flight.
211
+ * - **redeliver** — nothing else is going to apply the map. Always true of a settled surface,
212
+ * and true while streaming too when no frame has been delivered since the probe fired:
213
+ * `setImportMap` only stores, so that buffer stays compiled against a map without these
214
+ * entries and an unresolvable bare specifier kills the whole module graph, so the card is
215
+ * blank rather than wrong. Measured 2026-08-27 on a card importing `@radix-ui/react-tabs`:
216
+ * one delivery with `streaming: true` renders 0 characters **for good**, while the identical
217
+ * code with `streaming: false` renders. The error is swallowed on top of it — `errorAction`
218
+ * answers `ignore` while streaming, so the reader gets an empty card and the model gets no
219
+ * report. A cold probe is 1.0s and the import behind it another 2.4s, so the window this
220
+ * covers is seconds wide, not a frame.
221
+ * - **store** — a newer frame has already been delivered, and it will apply the map itself.
222
+ * Re-delivering here instead would replace the buffer with the prefix that was current when
223
+ * the probe fired, truncating the stream mid-flight.
224
+ *
225
+ * `redeliver` renders `deliveredRef.current`, which is read at SETTLE time — the newest buffer,
226
+ * not the captured one — so the append the next frame computes still lines up.
204
227
  *
205
228
  * The `delivered !== ""` part is not defensive: re-rendering an empty buffer clears the surface.
206
229
  */
207
- export const probeOutcome = (signature: string, current: string, streaming: boolean, delivered: string): "stale" | "redeliver" | "store" => {
230
+ export const probeOutcome = (signature: string, current: string, streaming: boolean, delivered: string, probed: string): "stale" | "redeliver" | "store" => {
208
231
  if (signature !== current) return "stale";
209
- return !streaming && delivered !== "" ? "redeliver" : "store";
232
+ if (delivered === "") return "store";
233
+ return !streaming || delivered === probed ? "redeliver" : "store";
210
234
  };
211
235
 
212
236
  export const dispatchError = (action: "ignore" | "retry" | "report", effects: { attempts: () => number; setAttempts: (n: number) => void; schedule: (ms: number) => void; report: () => void }) => {
@@ -214,7 +238,7 @@ export const dispatchError = (action: "ignore" | "retry" | "report", effects: {
214
238
  if (action === "retry") {
215
239
  const next = effects.attempts() + 1;
216
240
  effects.setAttempts(next);
217
- effects.schedule(RETRY_BACKOFF_MS * next);
241
+ effects.schedule(RETRY_BACKOFF_MS * RETRY_FACTOR ** (next - 1));
218
242
  return;
219
243
  }
220
244
  effects.report();
@@ -227,6 +251,56 @@ export const dispatchError = (action: "ignore" | "retry" | "report", effects: {
227
251
  * fresh, and appending a query to a `blob:` URL makes it unresolvable — which would break every
228
252
  * card rather than fixing one.
229
253
  */
254
+ /**
255
+ * The same import map with `bundle`/`external` dropped from every esm.sh entry.
256
+ *
257
+ * esm.sh serves two different builds and only one of them can fail: `?bundle` runs esbuild over
258
+ * the package's whole tree, and a version skew anywhere in it is a hard 500. Measured on
259
+ * `mermaid`, three attempts, deterministic — `?bundle&target=es2022&external=react,react-dom,scheduler`
260
+ * answers **500 `esbuild: No matching export in "node_modules/d3/src/index.js" for import
261
+ * "curveBumpX"`**, while the plain `https://esm.sh/mermaid?target=es2022` answers 200 and the
262
+ * module imports fine. The card meanwhile renders **completely blank with nothing in the
263
+ * console**, because an unresolvable import kills the whole module graph.
264
+ *
265
+ * So the retry that only busts the query re-requests the same broken build. Unbundling is a
266
+ * genuinely different artefact on esm.sh's side, which is what makes it a second chance rather
267
+ * than a second identical failure. It is not the first choice — the bundled build is one request
268
+ * instead of a waterfall — which is why this is on the retry path and not on the happy one.
269
+ */
270
+ export const unbundleFetchedImports = (imports: Record<string, string>): Record<string, string> =>
271
+ Object.fromEntries(
272
+ Object.entries(imports).map(([key, url]) => {
273
+ if (!url.startsWith("https://esm.sh/")) return [key, url];
274
+ const parsed = new URL(url);
275
+ parsed.searchParams.delete("bundle");
276
+ // `external` STAYS. Dropping it was in this function for one day and it broke rendering in a
277
+ // way far worse than the 500 it was meant to route around. Measured on the real URLs:
278
+ //
279
+ // ?bundle&target=es2022&external=react,react-dom,scheduler -> 500 (the bug being fixed)
280
+ // ?target=es2022&external=react,react-dom,scheduler -> 200 (fixed, react shared)
281
+ // ?target=es2022 -> 200 (fixed, react NOT shared)
282
+ //
283
+ // The 500 comes from `bundle` alone; `external` has nothing to do with it. What `external`
284
+ // does do is make esm.sh emit a BARE `import … from "react"`, which the document import map
285
+ // resolves to the host's single instance. Without it the same build emits
286
+ // `import "/react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0?target=es2022"` — an ABSOLUTE URL
287
+ // an import map keyed on the bare specifier cannot redirect, so the package gets esm.sh's own
288
+ // React (19, against this host's 18). Elements it creates then carry a `$$typeof` the host
289
+ // React does not recognise, and the card dies on **`Minified React error #31`, args
290
+ // `object with keys {$$typeof, type, key, ref, props}`**.
291
+ //
292
+ // Seen end to end in one session: a card rendered correctly, a later retry re-fetched its
293
+ // icons without `external`, every lucide element became a foreign object, and the model was
294
+ // handed a render-failure notice for a card that had been fine — it rewrote it, blaming
295
+ // itself for "rendering icons/objects directly".
296
+ //
297
+ // `bundle` is a valueless flag, and `URLSearchParams` writes it back as `bundle=` — which
298
+ // esm.sh reads as the flag being present. Deleting is enough; re-serialising is what would
299
+ // put it back.
300
+ return [key, parsed.toString()];
301
+ }),
302
+ );
303
+
230
304
  export const bustFetchedImports = (imports: Record<string, string>, attempt: number): Record<string, string> => Object.fromEntries(Object.entries(imports).map(([key, url]) => [key, url.startsWith("https://esm.sh/") ? `${url}${url.includes("?") ? "&" : "?"}ui4a-retry=${attempt}` : url]));
231
305
 
232
306
  export function GenUISurface({ code, streaming = false, preserveState = true, onError, onRendered, className }: GenUISurfaceProps) {
@@ -238,6 +312,22 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
238
312
  // through refs rather than captured — a new handler identity must not re-attach it.
239
313
  const onErrorRef = useLatest(onError);
240
314
  const onRenderedRef = useLatest(onRendered);
315
+ // Set by a RENDER throw, consumed by the paint that follows it — see `onRendered` below.
316
+ const threwRef = useRef(false);
317
+ /**
318
+ * The last failure `errorAction` suppressed because the surface was still streaming.
319
+ *
320
+ * Suppressing them is right — a truncated frame is not a broken card — but it leaves a hole at
321
+ * the other end: when the settled frame is byte-identical to the last streamed one,
322
+ * `deliveryFor` answers `nothing`, nothing recompiles, and a card that really is broken reports
323
+ * NOTHING at all. The last streamed frame's failure is the surface's actual state at that
324
+ * point, so it is what gets reported. Cleared by a real paint, which is the proof it was
325
+ * transient after all.
326
+ */
327
+ const strandedRef = useRef<{ error: Error; phase: "transform" | "compile" | "render" } | null>(null);
328
+ /** The code a stranded failure was already reported for, so a re-render does not report it again. */
329
+ const strandedReportedRef = useRef("");
330
+ const retryTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
241
331
  const streamingRef = useLatest(streaming);
242
332
  // Read once at attach: the renderer takes it as a construction option.
243
333
  const preserveStateRef = useRef(preserveState);
@@ -259,7 +349,10 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
259
349
  const attempt = retriesRef.current;
260
350
  void mergeFallbackImports(localImports(), code).then((imports) => {
261
351
  // Only the fetched esm.sh entries need busting; the local blob URLs are already fresh.
262
- renderer.setImportMap({ imports: bustFetchedImports(imports, attempt) });
352
+ // From the second attempt the bundled build is dropped as well: if the first failure was a
353
+ // bundle-side 500 (see `unbundleFetchedImports`) no amount of cache-busting can clear it.
354
+ const fresh = attempt > 0 ? unbundleFetchedImports(imports) : imports;
355
+ renderer.setImportMap({ imports: bustFetchedImports(fresh, attempt) });
263
356
  renderer.clear({ preserveVisualState: true });
264
357
  renderer.render(code);
265
358
  });
@@ -276,18 +369,36 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
276
369
  preserveStateOnUpdate: preserveStateRef.current,
277
370
  callbacks: {
278
371
  onError: (error, phase) => {
279
- dispatchError(errorAction(error.message, phase, streamingRef.current, retriesRef.current), {
372
+ if (phase === "render") threwRef.current = true;
373
+ const action = errorAction(error.message, phase, streamingRef.current, retriesRef.current);
374
+ // Remember what was swallowed. Only the newest matters: each frame supersedes the last,
375
+ // so this is the state of the buffer the stream stopped on.
376
+ if (action === "ignore") strandedRef.current = { error, phase };
377
+ dispatchError(action, {
280
378
  attempts: () => retriesRef.current,
281
379
  setAttempts: (n) => {
282
380
  retriesRef.current = n;
283
381
  },
284
- schedule: (ms) => setTimeout(() => retryRef.current(), ms),
382
+ // Tracked so the cleanup below can cancel it: a retry is scheduled up to 1.2s out,
383
+ // and a surface can unmount well inside that (a canvas tab closed, a message scrolled
384
+ // out of the host's window). Firing after that runs the whole import-probe chain
385
+ // against a renderer that has already been detached.
386
+ schedule: (ms) => void retryTimers.current.add(setTimeout(() => retryRef.current(), ms)),
285
387
  report: () => onErrorRef.current?.(error, phase),
286
388
  });
287
389
  },
288
390
  onRendered: () => {
289
391
  retriesRef.current = 0;
290
- onRenderedRef.current?.();
392
+ // partial-react repaints the LAST GOOD component after a render throw (`preserve` is on
393
+ // for every inline card), so this fires for a card whose new code is broken. The throw
394
+ // set `threwRef` a tick earlier; consuming it here is what tells the caller that this
395
+ // paint is a restore, not the new code working.
396
+ const restored = threwRef.current;
397
+ threwRef.current = false;
398
+ // A restore paints the LAST GOOD component, which says nothing about the current code —
399
+ // clearing on it would lose exactly the failure this exists to carry.
400
+ if (!restored) strandedRef.current = null;
401
+ onRenderedRef.current?.(restored);
291
402
  },
292
403
  },
293
404
  }).then((created) => {
@@ -304,6 +415,8 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
304
415
  attached?.detach();
305
416
  attached = null;
306
417
  setRenderer(null);
418
+ for (const timer of retryTimers.current) clearTimeout(timer);
419
+ retryTimers.current.clear();
307
420
  };
308
421
  // Attach exactly once. The refs above are how later prop values reach the renderer
309
422
  // without tearing it down, so they deliberately do not belong in these deps.
@@ -328,7 +441,7 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
328
441
  importedRef.current = signature;
329
442
  // A later frame's probe can settle first; without this the map reverts to an older import set.
330
443
  void mergeFallbackImports(localImports(), code).then((imports) => {
331
- const settle = probeOutcome(signature, importedRef.current, streamingRef.current, deliveredRef.current);
444
+ const settle = probeOutcome(signature, importedRef.current, streamingRef.current, deliveredRef.current, code);
332
445
  if (settle === "stale") return;
333
446
  renderer.setImportMap({ imports });
334
447
  if (settle === "redeliver") renderer.render(deliveredRef.current);
@@ -340,7 +453,18 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
340
453
  // whatever is already mounted, so a card paints unstyled for at most a frame instead of
341
454
  // holding up every delivery behind a generator that has to boot on the first call.
342
455
  void ensureUnoStyles(code, streaming);
343
- if (!deliver(renderer, deliveryFor(code, deliveredRef.current, streaming))) return;
456
+ if (!deliver(renderer, deliveryFor(code, deliveredRef.current, streaming))) {
457
+ // Nothing was delivered, so nothing will recompile and no error will be raised — but the
458
+ // buffer on screen may already have failed while streaming. This is the only moment that
459
+ // failure can still reach anyone. Keyed on the code so a settled card re-rendered by every
460
+ // later frame of the transcript reports once, not once per frame.
461
+ const stranded = strandedRef.current;
462
+ if (stranded !== null && reportStranded(streaming, stranded, code, strandedReportedRef.current)) {
463
+ strandedReportedRef.current = code;
464
+ onErrorRef.current?.(stranded.error, stranded.phase);
465
+ }
466
+ return;
467
+ }
344
468
  // `deliveredRef` must follow every delivery, or a later streaming frame diffs against a
345
469
  // prefix this render already superseded.
346
470
  deliveredRef.current = code;
@@ -183,8 +183,9 @@ async function request<T>(method: "GET" | "POST", path: string, content?: string
183
183
  const response = await fetch(`${FS_PATH}?${query}`, content === undefined ? { method } : { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ content }) });
184
184
  const body = (await response.json().catch(() => ({}))) as { error?: string };
185
185
  // A denial is not an outage. Naming it lets the card say "this session is read-only"
186
- // rather than "something went wrong".
187
- if (!response.ok) throw new Error(`[dsh-generative-ui] $dsh/fs ${path}: ${body.error ?? response.statusText}`);
186
+ // rather than "something went wrong" — and `denied` carries that as a field, because the
187
+ // alternative is every card string-matching our message, which then cannot be reworded.
188
+ if (!response.ok) throw Object.assign(new Error(`[dsh-generative-ui] $dsh/fs ${path}: ${body.error ?? response.statusText}`), { code: body.error, denied: response.status === 403 });
188
189
  return body as T;
189
190
  }
190
191
 
@@ -49,6 +49,17 @@ export function disposeCompiler(): void {
49
49
  initPromise = null;
50
50
  }
51
51
 
52
+ /**
53
+ * Does this source still export a component to mount?
54
+ *
55
+ * Deliberately a source test rather than a compile test: the failure being caught is a module that
56
+ * compiles perfectly and has nothing in it. Matches the two spellings the renderer accepts, and is
57
+ * written to ignore both comment forms so a `// export default` in prose cannot fake it.
58
+ */
59
+ const hasDefaultExport = (source: string) =>
60
+ /^\s*export\s+default\s/m.test(source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "")) ||
61
+ /\bexport\s*\{[^}]*\bas\s+default\b/.test(source);
62
+
52
63
  export function createBrowserTsxCompiler(): TsxCompiler {
53
64
  return {
54
65
  async compile(code, options = {}) {
@@ -68,12 +79,23 @@ export function createBrowserTsxCompiler(): TsxCompiler {
68
79
  if (options.partial === true) return build(normalizeGeneratedTsx(code, { mode: "streaming" }));
69
80
  try {
70
81
  return build(normalizeGeneratedTsx(code, { mode: "final" }));
71
- } catch {
82
+ } catch (error) {
72
83
  // **The final compile must never be more fragile than a streaming frame.** The only
73
84
  // difference between the modes is that `streaming` first cuts back the still-being-typed
74
85
  // tail, and some damage (an unterminated string, typically) is only recoverable by
75
86
  // cutting. Losing the last half-sentence beats going blank on the last frame.
76
- return build(normalizeGeneratedTsx(code, { mode: "streaming" }));
87
+ const cut = normalizeGeneratedTsx(code, { mode: "streaming" });
88
+ // **But only if anything is left to render.** The cut is bounded by the FIRST thing it
89
+ // cannot parse, so a card that puts its data above its component — the common shape when
90
+ // the data is long — loses the component too, and what comes back is a module of imports
91
+ // and type aliases. That compiles. It exports nothing, mounts nothing, and the surface
92
+ // reports no error, so the reader gets a card of zero height and the model is never told.
93
+ // Measured on a real session: the model wrote `{ name: "questions: "list[...]" }` in the
94
+ // first array element, `streaming` returned the same 265 characters for all 938 frames
95
+ // while the source grew to 13693, and the final frame took this fallback and went blank.
96
+ // The `final` error is the useful one here — it names the line and column of the typo.
97
+ if (!hasDefaultExport(cut)) throw error;
98
+ return build(cut);
77
99
  }
78
100
  },
79
101
  };