@adia-ai/a2ui 0.8.39 → 0.8.41
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/CHANGELOG.md +17 -0
- package/README.md +2 -2
- package/catalog/catalog-a2ui_0_9.json +1461 -490
- package/catalog/catalog-a2ui_0_9_rules.txt +41 -30
- package/catalog/common_types.json +1 -0
- package/catalog/tier-index.json +274 -71
- package/catalog/tiers/README.md +19 -1
- package/catalog/tiers/l1-widgets.json +6 -6
- package/package.json +1 -1
- package/registry.js +6 -0
- package/renderer.d.ts +77 -0
- package/renderer.js +420 -11
- package/stream.js +11 -3
- package/wiring-engine.d.ts +7 -0
- package/wiring-engine.js +15 -0
package/renderer.js
CHANGED
|
@@ -41,23 +41,85 @@ export class A2UIRenderer {
|
|
|
41
41
|
#flush() {
|
|
42
42
|
this.#rafId = null;
|
|
43
43
|
const batch = this.#queue.splice(0);
|
|
44
|
-
|
|
44
|
+
// Per-message error isolation (gh#1364 review finding): #flush runs
|
|
45
|
+
// inside a requestAnimationFrame callback with no caller to propagate
|
|
46
|
+
// to — a hard protocol error from ONE message (e.g. REQ-002's
|
|
47
|
+
// DUPLICATE_SURFACE_ID throw) must not silently drop every remaining
|
|
48
|
+
// message in the batch, nor escape uncaught where nothing can observe
|
|
49
|
+
// it. Non-batching `process()` keeps throwing straight to its caller
|
|
50
|
+
// (a real call site that can catch it) — this isolation is specific
|
|
51
|
+
// to the queued/rAF path, which <a2ui-root> reaches whenever `batch`
|
|
52
|
+
// is set.
|
|
53
|
+
for (const msg of batch) {
|
|
54
|
+
try {
|
|
55
|
+
this.#processOne(msg);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('A2UI: message failed during batch flush', err);
|
|
58
|
+
this.#dispatchDiagnostic(null, 'a2ui-message-error', {
|
|
59
|
+
error: err, messageType: msg?.type || msg?.messageType,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
45
63
|
}
|
|
46
64
|
|
|
65
|
+
// Message kinds that buffer inside an open beginSurfaceUpdate bracket
|
|
66
|
+
// (spec-a2ui-surface-lifecycle.md). `deleteSurface` is deliberately
|
|
67
|
+
// absent — it "never buffers... tears down immediately, cancelling any
|
|
68
|
+
// pending generation" (spec, state-machine section); `createSurface`
|
|
69
|
+
// targeting an id already in the map always hits the REQ-002 hard-error
|
|
70
|
+
// branch instead, pending or not.
|
|
71
|
+
static #BUFFERABLE_KINDS = new Set([
|
|
72
|
+
'updateComponents', 'updateDataModel', 'wireComponents', 'updateStyles', 'removeStyles',
|
|
73
|
+
]);
|
|
74
|
+
|
|
47
75
|
#processOne(message) {
|
|
76
|
+
const kind = message.type || message.messageType;
|
|
77
|
+
const surfaceId = message.surfaceId;
|
|
78
|
+
if (surfaceId && A2UIRenderer.#BUFFERABLE_KINDS.has(kind)) {
|
|
79
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
80
|
+
if (surface && !surface.synthetic && this.#isSurfacePending(surface)) {
|
|
81
|
+
surface.lifecycle.buffer.push(message);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.#dispatchMessage(message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The raw switch — no lifecycle/buffering guard. Called directly by
|
|
89
|
+
// #processOne for an immediate apply, and by #commitSurfaceUpdate to
|
|
90
|
+
// replay a bracket's buffer (which must bypass the pending check above,
|
|
91
|
+
// since the surface is still `pending-*` while its own buffer replays).
|
|
92
|
+
//
|
|
93
|
+
// Deliberately NOT declared `async`: a hard synchronous throw here (e.g.
|
|
94
|
+
// REQ-002's DUPLICATE_SURFACE_ID) must propagate to the caller as a real
|
|
95
|
+
// synchronous exception — #flush's per-message try/catch (gh#1364 review
|
|
96
|
+
// finding) and process()'s own direct-throw contract both depend on
|
|
97
|
+
// that. `async` would silently convert that throw into a rejected
|
|
98
|
+
// Promise instead. The `wireComponents` branch RETURNS `#wireComponents`'s
|
|
99
|
+
// own promise (that method is itself `async`) without awaiting it here —
|
|
100
|
+
// #processOne's call site drops it (fire-and-forget, same as `process()`
|
|
101
|
+
// always did for wireComponents); #commitSurfaceUpdate's buffer replay
|
|
102
|
+
// is the one caller that DOES await this return value, so a bracket's
|
|
103
|
+
// wiring finishes docking (and #wiringEngine is guaranteed initialized)
|
|
104
|
+
// before #sweepSurvivors runs.
|
|
105
|
+
#dispatchMessage(message) {
|
|
48
106
|
switch (message.type || message.messageType) {
|
|
49
|
-
case 'createSurface': this.#createSurface(message);
|
|
50
|
-
case 'updateComponents': this.#updateComponents(message);
|
|
51
|
-
case 'updateDataModel': this.#updateDataModel(message);
|
|
52
|
-
case 'wireComponents': this.#wireComponents(message);
|
|
53
|
-
case 'deleteSurface': this.#deleteSurface(message);
|
|
54
|
-
case 'updateStyles': this.#updateStyles(message);
|
|
55
|
-
case 'removeStyles': this.#removeStyles(message);
|
|
56
|
-
case 'meta':
|
|
57
|
-
default: console.warn('A2UI: unknown message type', message.type);
|
|
107
|
+
case 'createSurface': this.#createSurface(message); return undefined;
|
|
108
|
+
case 'updateComponents': this.#updateComponents(message); return undefined;
|
|
109
|
+
case 'updateDataModel': this.#updateDataModel(message); return undefined;
|
|
110
|
+
case 'wireComponents': return this.#wireComponents(message);
|
|
111
|
+
case 'deleteSurface': this.#deleteSurface(message); return undefined;
|
|
112
|
+
case 'updateStyles': this.#updateStyles(message); return undefined;
|
|
113
|
+
case 'removeStyles': this.#removeStyles(message); return undefined;
|
|
114
|
+
case 'meta': return undefined; // LLM self-critique — not renderable
|
|
115
|
+
default: console.warn('A2UI: unknown message type', message.type); return undefined;
|
|
58
116
|
}
|
|
59
117
|
}
|
|
60
118
|
|
|
119
|
+
#isSurfacePending(surface) {
|
|
120
|
+
return surface.lifecycle.state === 'pending-first' || surface.lifecycle.state === 'pending-stale';
|
|
121
|
+
}
|
|
122
|
+
|
|
61
123
|
// ── wireComponents (lazy-loaded) ──
|
|
62
124
|
|
|
63
125
|
#wiringEngine = null;
|
|
@@ -85,7 +147,28 @@ export class A2UIRenderer {
|
|
|
85
147
|
// ── createSurface ──
|
|
86
148
|
|
|
87
149
|
#createSurface({ surfaceId, catalogId, root: rootId }) {
|
|
88
|
-
if (this.#surfaces.has(surfaceId))
|
|
150
|
+
if (this.#surfaces.has(surfaceId)) {
|
|
151
|
+
// REQ-002 (spec-a2ui-v1-conformance.md): v1.0 Candidate makes a
|
|
152
|
+
// duplicate surfaceId a HARD error. Promoted from the P0
|
|
153
|
+
// diagnostic-only no-op (gh#1350) now that the surface lifecycle API
|
|
154
|
+
// ships below (gh#1364, ADR-0061's interlock): hosts have a
|
|
155
|
+
// sanctioned regeneration path — beginSurfaceUpdate → applyTo →
|
|
156
|
+
// commitSurfaceUpdate (spec-a2ui-surface-lifecycle.md) — so a second
|
|
157
|
+
// createSurface for an existing id is no longer anyone's only route.
|
|
158
|
+
// The diagnostic event still fires first (observability parity with
|
|
159
|
+
// the P0 shape) before the throw.
|
|
160
|
+
const existing = this.#surfaces.get(surfaceId);
|
|
161
|
+
this.#dispatchDiagnostic(existing.root, 'a2ui-duplicate-surface-id', { surfaceId });
|
|
162
|
+
const err = new Error(
|
|
163
|
+
`A2UI: duplicate createSurface for surfaceId "${surfaceId}" — REQ-002 ` +
|
|
164
|
+
`(spec-a2ui-v1-conformance.md) makes this a hard error. Regenerate an ` +
|
|
165
|
+
`existing surface with renderer.beginSurfaceUpdate(surfaceId) → applyTo ` +
|
|
166
|
+
`→ commitSurfaceUpdate instead (spec-a2ui-surface-lifecycle.md).`
|
|
167
|
+
);
|
|
168
|
+
err.code = 'DUPLICATE_SURFACE_ID';
|
|
169
|
+
err.surfaceId = surfaceId;
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
89
172
|
|
|
90
173
|
const root = document.createElement('div');
|
|
91
174
|
root.setAttribute('data-a2ui-surface', surfaceId);
|
|
@@ -106,6 +189,19 @@ export class A2UIRenderer {
|
|
|
106
189
|
// Populated by #updateStyles; cleared by #removeStyles / #deleteSurface.
|
|
107
190
|
// See .claude/docs/specs/genui-css-channel.md §5.4.
|
|
108
191
|
adoptedSheets: new Map(),
|
|
192
|
+
// Surface lifecycle (spec-a2ui-surface-lifecycle.md, ADR-0061,
|
|
193
|
+
// gh#1364): pending/stale/healing state machine, one per real
|
|
194
|
+
// (createSurface'd) surface. `synthetic: false` marks that the
|
|
195
|
+
// lifecycle contract applies — see #beginSurfaceUpdate below.
|
|
196
|
+
synthetic: false,
|
|
197
|
+
lifecycle: {
|
|
198
|
+
state: 'empty',
|
|
199
|
+
generationId: null,
|
|
200
|
+
buffer: null,
|
|
201
|
+
mode: 'replace',
|
|
202
|
+
reason: null,
|
|
203
|
+
hadError: false,
|
|
204
|
+
},
|
|
109
205
|
});
|
|
110
206
|
}
|
|
111
207
|
|
|
@@ -125,6 +221,14 @@ export class A2UIRenderer {
|
|
|
125
221
|
bindings: new Map(),
|
|
126
222
|
// CSS channel parity with #createSurface (see spec §5.4).
|
|
127
223
|
adoptedSheets: new Map(),
|
|
224
|
+
// Surface lifecycle (gh#1364): synthetic surfaces share the
|
|
225
|
+
// renderer's #container as their root — there is no per-surface
|
|
226
|
+
// element to carry per-surface lifecycle state, so they are
|
|
227
|
+
// outside the lifecycle contract by ruling
|
|
228
|
+
// (spec-a2ui-surface-lifecycle.md's state-machine section).
|
|
229
|
+
// #beginSurfaceUpdate no-ops with a console warn for these.
|
|
230
|
+
synthetic: true,
|
|
231
|
+
lifecycle: null,
|
|
128
232
|
};
|
|
129
233
|
this.#surfaces.set(surfaceId, surface);
|
|
130
234
|
// CSS channel: tag the container so the @scope wrapper has an anchor
|
|
@@ -414,6 +518,11 @@ export class A2UIRenderer {
|
|
|
414
518
|
#deleteSurface({ surfaceId }) {
|
|
415
519
|
const surface = this.#surfaces.get(surfaceId);
|
|
416
520
|
if (!surface) return;
|
|
521
|
+
// Surface lifecycle (gh#1364): deleteSurface is not a BUFFERABLE_KIND
|
|
522
|
+
// (see #processOne) — it always runs immediately, per spec: "never
|
|
523
|
+
// buffers... tears down immediately, cancelling any pending
|
|
524
|
+
// generation." The whole record (lifecycle included) is deleted below,
|
|
525
|
+
// so any in-flight bracket is simply gone — no separate cancel step.
|
|
417
526
|
this.#wiringEngine?.teardown(surfaceId);
|
|
418
527
|
for (const [id] of surface.elements) this.#elements.delete(id);
|
|
419
528
|
// CSS channel: splice any adopted stylesheets out of the document.
|
|
@@ -428,6 +537,288 @@ export class A2UIRenderer {
|
|
|
428
537
|
this.#surfaces.delete(surfaceId);
|
|
429
538
|
}
|
|
430
539
|
|
|
540
|
+
// ── Surface lifecycle (spec-a2ui-surface-lifecycle.md, ADR-0061, gh#1364) ──
|
|
541
|
+
//
|
|
542
|
+
// Per-surface pending/stale/healing state machine, driven by a public
|
|
543
|
+
// host API: beginSurfaceUpdate → applyTo (buffers) → commitSurfaceUpdate
|
|
544
|
+
// (or abortSurfaceUpdate). Strictly additive (REQ-011): a surface whose
|
|
545
|
+
// begin is never called stays in `empty` forever and every existing
|
|
546
|
+
// call path (process/processStream/applyTo-while-live) behaves exactly
|
|
547
|
+
// as before.
|
|
548
|
+
|
|
549
|
+
#genCounter = 0;
|
|
550
|
+
|
|
551
|
+
#mintGenerationId() {
|
|
552
|
+
this.#genCounter += 1;
|
|
553
|
+
return `g${this.#genCounter}-${Date.now().toString(36)}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#setLifecycleAttr(surface, state) {
|
|
557
|
+
surface.root?.setAttribute?.('data-a2ui-lifecycle', state);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Dispatch a bubbling CustomEvent for a surface lifecycle transition
|
|
561
|
+
// (REQ-010) — surface-pending / surface-committed / surface-aborted.
|
|
562
|
+
// Same fail-silent shape as #dispatchStylesEvent / #dispatchDiagnostic.
|
|
563
|
+
#dispatchLifecycleEvent(target, eventName, detail) {
|
|
564
|
+
const node = target || this.#container;
|
|
565
|
+
if (!node || typeof CustomEvent === 'undefined') return;
|
|
566
|
+
try {
|
|
567
|
+
node.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true, composed: false }));
|
|
568
|
+
} catch {
|
|
569
|
+
// Hard fail-silent — event dispatch should never break the renderer.
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Begin a regeneration bracket for `surfaceId` (REQ-002/006/008).
|
|
575
|
+
* `empty`/`error-empty` → `pending-first`; `live`/`error-stale` →
|
|
576
|
+
* `pending-stale`. A begin on an already-pending surface supersedes the
|
|
577
|
+
* older generation (REQ-008) — its buffer is discarded. No-op (console
|
|
578
|
+
* warn) for a missing or synthetic surface (state-machine ruling).
|
|
579
|
+
*
|
|
580
|
+
* @param {string} surfaceId
|
|
581
|
+
* @param {{generationId?: string, reason?: 'refine'|'revalidate'|'regenerate', mode?: 'replace'|'patch'}} [opts]
|
|
582
|
+
* @returns {string|null} the minted/echoed generationId, or null on no-op
|
|
583
|
+
*/
|
|
584
|
+
beginSurfaceUpdate(surfaceId, { generationId, reason = null, mode = 'replace' } = {}) {
|
|
585
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
586
|
+
if (!surface) {
|
|
587
|
+
console.warn(`A2UI: beginSurfaceUpdate — no such surface "${surfaceId}"`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
if (surface.synthetic) {
|
|
591
|
+
console.warn(
|
|
592
|
+
`A2UI: beginSurfaceUpdate — surfaceId "${surfaceId}" is synthetic ` +
|
|
593
|
+
`(no prior createSurface); lifecycle is a no-op here. Send createSurface first.`
|
|
594
|
+
);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const lc = surface.lifecycle;
|
|
599
|
+
const wasError = lc.state === 'error-empty' || lc.state === 'error-stale';
|
|
600
|
+
const hadContent = lc.state === 'live' || lc.state === 'error-stale';
|
|
601
|
+
const nextState = hadContent ? 'pending-stale' : 'pending-first';
|
|
602
|
+
|
|
603
|
+
lc.state = nextState;
|
|
604
|
+
lc.generationId = typeof generationId === 'string' && generationId ? generationId : this.#mintGenerationId();
|
|
605
|
+
lc.buffer = [];
|
|
606
|
+
lc.mode = mode === 'patch' ? 'patch' : 'replace';
|
|
607
|
+
lc.reason = reason;
|
|
608
|
+
lc.hadError = wasError;
|
|
609
|
+
|
|
610
|
+
this.#setLifecycleAttr(surface, nextState);
|
|
611
|
+
this.#dispatchLifecycleEvent(surface.root, 'surface-pending', {
|
|
612
|
+
surfaceId, generationId: lc.generationId, reason, firstRender: nextState === 'pending-first',
|
|
613
|
+
});
|
|
614
|
+
return lc.generationId;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Apply messages targeting `surfaceId`. While the surface is pending
|
|
619
|
+
* (inside a beginSurfaceUpdate bracket), messages buffer and apply
|
|
620
|
+
* atomically at commitSurfaceUpdate (REQ-003). Outside a bracket,
|
|
621
|
+
* applies immediately — identical to calling process() for that
|
|
622
|
+
* message, so legacy callers that never touch the lifecycle API see no
|
|
623
|
+
* difference (REQ-011).
|
|
624
|
+
*
|
|
625
|
+
* @param {string} surfaceId
|
|
626
|
+
* @param {object|object[]} messages
|
|
627
|
+
*/
|
|
628
|
+
applyTo(surfaceId, messages) {
|
|
629
|
+
const list = Array.isArray(messages) ? messages : [messages];
|
|
630
|
+
for (const msg of list) this.#processOne({ ...msg, surfaceId });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Commit the in-flight generation for `surfaceId`: applies the buffered
|
|
635
|
+
* bracket atomically (REQ-003). `replace` mode (default) sweeps
|
|
636
|
+
* components the new answer no longer declares — the survivor set is
|
|
637
|
+
* the union of component ids across every buffered updateComponents
|
|
638
|
+
* batch (REQ-004); buffered `updateStyles` replace the surface's
|
|
639
|
+
* `adoptedSheets` set the same way (any styleId not re-declared this
|
|
640
|
+
* bracket is dropped). `patch` mode applies with today's upsert-only
|
|
641
|
+
* semantics for both. A commit whose `generationId` doesn't match the
|
|
642
|
+
* current in-flight generation is ignored as stale (REQ-008).
|
|
643
|
+
*
|
|
644
|
+
* `async` (gh#1364 review finding): the buffer replay AWAITS each
|
|
645
|
+
* dispatched message — specifically so a buffered `wireComponents`
|
|
646
|
+
* finishes docking (and `#wiringEngine` is guaranteed initialized)
|
|
647
|
+
* before the sweep runs; every other message kind resolves its await
|
|
648
|
+
* immediately (`#dispatchMessage` returns a bare value for them, not a
|
|
649
|
+
* promise it made you wait on).
|
|
650
|
+
*
|
|
651
|
+
* @param {string} surfaceId
|
|
652
|
+
* @param {string} [generationId] — when passed, must match the surface's
|
|
653
|
+
* current in-flight generation.
|
|
654
|
+
* @returns {Promise<boolean>} true if the commit applied
|
|
655
|
+
*/
|
|
656
|
+
async commitSurfaceUpdate(surfaceId, generationId) {
|
|
657
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
658
|
+
if (!surface || surface.synthetic) {
|
|
659
|
+
console.warn(`A2UI: commitSurfaceUpdate — no such lifecycle surface "${surfaceId}"`);
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
const lc = surface.lifecycle;
|
|
663
|
+
if (!this.#isSurfacePending(surface)) {
|
|
664
|
+
console.warn(`A2UI: commitSurfaceUpdate — surface "${surfaceId}" has no in-flight generation to commit`);
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
if (generationId != null && generationId !== lc.generationId) {
|
|
668
|
+
return false; // REQ-008 — a superseded generationId is ignored, not an error.
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const buffer = lc.buffer || [];
|
|
672
|
+
const committedGenerationId = lc.generationId;
|
|
673
|
+
const healed = lc.hadError;
|
|
674
|
+
|
|
675
|
+
let swept = [];
|
|
676
|
+
let stylesSwept = [];
|
|
677
|
+
if (lc.mode === 'replace') {
|
|
678
|
+
const survivors = new Set();
|
|
679
|
+
let sawComponents = false;
|
|
680
|
+
const survivorStyleIds = new Set();
|
|
681
|
+
let sawStyles = false;
|
|
682
|
+
for (const msg of buffer) {
|
|
683
|
+
const kind = msg.type || msg.messageType;
|
|
684
|
+
if (kind === 'updateComponents') {
|
|
685
|
+
sawComponents = true;
|
|
686
|
+
for (const comp of msg.components || []) {
|
|
687
|
+
if (comp.id != null) survivors.add(comp.id);
|
|
688
|
+
}
|
|
689
|
+
} else if (kind === 'updateStyles') {
|
|
690
|
+
sawStyles = true;
|
|
691
|
+
if (typeof msg.styleId === 'string') survivorStyleIds.add(msg.styleId);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
for (const msg of buffer) await this.#dispatchMessage(msg);
|
|
695
|
+
// Only sweep when this bracket actually declared a component/style
|
|
696
|
+
// set — a bracket carrying only e.g. updateDataModel sweeps
|
|
697
|
+
// neither, since it never named a survivor set at all.
|
|
698
|
+
if (sawComponents) swept = this.#sweepSurvivors(surfaceId, survivors);
|
|
699
|
+
if (sawStyles) stylesSwept = this.#sweepStyles(surfaceId, survivorStyleIds);
|
|
700
|
+
} else {
|
|
701
|
+
for (const msg of buffer) await this.#dispatchMessage(msg);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
lc.state = 'live';
|
|
705
|
+
lc.buffer = null;
|
|
706
|
+
lc.generationId = null;
|
|
707
|
+
lc.reason = null;
|
|
708
|
+
lc.hadError = false;
|
|
709
|
+
|
|
710
|
+
this.#setLifecycleAttr(surface, 'live');
|
|
711
|
+
this.#dispatchLifecycleEvent(surface.root, 'surface-committed', {
|
|
712
|
+
surfaceId, generationId: committedGenerationId, healed, swept, stylesSwept,
|
|
713
|
+
});
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Remove every adoptedSheets entry in `surfaceId` outside
|
|
718
|
+
// `survivorStyleIds` — buffered `updateStyles` replace the surface's
|
|
719
|
+
// style set the same way replace-mode replaces its component set
|
|
720
|
+
// (REQ-004's style-sweep half). Same removal path as #removeStyles,
|
|
721
|
+
// including its `styles-removed` event per dropped styleId.
|
|
722
|
+
#sweepStyles(surfaceId, survivorStyleIds) {
|
|
723
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
724
|
+
if (!surface || !surface.adoptedSheets || surface.adoptedSheets.size === 0) return [];
|
|
725
|
+
const swept = [];
|
|
726
|
+
for (const styleId of [...surface.adoptedSheets.keys()]) {
|
|
727
|
+
if (survivorStyleIds.has(styleId)) continue;
|
|
728
|
+
const entry = surface.adoptedSheets.get(styleId);
|
|
729
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(s => s !== entry.sheet);
|
|
730
|
+
surface.adoptedSheets.delete(styleId);
|
|
731
|
+
this.#dispatchStylesEvent(surface.root, 'styles-removed', { surfaceId, styleId });
|
|
732
|
+
swept.push(styleId);
|
|
733
|
+
}
|
|
734
|
+
return swept;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Remove every element id in `surfaceId`'s element map outside
|
|
738
|
+
// `survivors` — DOM, elements, bindings, #prevProps, and any docked
|
|
739
|
+
// wiring for those ids (existing per-id undock path, surface.js:107-113,
|
|
740
|
+
// reached via WiringEngine#undockIds). REQ-004.
|
|
741
|
+
#sweepSurvivors(surfaceId, survivors) {
|
|
742
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
743
|
+
if (!surface) return [];
|
|
744
|
+
const swept = [];
|
|
745
|
+
for (const id of [...surface.elements.keys()]) {
|
|
746
|
+
if (survivors.has(id)) continue;
|
|
747
|
+
const el = surface.elements.get(id);
|
|
748
|
+
el?.remove();
|
|
749
|
+
surface.elements.delete(id);
|
|
750
|
+
this.#elements.delete(id);
|
|
751
|
+
surface.bindings.delete(id);
|
|
752
|
+
this.#prevProps.delete(id);
|
|
753
|
+
swept.push(id);
|
|
754
|
+
}
|
|
755
|
+
if (swept.length > 0) this.#wiringEngine?.undockIds(surfaceId, swept);
|
|
756
|
+
return swept;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Abort the in-flight generation for `surfaceId` — the old answer (if
|
|
761
|
+
* any) stays visible (REQ-007). `pending-first` → `error-empty`;
|
|
762
|
+
* `pending-stale` → `error-stale`. A superseded `generationId` is
|
|
763
|
+
* ignored (REQ-008), same as commit.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} surfaceId
|
|
766
|
+
* @param {string} [generationId]
|
|
767
|
+
* @param {object} [error]
|
|
768
|
+
* @returns {boolean} true if the abort applied
|
|
769
|
+
*/
|
|
770
|
+
abortSurfaceUpdate(surfaceId, generationId, error) {
|
|
771
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
772
|
+
if (!surface || surface.synthetic) {
|
|
773
|
+
console.warn(`A2UI: abortSurfaceUpdate — no such lifecycle surface "${surfaceId}"`);
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
const lc = surface.lifecycle;
|
|
777
|
+
if (!this.#isSurfacePending(surface)) {
|
|
778
|
+
console.warn(`A2UI: abortSurfaceUpdate — surface "${surfaceId}" has no in-flight generation to abort`);
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
if (generationId != null && generationId !== lc.generationId) {
|
|
782
|
+
return false; // REQ-008 — stale abort ignored
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const abortedGenerationId = lc.generationId;
|
|
786
|
+
const nextState = lc.state === 'pending-first' ? 'error-empty' : 'error-stale';
|
|
787
|
+
|
|
788
|
+
lc.state = nextState;
|
|
789
|
+
lc.buffer = null;
|
|
790
|
+
lc.generationId = null;
|
|
791
|
+
lc.reason = null;
|
|
792
|
+
lc.hadError = true;
|
|
793
|
+
|
|
794
|
+
this.#setLifecycleAttr(surface, nextState);
|
|
795
|
+
this.#dispatchLifecycleEvent(surface.root, 'surface-aborted', {
|
|
796
|
+
surfaceId, generationId: abortedGenerationId, error: error ?? null,
|
|
797
|
+
});
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Read-only lifecycle snapshot for `surfaceId`, or null (missing/synthetic). */
|
|
802
|
+
getLifecycle(surfaceId) {
|
|
803
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
804
|
+
return surface && !surface.synthetic ? surface.lifecycle : null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** True while `surfaceId` has an in-flight generation (pending-first/pending-stale). */
|
|
808
|
+
isPending(surfaceId) {
|
|
809
|
+
const lc = this.getLifecycle(surfaceId);
|
|
810
|
+
return lc?.state === 'pending-first' || lc?.state === 'pending-stale';
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** IDs of every surface currently pending — hosts reflect this as one boolean (a2ui-root's `pending`). */
|
|
814
|
+
get pendingSurfaces() {
|
|
815
|
+
const out = [];
|
|
816
|
+
for (const [id, s] of this.#surfaces) {
|
|
817
|
+
if (!s.synthetic && this.#isSurfacePending(s)) out.push(id);
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
|
|
431
822
|
// ── CSS channel (updateStyles / removeStyles) ──
|
|
432
823
|
// Spec: .claude/docs/specs/genui-css-channel.md
|
|
433
824
|
// Implements Phase 1 of the gen-ui parallel-channels initiative.
|
|
@@ -676,6 +1067,24 @@ export class A2UIRenderer {
|
|
|
676
1067
|
}
|
|
677
1068
|
}
|
|
678
1069
|
|
|
1070
|
+
// Dispatch a bubbling CustomEvent for a non-fatal protocol diagnostic
|
|
1071
|
+
// (REQ-002's duplicate-surfaceId case today). Same fail-silent shape as
|
|
1072
|
+
// #dispatchStylesEvent but a distinct, generically-named method — this
|
|
1073
|
+
// has nothing to do with the CSS channel.
|
|
1074
|
+
#dispatchDiagnostic(target, eventName, detail) {
|
|
1075
|
+
const node = target || this.#container;
|
|
1076
|
+
if (!node || typeof CustomEvent === 'undefined') return;
|
|
1077
|
+
try {
|
|
1078
|
+
node.dispatchEvent(new CustomEvent(eventName, {
|
|
1079
|
+
detail,
|
|
1080
|
+
bubbles: true,
|
|
1081
|
+
composed: false,
|
|
1082
|
+
}));
|
|
1083
|
+
} catch {
|
|
1084
|
+
// Hard fail-silent — event dispatch should never break the renderer.
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
679
1088
|
// ── Public ──
|
|
680
1089
|
|
|
681
1090
|
getSurface(id) { return this.#surfaces.get(id); }
|
package/stream.js
CHANGED
|
@@ -145,12 +145,20 @@ export function mcpStream(url, options = {}) {
|
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Helper: extract A2UI messages from a JSON-RPC `result.content` array
|
|
148
|
-
* and push them into a queue/resolver pair.
|
|
148
|
+
* and push them into a queue/resolver pair. Exported (underscore-prefixed,
|
|
149
|
+
* still internal) so stream.test.js can exercise the MIME-flip logic
|
|
150
|
+
* directly without standing up a fake MCP transport.
|
|
149
151
|
*/
|
|
150
|
-
function _extractA2UIFromRpc(rpc, push) {
|
|
152
|
+
export function _extractA2UIFromRpc(rpc, push) {
|
|
151
153
|
if (rpc.result?.content) {
|
|
152
154
|
for (const block of rpc.result.content) {
|
|
153
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
block.type === 'resource' &&
|
|
157
|
+
// v1.0 Candidate MIME (REQ-001); the pre-Candidate form is still
|
|
158
|
+
// accepted on read for the transition (spec-a2ui-v1-conformance).
|
|
159
|
+
(block.resource?.mimeType === 'application/a2ui+json' ||
|
|
160
|
+
block.resource?.mimeType === 'application/json+a2ui')
|
|
161
|
+
) {
|
|
154
162
|
try {
|
|
155
163
|
const messages = JSON.parse(block.resource.text);
|
|
156
164
|
for (const msg of (Array.isArray(messages) ? messages : [messages])) push(msg);
|
package/wiring-engine.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ export declare class WiringEngine {
|
|
|
44
44
|
*/
|
|
45
45
|
teardown(surfaceId: string): void;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Undock specific dockable ids for a surface without tearing down the
|
|
49
|
+
* whole surface (called by the renderer's replace-mode lifecycle commit
|
|
50
|
+
* sweep — spec-a2ui-surface-lifecycle.md REQ-004, gh#1364).
|
|
51
|
+
*/
|
|
52
|
+
undockIds(surfaceId: string, ids: string[]): void;
|
|
53
|
+
|
|
47
54
|
/**
|
|
48
55
|
* Tear down all wired surfaces.
|
|
49
56
|
*/
|
package/wiring-engine.js
CHANGED
|
@@ -93,6 +93,21 @@ export class WiringEngine {
|
|
|
93
93
|
this.#surfaces.delete(surfaceId);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Undock specific dockable ids for a surface without tearing down the
|
|
98
|
+
* whole surface — used by the renderer's replace-mode commit sweep
|
|
99
|
+
* (spec-a2ui-surface-lifecycle.md REQ-004, gh#1364): swept component ids
|
|
100
|
+
* lose their wiring too, but the surface itself (and its other docked
|
|
101
|
+
* dockables) survive the commit.
|
|
102
|
+
* @param {string} surfaceId
|
|
103
|
+
* @param {string[]} ids
|
|
104
|
+
*/
|
|
105
|
+
undockIds(surfaceId, ids) {
|
|
106
|
+
const surface = this.#surfaces.get(surfaceId);
|
|
107
|
+
if (!surface) return;
|
|
108
|
+
surface.undockMany(ids);
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* Tear down everything.
|
|
98
113
|
*/
|