dsh-generative-ui 0.0.2 → 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.
@@ -24,7 +24,8 @@ import { createRoot, type Root } from "react-dom/client";
24
24
  import { createElement } from "react";
25
25
  import { CodeBlock } from "@deepseek-ai/dsh-client-ui-primitives";
26
26
  import type { ReactElement } from "react";
27
- import type { Ui4aSegment } from "./segments.ts";
27
+ import { FENCE_LANG } from "../../contract.ts";
28
+ import { parseUi4aSegments, type Ui4aSegment } from "./segments.ts";
28
29
  import { observeTranscript } from "./observe.ts";
29
30
 
30
31
  const CLAIMED = "data-ui4a-claimed";
@@ -42,7 +43,8 @@ const dropPreview = (claim: { preview: { host: HTMLElement; root: Root } | null
42
43
  });
43
44
  };
44
45
 
45
- type Claim = { block: HTMLElement; mount: HTMLElement; root: Root; code: string; complete: boolean; rendered: string; painted: MutationObserver | null; preview: { host: HTMLElement; root: Root } | null };
46
+ /** `reserved` is the segment code this claim owns, so no other block can be matched to it see `sweep`. */
47
+ type Claim = { block: HTMLElement; mount: HTMLElement; root: Root; code: string; reserved: string; complete: boolean; rendered: string; painted: MutationObserver | null; preview: { host: HTMLElement; root: Root } | null };
46
48
 
47
49
  /**
48
50
  * Whether the card has actually painted something a reader can see.
@@ -87,8 +89,17 @@ export const hasPainted = (mount: HTMLElement) => {
87
89
  return false;
88
90
  };
89
91
 
92
+ /** A wider Markdown fence leaves the real ui4a opener as the first line of the rendered code. */
93
+ const LEADING_UI4A_FENCE = new RegExp(String.raw`^\s*\x60{3,}${FENCE_LANG.replace("/", "\\/")}(?:[^\n]*)(?:\n|$)`);
94
+
90
95
  /** The block's source. `pre` when the grammar was unknown, the highlighted div otherwise. */
91
- const codeOf = (block: HTMLElement) => block.querySelector("pre")?.textContent ?? "";
96
+ const codeOf = (block: HTMLElement) => {
97
+ const rendered = block.querySelector("pre")?.textContent ?? "";
98
+ if (!LEADING_UI4A_FENCE.test(rendered)) return rendered;
99
+ // The snapshot parser already owns every malformed-closer tolerance. Reusing it here keeps the
100
+ // DOM half on the same recovered body instead of growing a second fence grammar.
101
+ return parseUi4aSegments(rendered)[0]?.code ?? rendered;
102
+ };
92
103
 
93
104
  /** CodeBlock trims one trailing newline for display, so compare on trimmed ends. */
94
105
  export const sameCode = (a: string, b: string) => a.trimEnd() === b.trimEnd();
@@ -103,14 +114,103 @@ export const matchSegment = (segments: readonly Ui4aSegment[], rendered: string)
103
114
  export type InlineFenceOptions = {
104
115
  /** Every ui4a segment currently in the transcript, in document order. */
105
116
  segments: () => readonly Ui4aSegment[];
106
- render: (props: { code: string; streaming: boolean }) => ReactElement;
117
+ /** `last` answers whether this card is still the transcript's newest — asked at report time, not render time. */
118
+ render: (props: { code: string; streaming: boolean; last: () => boolean }) => ReactElement;
107
119
  scope?: HTMLElement;
108
120
  };
109
121
 
