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.
- package/README.md +9 -2
- package/lib/client.js +484 -98
- package/lib/client.js.map +19 -17
- package/lib/index.js +523 -34
- package/lib/types/card-failure.d.ts +88 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +1 -1
- package/lib/types/client/canvas/index.d.ts +1 -1
- package/lib/types/client/runtime/GenUISurface.d.ts +65 -35
- package/lib/types/client/runtime/inline-fence.d.ts +28 -1
- package/lib/types/client/runtime/report-error.d.ts +59 -12
- package/lib/types/contract-assets.d.ts +10 -0
- package/lib/types/index.d.ts +50 -7
- package/package.json +4 -2
- package/src/card-failure.ts +106 -0
- package/src/client/canvas/CanvasPanel.tsx +10 -4
- package/src/client/canvas/index.ts +1 -1
- package/src/client/index.ts +22 -9
- package/src/client/runtime/GenUISurface.tsx +172 -48
- package/src/client/runtime/bindings.ts +3 -2
- package/src/client/runtime/compiler.ts +24 -2
- package/src/client/runtime/inline-fence.ts +185 -8
- package/src/client/runtime/register.ts +16 -4
- package/src/client/runtime/report-error.ts +89 -34
- package/src/client/runtime/state.ts +13 -1
- package/src/client/runtime/uno-config.ts +13 -0
- package/src/client/session.ts +10 -3
- package/src/contract-assets.ts +11 -0
- package/src/index.ts +113 -10
- package/src/prompt.ts +154 -10
- package/src/skill.ts +296 -22
- package/types/fs.d.ts +15 -1
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
6
|
-
* shell's platform table (see
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
43
|
+
cancelPendingReport();
|
|
44
|
+
outstanding = null;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
64
|
-
export function
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/client/session.ts
CHANGED
|
@@ -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
|
|
17
|
-
if (
|
|
18
|
-
|
|
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
|
/**
|
package/src/contract-assets.ts
CHANGED
|
@@ -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";
|