122
+ /**
123
+ * Whether this card's code is the last ui4a segment in the transcript.
124
+ *
125
+ * **Only the last block's failure is worth telling the model about.** An earlier card that cannot
126
+ * render stays in the transcript and re-renders on every later frame, so it fails again, and
127
+ * again, for the rest of the session — measured on a real one where the model wrote a card
128
+ * importing `Github` from `lucide-react` (removed upstream), was told, fixed it to `GitBranch` in
129
+ * the very next reply, and the failure notice kept coming back from the reply it had already
130
+ * superseded. The model cannot act on it: editing a message it already sent is not a thing it
131
+ * can do.
132
+ *
133
+ * Evaluated when the report is about to be SENT, not when the error is raised. A card is the last
134
+ * one at the moment it breaks and stops being so as soon as the next card appears, which is
135
+ * exactly the second in between.
136
+ *
137
+ * **Segments, not mounts.** Comparing DOM nodes was wrong three separate ways, all of them
138
+ * silent: `release` calls `claim.mount.remove()` on every markdown re-render, so a broken card
139
+ * followed by any trailing prose compared a detached node and dropped its own report; `defer`
140
+ * parks a far-offscreen block without ever creating a mount, so a reader scrolled up while the fix
141
+ * streams left an older card holding the last mount and reporting as though it were newest; and an
142
+ * empty node list has to mean false, since a session switch inside the settle window empties it
143
+ * while `sendToModel` reads `currentSession()` at send time — session A's error into session B's
144
+ * store. The segment list is the transcript's own order and survives all three.
145
+ */
146
+ export const isLastSegment = (segments: readonly Ui4aSegment[], code: string): boolean => {
147
+ const last = segments.at(-1);
148
+ return last !== undefined && (sameCode(last.code, code) || last.code.startsWith(code));
149
+ };
150
+
151
+ /**
152
+ * How far outside the viewport a card is still worth compiling.
153
+ *
154
+ * One viewport in each direction: the reader who scrolls towards a card finds it already
155
+ * rendered, because compiling starts a screen before it arrives. Smaller and a fast scroll
156
+ * outruns it; much larger and the whole transcript is "near" again, which is the state this
157
+ * exists to leave.
158
+ */
159
+ const NEAR_VIEWPORT = "100% 0px";
160
+
110
161
  export function claimInlineFences({ segments, render, scope }: InlineFenceOptions): () => void {
111
162
  const claims = new Map<HTMLElement, Claim>();
112
163
  const root = scope ?? document.body;
113
164
 
165
+ // Blocks parked until they come near the viewport. Kept so the disposer can stop observing
166
+ // them, and so `sweep` does not re-observe one it is already watching.
167
+ const parked = new Set<HTMLElement>();
168
+ // Blocks the observer has woken. Kept separately because waking does NOT move the block: the
169
+ // observer fires on entering the margin, while `getBoundingClientRect` still reads whatever
170
+ // the layout says, so re-measuring in the sweep that the wake triggered parks it straight back
171
+ // and the card never renders. Membership here is the permission; the sweep clears it.
172
+ const woken = new Set<HTMLElement>();
173
+ const nearby: IntersectionObserver | null =
174
+ typeof IntersectionObserver === "undefined"
175
+ ? null
176
+ : new IntersectionObserver(
177
+ (entries) => {
178
+ let woke = false;
179
+ for (const entry of entries) {
180
+ if (!entry.isIntersecting) continue;
181
+ const block = entry.target as HTMLElement;
182
+ nearby?.unobserve(block);
183
+ parked.delete(block);
184
+ woken.add(block);
185
+ woke = true;
186
+ }
187
+ // One sweep for the batch, not one per block: a scroll can bring a dozen into range
188
+ // in the same frame, and `sweep` walks the whole transcript each time it runs.
189
+ if (woke) sweep();
190
+ },
191
+ { rootMargin: NEAR_VIEWPORT },
192
+ );
193
+
194
+ /** True when this block should wait. An incomplete (still streaming) segment never waits — it is what the reader is watching. */
195
+ const defer = (block: HTMLElement, segment: Ui4aSegment): boolean => {
196
+ if (nearby === null || !segment.complete) return false;
197
+ if (woken.delete(block)) return false;
198
+ if (parked.has(block)) return true;
199
+ // `getBoundingClientRect` rather than waiting for the observer's first callback: the observer
200
+ // reports asynchronously, so a block would be claimed before its first entry ever arrives and
201
+ // the deferral would never happen at all.
202
+ const box = block.getBoundingClientRect();
203
+ const limit = (globalThis.innerHeight || 0) || 0;
204
+ // A zero-height box means the block is not laid out yet (display:none, or an ancestor still
205
+ // hidden). That is not "far away", and treating it as such parks a card that is about to be
206
+ // visible, so measure it again next sweep instead of parking it.
207
+ if (limit === 0 || (box.height === 0 && box.width === 0)) return false;
208
+ if (box.bottom > -limit && box.top < limit * 2) return false;
209
+ parked.add(block);
210
+ nearby.observe(block);
211
+ return true;
212
+ };
213
+
114
214
  const release = (claim: Claim, restore: boolean) => {
115
215
  claim.painted?.disconnect();
116
216
  claim.root.unmount();
@@ -125,13 +225,71 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
125
225
 
126
226
  const sweep = () => {
127
227
  const current = segments();
228
+ // Dead claims first, because the reservation below is built from what claims hold. A block
229
+ // the host's markdown re-render replaced is gone but its claim is not, and releasing it in
230
+ // the loop *after* the claim loop let it reserve its segment for one more sweep — long
231
+ // enough for the replacement block to find nothing free and paint nothing. With the
232
+ // re-render happening every streamed frame that alternates, and the card flickers.
233
+ for (const claim of claims.values()) if (!claim.block.isConnected) release(claim, false);
234
+ // A segment backs at most one block. `matchSegment` matches by PREFIX and takes the first
235
+ // hit in document order, and every generated card opens with the same line — measured on a
236
+ // real transcript, all three cards started `import { useState } from "react"`, so the third
237
+ // card's opening 40 characters were still a prefix of the FIRST card's code. Its slot
238
+ // rendered card one in full, then blanked for 2.8s when the texts diverged and the partial
239
+ // buffer had no `export default` yet: content, then nothing, then finally the right card.
240
+ // Excluding what live claims already own leaves each new block only the segments still going
241
+ // spare, which in a growing transcript is the one still streaming.
242
+ const taken = new Set([...claims.values()].map((claim) => claim.reserved));
128
243
 
129
244
  for (const block of root.querySelectorAll<HTMLElement>(`.md-code-block:not([${CLAIMED}])`)) {
245
+ // OUR OWN nodes are not candidates. `CodeBlock` — the host component the source preview is
246
+ // rendered with — puts `md-code-block` on its own root, so every preview we mount is itself
247
+ // a match for this selector, and a card that renders a code block is another. Claiming one
248
+ // mounts a card inside it, whose preview is a third block, and so on.
249
+ //
250
+ // Measured in a live session: `blocks` climbed 1 → 2 → 3 while `mounts` and `prev` climbed
251
+ // with it, then the whole stack collapsed to 0 and rebuilt, several times a second — the
252
+ // flicker. The reservation below does NOT stop it: it holds `claim.reserved`, the code from
253
+ // the PREVIOUS frame, while the segment has already grown, so mid-stream the two never
254
+ // match and nothing is excluded.
255
+ if (block.closest(`[${PREVIEW}]`) !== null || block.closest(`[${MOUNT}]`) !== null) continue;
130
256
  const code = codeOf(block);
131
257
  if (code === "") continue;
132
258
  // A streaming block's rendered text is a prefix of its segment; a settled one equals it.
133
- const segment = matchSegment(current, code);
259
+ const segment = matchSegment(
260
+ current.filter((candidate) => !taken.has(candidate.code)),
261
+ code,
262
+ );
134
263
  if (segment === undefined) continue;
264
+ // A DEFERRED block still owns its segment. Deferring means "do not mount this yet", not
265
+ // "this segment is free" — and the card that gets parked is by definition a settled one far
266
+ // off screen, which in a live transcript is the oldest card while the reader sits at the
267
+ // bottom watching a new one arrive. Reserving only on the claim path left that segment
268
+ // spare, and the new block — still showing nothing but the opening line every generated
269
+ // card shares — matched it by prefix and painted the OLD card where the new one belonged.
270
+ //
271
+ // Reserve BEFORE the deferral check, or the two earlier fixes to this class (the per-claim
272
+ // reservation, and pruning dead claims before building it) go on missing it: both hold only
273
+ // what a live claim owns, and a parked block has no claim. Reserving here also covers the
274
+ // within-one-sweep case a reload presents — every block unclaimed at once, all of them
275
+ // otherwise matching the same segment.
276
+ taken.add(segment.code);
277
+ // FAR OFFSCREEN AND NOT YET STREAMING: leave it for later. Claiming a block compiles it,
278
+ // mounts a React root, runs every effect it declares and pulls its third-party imports off
279
+ // esm.sh — a Monaco card costs megabytes and starts a language service. `isConnected` was
280
+ // the only liveness test here, and the host keeps a long transcript's messages in the DOM,
281
+ // so scrolling back through twenty cards paid all of that twenty times over for cards
282
+ // nobody was looking at.
283
+ //
284
+ // A STREAMING block is never deferred: it is what the reader is watching, and its segment
285
+ // is still growing. `defer` also answers false when there is no observer (no
286
+ // IntersectionObserver, or `scope` is detached in a test), so the behaviour without one is
287
+ // exactly what it was before.
288
+ //
289
+ // Called ONCE. It has side effects — it consumes the wake flag, parks the block and starts
290
+ // observing it — so asking twice in one sweep eats the wake and re-parks a block that had
291
+ // just come into view.
292
+ if (defer(block, segment)) continue;
135
293
  block.setAttribute(CLAIMED, "");
136
294
  const mount = document.createElement("div");
137
295
  mount.setAttribute(MOUNT, "");
@@ -148,7 +306,7 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
148
306
  previewHost.setAttribute(PREVIEW, "");
149
307
  block.parentElement?.insertBefore(previewHost, block);
150
308
  block.style.display = "none";
151
- const claim: Claim = { block, mount, root: createRoot(mount), code: "", complete: false, rendered: "", painted: null, preview: { host: previewHost, root: createRoot(previewHost) } };
309
+ const claim: Claim = { block, mount, root: createRoot(mount), code: "", reserved: segment.code, complete: false, rendered: "", painted: null, preview: { host: previewHost, root: createRoot(previewHost) } };
152
310
  // The source stays visible until the card paints. Checked at most once per frame and
153
311
  // torn down the moment it fires: a streaming card mutates thousands of times, and
154
312
  // `textContent` walks the whole subtree, so a per-mutation check would be
@@ -169,6 +327,8 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
169
327
  }
170
328
 
171
329
  for (const claim of claims.values()) {
330
+ // Already pruned at the top of the sweep; a block can still go while the claim loop above
331
+ // runs, so this stays as the guard for the rest of this pass.
172
332
  if (!claim.block.isConnected) {
173
333
  release(claim, false);
174
334
  continue;
@@ -185,7 +345,13 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
185
345
  // is still streaming is being re-scanned every frame regardless.
186
346
  if (rendered === claim.rendered && claim.code !== "" && claim.complete) continue;
187
347
  claim.rendered = rendered;
188
- const segment = matchSegment(current, rendered);
348
+ // Same reservation as the claim loop, minus this claim's own segment: a block whose text is
349
+ // still the shared opening line matches an older card here too, and the newest block would
350
+ // be handed the oldest card's code on the very sweep that claimed it.
351
+ const segment = matchSegment(
352
+ current.filter((candidate) => candidate.code === claim.reserved || !taken.has(candidate.code)),
353
+ rendered,
354
+ );
189
355
  // The snapshot is authoritative while it still describes this block: mid-stream its
190
356
  // code runs ahead of what markdown has painted. Once it stops describing it — an
191
357
  // older page dropped out of the loaded window — the last good frame stands, because
@@ -202,10 +368,16 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
202
368
  continue;
203
369
  }
204
370
  const { code, complete } = segment;
371
+ // Follow the segment as it grows, or the reservation still names the prefix it was claimed on.
372
+ taken.delete(claim.reserved);
373
+ taken.add(code);
374
+ claim.reserved = code;
205
375
  if (code === claim.code && complete === claim.complete) continue;
206
376
  claim.code = code;
207
377
  claim.complete = complete;
208
- claim.root.render(render({ code, streaming: !complete }));
378
+ // `code` is captured, `segments()` is read at call time: that is exactly the report-time
379
+ // question, and it stays correct across the re-render that replaces this claim's own block.
380
+ claim.root.render(render({ code, streaming: !complete, last: () => isLastSegment(segments(), code) }));
209
381
  // The preview follows the SEGMENT, not the block: mid-stream the snapshot runs ahead of
210
382
  // what markdown has painted, so this is the newer text and the one the reader wants while
211
383
  // waiting. Dropped the moment the card paints.
@@ -217,6 +389,11 @@ export function claimInlineFences({ segments, render, scope }: InlineFenceOption
217
389
 
218
390
  return () => {
219
391
  stop();
392
+ // `disconnect` rather than unobserving each: the observer is going away with us, and a
393
+ // parked block that is never claimed would otherwise keep it alive through its target list.
394
+ nearby?.disconnect();
395
+ parked.clear();
396
+ woken.clear();
220
397
  for (const claim of claims.values()) release(claim, true);
221
398
  };
222
399
  }
@@ -2,17 +2,28 @@
2
2
  * Everything generated code may import as a "local" module.
3
3
  *
4
4
  * The react family MUST be here: generated code shares the shell's single React
5
- * instance or hooks blow up with an invalid-hook-call. All five come from the
6
- * shell's platform table (see tsdown.config.ts), so registering them costs no
5
+ * instance or hooks blow up with an invalid-hook-call. Four of the five come from
6
+ * the shell's platform table (see platform.ts), so registering them costs no
7
7
  * bundle weight — it only republishes the instances we already received.
8
8
  *
9
- * We deliberately pre-register no component library. Anything else resolves
10
- * through the esm.sh fallback at compile time.
9
+ * `scheduler` is the fifth, and it is NOT a platform module (see platform.ts's own
10
+ * comment) build.ts bundles a real copy of it into this file. It still has to be
11
+ * registered here, because `partial-react`'s `DEFAULT_ESM_SH_EXTERNALS` lists it
12
+ * alongside `react`/`react-dom` and asks esm.sh to leave it unbundled in every
13
+ * `@react-three/*`-shaped fallback fetch (`?...&external=react,react-dom,scheduler`).
14
+ * Those packages' compiled output therefore contains a literal `import ... from
15
+ * "scheduler"` that only resolves against the document import map this module
16
+ * installs — omitting the entry here is a silent `Failed to resolve module
17
+ * specifier "scheduler"` at the browser level, not a compile-time error.
18
+ *
19
+ * We otherwise pre-register no component library. Anything else resolves through
20
+ * the esm.sh fallback at compile time.
11
21
  */
12
22
  import * as React from "react";
13
23
  import * as ReactJsxRuntime from "react/jsx-runtime";
14
24
  import * as ReactDom from "react-dom";
15
25
  import * as ReactDomClient from "react-dom/client";
26
+ import * as Scheduler from "scheduler";
16
27
  import { registerModules, registryImports } from "./registry.ts";
17
28
 
18
29
  let installed = false;
@@ -50,6 +61,7 @@ export function registerRuntimeModules(): void {
50
61
  "react/jsx-dev-runtime": ReactJsxRuntime,
51
62
  "react-dom": ReactDom,
52
63
  "react-dom/client": ReactDomClient,
64
+ scheduler: Scheduler,
53
65
  });
54
66
  installDocumentImportMap();
55
67
  }
@@ -8,7 +8,7 @@
8
8
  * ends there: `MonacoEditor` appears 6 times in the records after that point and `no export named`
9
9
  * zero. The reader saw the answer on screen; the one party who could act on it did not.
10
10
  *
11
- * Three constraints, each of which this got wrong in an obvious first version:
11
+ * Four constraints, each of which this got wrong in an obvious first version:
12
12
  *
13
13
  * - **Only errors that survived.** `GenUISurface` already separates a mid-stream prefix failure
14
14
  * and a retryable network blip from a real one — the `report` branch of its error action. That
@@ -22,34 +22,30 @@
22
22
  * frame costs nothing; sending the model a message about a card that then worked costs it a
23
23
  * turn spent fixing what is not broken. So the send is DEFERRED, and a paint cancels it.
24
24
  * - **Once per card, not once per frame.** A settled card that fails re-renders on every later
25
- * frame of the transcript, and a message per render is a loop the user has to kill. Keyed on
26
- * the message text.
27
- * - **Announced as automatic.** The model is about to read a user-role message it was not sent.
28
- * Saying where it came from is what stops it replying "sorry about that" to a person who typed
29
- * nothing.
25
+ * frame of the transcript, and a turn per render is a loop the user has to kill. Deduplication
26
+ * lives HOST-side now (`CardFailures.set`), because this half is thrown away by every
27
+ * navigation and a dedup set that resets on reload wakes the model about a card it already
28
+ * knows. What stays here is the settling, which is about frames, not about turns.
29
+ * - **Recovery is reported too.** The report is state, not an event: a card that starts working
30
+ * sends `null`, and the host drops it out of the model's context. As a chat message that was
31
+ * impossible, which is why the old body had to carry a paragraph explaining that nobody had
32
+ * typed it.
33
+ * - **Only the newest card counts.** An earlier card that cannot render stays in the transcript
34
+ * and re-renders on every later frame, so it goes on failing for the rest of the session.
35
+ * Measured on a real one: the model wrote a card importing `Github` from `lucide-react`
36
+ * (removed upstream), was told, fixed it to `GitBranch` in its very next reply — and the
37
+ * failure notice kept coming back from the message it had already superseded. There is nothing
38
+ * the model can do with that; it cannot edit a reply it has sent. `isLastSegment` is the gate, on both the report and the retraction.
30
39
  */
31
- const sent = new Set<string>();
32
40
 
33
41
  /** Exported for the test: a fresh card in a fresh session should be able to report again. */
34
42
  export const forgetReportedErrors = () => {
35
- sent.clear();
36
- cardRendered();
43
+ cancelPendingReport();
44
+ outstanding = null;
37
45
  };
38
46
 
39
- export type ErrorReporter = (text: string) => void;
40
-
41
- /**
42
- * The message body. Kept short and factual: it is spent from the user's context window, and the
43
- * one thing the model needs is what failed and that nobody typed it.
44
- *
45
- * English, like the prompt and the skill it sits beside. This message is the only text this
46
- * plugin puts into the conversation, and writing it in Chinese did two things: it read as a
47
- * different voice from everything else the plugin says, and — because a card must be written in
48
- * the language the USER wrote in — it pushed the model toward answering a Spanish or French
49
- * speaker in the wrong language for the rest of the turn.
50
- */
51
- export const reportBody = (message: string, phase: string) =>
52
- `[automatic] The card you just wrote did not render. It failed at the ${phase} step:\n\n${message}\n\nThis was sent by the renderer, not by the user — nobody typed it, so do not apologise or address it as a request. 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, and answer in the language the user has been writing in.`;
47
+ /** `null` means the card recovered. */
48
+ export type ErrorReporter = (report: { message: string; phase: string } | null) => void;
53
49
 
54
50
  /**
55
51
  * How long an error must stand before the model hears about it. A streaming card recompiles many
@@ -58,22 +54,81 @@ export const reportBody = (message: string, phase: string) =>
58
54
  */
59
55
  const SETTLE_MS = 1000;
60
56
 
61
- let pending: ReturnType<typeof setTimeout> | null = null;
57
+ /**
58
+ * The one report waiting out its settle window, and the card it belongs to.
59
+ *
60
+ * One slot, not one per card, because only one report can be outstanding host-side. But the slot
61
+ * being shared is what made the gate below dangerous: a superseded card re-compiles on every later
62
+ * frame — that is the premise of this whole file — and evicting the newest card's armed timer to
63
+ * install its own meant the newest card's report died when the superseded one was gated off. So
64
+ * eviction is now conditional on the incoming card actually being reportable.
65
+ */
66
+ let pending: { timer: ReturnType<typeof setTimeout>; message: string } | null = null;
67
+ /**
68
+ * Whether the host currently holds a failure for this surface.
69
+ *
70
+ * The report is state, so recovery has to be reported too — otherwise a card that failed once
71
+ * stays in the model's context for the rest of the session. Kept here rather than derived from
72
+ * `pending`, which is only the not-yet-sent window.
73
+ */
74
+ let outstanding: ErrorReporter | null = null;
62
75
 
63
- /** Called when a surface paints. Cancels a report the very next frame made untrue. */
64
- export function cardRendered(): void {
76
+ /** Drop a report the page is going away before it can deliver. NOT a recovery nothing is fixed. */
77
+ export function cancelPendingReport(): void {
65
78
  if (pending === null) return;
66
- clearTimeout(pending);
79
+ clearTimeout(pending.timer);
67
80
  pending = null;
68
81
  }
69
82
 
70
- export function reportCardError(send: ErrorReporter | undefined, message: string, phase: string): void {
83
+ /**
84
+ * Called when a surface paints. Cancels a report the very next frame made untrue.
85
+ *
86
+ * **`restored` is not a detail.** partial-react answers a render throw by re-mounting the LAST
87
+ * GOOD component (`runtime.ts:416-419`, whenever `preserve` is on — which is every inline card),
88
+ * and that re-mount paints, and painting used to cancel the report that the very same throw had
89
+ * just armed. So the one case the reporting exists for — a card that worked, then broke on an
90
+ * edit — showed the reader stale content and told the model nothing. A paint only means the card
91
+ * is fine when it is the NEW code that painted.
92
+ */
93
+ /**
94
+ * @param current whether the painting card is the one the gate would let report. Defaults to
95
+ * permissive for the canvas path, which shows one card at a time.
96
+ */
97
+ export function cardRendered(restored = false, current: () => boolean = () => true): void {
98
+ if (restored) return;
99
+ cancelPendingReport();
100
+ // **A card may only retract its OWN failure.** The error path is gated on `isLastSegment`; without
101
+ // the same gate here the asymmetry is that a superseded card cannot report a failure but can
102
+ // still clear someone else's. That is reachable by scrolling: an old card enters the
103
+ // IntersectionObserver margin, gets claimed, compiles and paints, and the model's context is
104
+ // told nothing is broken while the newest card is still a red panel — and it cannot re-report,
105
+ // because `reportStranded`'s `reportedFor` guard already fired for that code.
106
+ if (!current()) return;
107
+ // A card that is working again must be taken OUT of the model's context, or it reads about a
108
+ // failure that no longer exists on every step for the rest of the session.
109
+ const send = outstanding;
110
+ outstanding = null;
111
+ send?.(null);
112
+ }
113
+
114
+ export function reportCardError(send: ErrorReporter | undefined, message: string, phase: string, current: () => boolean = () => true): void {
71
115
  if (send === undefined) return;
72
- if (sent.has(message)) return;
73
- sent.add(message);
74
- if (pending !== null) clearTimeout(pending);
75
- pending = setTimeout(() => {
116
+ // **Asked twice, and the first time is not redundant.** A superseded card that will be gated off
117
+ // must not evict an armed report belonging to a card that will not be — otherwise the newest
118
+ // card, which IS broken, reports nothing: its timer is cleared, the evictor's own timer fires
119
+ // 1000ms later, `current()` is false, and the slot empties with nothing sent. The newest card
120
+ // cannot re-arm either, because `reportStranded`'s `reportedFor` guard suppresses a second
121
+ // report for the same code. Measured: with the old card re-failing every 300ms the newest
122
+ // card's reporter stayed empty indefinitely.
123
+ if (!current()) return;
124
+ if (pending !== null) clearTimeout(pending.timer);
125
+ // Asked AGAIN when the report is about to be SENT, because that is the question that matters:
126
+ // a card is the newest one in the transcript at the instant it throws and stops being so the
127
+ // moment the model's next reply lands — which is precisely the second this timer waits out.
128
+ pending = { timer: setTimeout(() => {
76
129
  pending = null;
77
- send(reportBody(message, phase));
78
- }, SETTLE_MS);
130
+ if (!current()) return;
131
+ outstanding = send;
132
+ send({ message, phase });
133
+ }, SETTLE_MS), message };
79
134
  }
@@ -12,7 +12,19 @@
12
12
  */
13
13
  import * as React from "react";
14
14
 
15
- /** Namespaced so two cards picking the same obvious key ("todos") do not read each other's data. */
15
+ /**
16
+ * Namespaced away from the host's own storage — and **not** from other cards, which this comment
17
+ * used to claim. Two cards that both pick `"todos"` share one entry, because the prefix is
18
+ * constant. The design note for this feature keys on the message id as well
19
+ * (`key + message id`), which would isolate them; we do not, and the measured consequence is
20
+ * small: across 36 uses in four waves, 35 keys were distinct — the model reaches for descriptive
21
+ * names (`plan-semanal:eaten`) rather than `todos`, and the one collision was the same question
22
+ * sampled twice, where sharing is arguably right.
23
+ *
24
+ * Adding the message id is not free either: it would make a card's state vanish when the card is
25
+ * re-delivered under a new id, which is the remount this hook exists to survive. Left as is
26
+ * deliberately, recorded so the next reader does not have to re-derive it.
27
+ */
16
28
  const scope = (key: string) => `dsh-genui:${key}`;
17
29
 
18
30
  function read<T>(key: string, initial: T | (() => T)): T {
@@ -39,8 +39,21 @@ export const unoConfig = (scope: string): UserConfig => ({
39
39
  // correct — a grid list has no use for UA markers — and dropping the reset put them back, so
40
40
  // a column of stray bullets sits OUTSIDE the card border at every width. Not something to
41
41
  // ask the model to write `list-none` for on every list.
42
+ //
43
+ // Headings and paragraphs are the same omission, found later and from the other end: a
44
+ // reader asking why there is ALWAYS a band of empty space above a card's title. Measured on
45
+ // the surface, the UA margins still standing were h1 21.4px, h2 19.9, h3 18.7, h4 21.3,
46
+ // p/blockquote/figure 16, pre 13 — while `ul` read 0, which is what proves the line above
47
+ // works and these tags were simply not in it. A card's first child is almost always a
48
+ // heading, so it pushes itself down by more than the card's own `p-3`, and `<p>` margins
49
+ // then fight whatever `gap-*` the layout uses. `important: scope` makes every utility
50
+ // `.ui4a-root :is(.mt-4)` at (0,2,0), which outranks these (0,1,1) rules — so a card that
51
+ // asks for a margin still gets one, and only the browser's uninvited ones go.
42
52
  getCSS: () => `${scope} *, ${scope} *::before, ${scope} *::after { box-sizing: border-box; }
43
53
  ${scope} ul, ${scope} ol { list-style: none; margin: 0; padding: 0; }
54
+ ${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4, ${scope} h5, ${scope} h6,
55
+ ${scope} p, ${scope} blockquote, ${scope} figure, ${scope} figcaption,
56
+ ${scope} dl, ${scope} dd, ${scope} pre, ${scope} hr { margin: 0; }
44
57
  ${scope} button, ${scope} input, ${scope} select, ${scope} textarea {
45
58
  background: transparent; color: inherit; font: inherit; border: 0 solid; cursor: pointer;
46
59
  }
@@ -9,13 +9,20 @@ import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
9
9
 
10
10
  export type ChatNodeView = { readonly kind: string; readonly data: unknown; readonly anchorSeq: number };
11
11
 
12
+ type ChatSnapshotLike = { readonly nodes: { values(): Iterable<unknown> } };
13
+ type UiConversationLike = { binding(source: unknown): { target(name: "chat"): { getSnapshot(): ChatSnapshotLike | undefined } } };
14
+
12
15
  /** The current session's chat nodes, or an empty list when no session is open. */
13
16
  export function chatNodes(ctx: ClientContext): readonly ChatNodeView[] {
14
17
  const sessionId = ctx.sessions.list.getSnapshot().current;
15
18
  if (sessionId === undefined) return [];
16
- const snapshot = ctx.sessions.binding(sessionId)?.session.getSnapshot();
17
- if (snapshot === undefined) return [];
18
- return [...snapshot.chat.nodes.values()] as readonly ChatNodeView[];
19
+ const binding = ctx.sessions.binding(sessionId);
20
+ if (binding === undefined) return [];
21
+ // A static inject would disable older hosts, so feature-detect the 0.1.2 service per sweep.
22
+ const uiConversation = ctx.get("uiConversation") as UiConversationLike | undefined;
23
+ const chat = uiConversation === undefined ? (binding.session.getSnapshot() as unknown as { readonly chat?: ChatSnapshotLike } | undefined)?.chat : uiConversation.binding(binding).target("chat").getSnapshot();
24
+ if (chat === undefined) return [];
25
+ return [...chat.nodes.values()] as readonly ChatNodeView[];
19
26
  }
20
27
 
21
28
  /**
@@ -44,3 +44,14 @@ export const EXEC_PATH = "/dsh-generative-ui/exec";
44
44
  * re-opening from here what the host closed for its own tools is not ours to do.
45
45
  */
46
46
  export const WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
47
+
48
+ /**
49
+ * A card's surviving failure, reported by the browser half: `?session=<id>`, POST
50
+ * `{message, phase}` to set it and `{}` to clear it.
51
+ *
52
+ * The detail does NOT come back as a chat message. It becomes a runtime-context snapshot
53
+ * (`ui4a:card-failure`), which is re-evaluated per assembly and superseded by the next one, so a
54
+ * card that gets fixed stops being mentioned instead of leaving a stale complaint in history. The
55
+ * route only carries the state; `wakeAgent` is what asks the model to look at it.
56
+ */
57
+ export const CARD_ERROR_PATH = "/dsh-generative-ui/card-error";