@ggui-ai/iframe-runtime 0.1.0-rc.1 → 0.2.0-alpha.1
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 +7 -6
- package/dist/channel-transport.d.ts +15 -15
- package/dist/channel-transport.js +32 -35
- package/dist/channels/channel-payload.d.ts +2 -2
- package/dist/channels/channel-payload.js +1 -1
- package/dist/channels/data.d.ts +14 -19
- package/dist/channels/data.js +13 -31
- package/dist/channels/drain-ack.d.ts +2 -2
- package/dist/channels/drain-ack.js +1 -1
- package/dist/channels/feedback.d.ts +1 -1
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/index.js +1 -1
- package/dist/channels/props-update.d.ts +33 -37
- package/dist/channels/props-update.js +30 -106
- package/dist/channels/render-frame.d.ts +52 -0
- package/dist/channels/render-frame.js +53 -0
- package/dist/channels/system.d.ts +1 -1
- package/dist/compiled-validators.d.ts +15 -0
- package/dist/compiled-validators.js +31 -0
- package/dist/context-observer.d.ts +68 -8
- package/dist/context-observer.js +25 -45
- package/dist/events-polling.d.ts +95 -0
- package/dist/events-polling.js +143 -0
- package/dist/gadget-loader.d.ts +1 -1
- package/dist/globals.d.ts +4 -4
- package/dist/host-context-emitter.d.ts +84 -20
- package/dist/host-context-emitter.js +159 -29
- package/dist/iframe-runtime.js +98 -59
- package/dist/index.d.ts +18 -13
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +2 -2
- package/dist/lifecycle.js +5 -5
- package/dist/mcp-app-iframe-host.d.ts +9 -9
- package/dist/mcp-app-iframe-host.js +16 -17
- package/dist/meta-parse.d.ts +44 -0
- package/dist/meta-parse.js +390 -0
- package/dist/observability.d.ts +6 -6
- package/dist/protocol-error.d.ts +7 -7
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +10 -11
- package/dist/react-renderer.js +8 -8
- package/dist/registry-subscribe.d.ts +27 -9
- package/dist/registry-subscribe.js +32 -27
- package/dist/render-item.d.ts +83 -0
- package/dist/{stack-item-renderer.js → render-item.js} +106 -106
- package/dist/runtime.d.ts +183 -131
- package/dist/runtime.js +1264 -1330
- package/dist/status-dom.d.ts +52 -47
- package/dist/status-dom.js +51 -34
- package/dist/system-cards/NoCredentialsCard.d.ts +1 -1
- package/dist/system-cards/NoCredentialsCard.js +1 -1
- package/dist/system-cards/ProtocolProbeCard.js +2 -2
- package/dist/system-cards/index.d.ts +4 -4
- package/dist/system-cards/index.js +4 -4
- package/dist/types.d.ts +70 -34
- package/dist/types.js +1 -1
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +2 -2
- package/dist/wire-config.d.ts +54 -82
- package/dist/wire-config.js +61 -93
- package/package.json +9 -8
- package/dist/bootstrap.d.ts +0 -72
- package/dist/bootstrap.js +0 -500
- package/dist/canvas/animator/animator-host.d.ts +0 -63
- package/dist/canvas/animator/animator-host.js +0 -105
- package/dist/canvas/animator/animator-pill.d.ts +0 -42
- package/dist/canvas/animator/animator-pill.js +0 -119
- package/dist/canvas/animator/state-machine.d.ts +0 -106
- package/dist/canvas/animator/state-machine.js +0 -150
- package/dist/canvas/canvas-shell.d.ts +0 -77
- package/dist/canvas/canvas-shell.js +0 -117
- package/dist/canvas/display-mode-policy.d.ts +0 -104
- package/dist/canvas/display-mode-policy.js +0 -98
- package/dist/canvas/mount.d.ts +0 -150
- package/dist/canvas/mount.js +0 -202
- package/dist/canvas/nav-stack.d.ts +0 -133
- package/dist/canvas/nav-stack.js +0 -172
- package/dist/channels/push.d.ts +0 -47
- package/dist/channels/push.js +0 -54
- package/dist/stack-item-renderer.d.ts +0 -103
- package/dist/stack.d.ts +0 -90
- package/dist/stack.js +0 -118
package/dist/runtime.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { parseMetaFromGlobal, parseMetaFromToolResult, } from './meta-parse.js';
|
|
2
|
+
import { projectHostContext } from '@ggui-ai/protocol';
|
|
3
|
+
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
4
|
+
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
|
|
4
5
|
import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
5
6
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
6
|
-
import { ChannelRegistry } from '@ggui-ai/channel
|
|
7
|
-
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createFeedbackHandler, createPropsUpdateHandler,
|
|
7
|
+
import { ChannelRegistry } from '@ggui-ai/live-channel';
|
|
8
|
+
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createFeedbackHandler, createPropsUpdateHandler, createRenderHandler, createSystemHandler, } from './channels/index.js';
|
|
8
9
|
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
9
|
-
import {
|
|
10
|
+
import { buildEventsPolling } from './events-polling.js';
|
|
11
|
+
import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
|
|
10
12
|
import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
+
import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
|
|
14
|
+
import { mountRender, } from './render-item.js';
|
|
13
15
|
import { fromBootstrapFailure, } from './protocol-error.js';
|
|
14
16
|
import { postObservabilityToParent, } from './observability.js';
|
|
15
17
|
import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
|
|
@@ -23,6 +25,19 @@ import { contextSlotLastValues, createContextStateHost, installContextRegistry,
|
|
|
23
25
|
// substitute via esbuild `define` if a non-package value is wanted.
|
|
24
26
|
// =============================================================================
|
|
25
27
|
const RENDERER_VERSION = '0.1.0';
|
|
28
|
+
/**
|
|
29
|
+
* How long the spec-canonical postMessage tier (Tier 3 in
|
|
30
|
+
* `bootSequence`'s resolver chain + the autostart resolver's race
|
|
31
|
+
* against `bootProduction`) waits for an inbound
|
|
32
|
+
* `ui/notifications/tool-result` notification before falling through.
|
|
33
|
+
*
|
|
34
|
+
* 30 seconds chosen to span the slowest spec-compliant hosts that
|
|
35
|
+
* post the tool result LATE (after their `ui/initialize` response
|
|
36
|
+
* resolves + after the tool call round-trips through the LLM
|
|
37
|
+
* provider). Faster hosts return immediately on arrival; the timeout
|
|
38
|
+
* only fires when the host never posts at all.
|
|
39
|
+
*/
|
|
40
|
+
const POSTMESSAGE_BOOT_TIMEOUT_MS = 30_000;
|
|
26
41
|
const drainAckListeners = new Set();
|
|
27
42
|
export function subscribeDrainAck(listener) {
|
|
28
43
|
drainAckListeners.add(listener);
|
|
@@ -41,56 +56,106 @@ function dispatchDrainAck(payload) {
|
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
}
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
|
|
61
|
+
// JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
|
|
62
|
+
// `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
|
|
63
|
+
// for `tools/call` and a `installPostMountListener` for inbound
|
|
64
|
+
// `ui/notifications/tool-result`). Every one of those was a hand-rolled
|
|
65
|
+
// reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
|
|
66
|
+
// `App` class already ships — drift across them produced the Reading-B
|
|
67
|
+
// vs spec-canonical schism we spent the postMessage tier-3 resolver
|
|
68
|
+
// patching around. Post-1.19b.3 every host-iframe message hops through
|
|
69
|
+
// one `App` instance.
|
|
70
|
+
//
|
|
71
|
+
// Two-piece construction:
|
|
72
|
+
// - `createDefaultApp` builds an `App` + `PostMessageTransport` against
|
|
73
|
+
// `window.parent`. Production wires this; tests inject their own.
|
|
74
|
+
// - `connectApp` runs `App.connect(transport)` and surfaces failures
|
|
75
|
+
// as the typed `ConnectAppResult` discriminated union (since App
|
|
76
|
+
// throws and we want bootSequence's `UI_INITIALIZE_FAILED` envelope
|
|
77
|
+
// to surface a string `message` without `instanceof` gymnastics).
|
|
78
|
+
// =============================================================================
|
|
79
|
+
const APP_INFO = { name: 'ggui-iframe-runtime', version: RENDERER_VERSION };
|
|
80
|
+
/**
|
|
81
|
+
* Available display modes the iframe-runtime supports — declared on
|
|
82
|
+
* `ui/initialize` so spec-compliant hosts know `ui/request-display-mode`
|
|
83
|
+
* requests for these values are honored. The runtime emits
|
|
84
|
+
* `ui/request-display-mode` from the `Element.requestFullscreen`
|
|
85
|
+
* interceptor + the canvas-mode display-mode escalation policy; both
|
|
86
|
+
* target this enum.
|
|
87
|
+
*/
|
|
88
|
+
const APP_CAPABILITIES = {
|
|
89
|
+
availableDisplayModes: ['inline', 'fullscreen', 'pip'],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Build the default production `App` + `PostMessageTransport` pair
|
|
93
|
+
* targeting `window.parent`. Tests inject their own; production calls
|
|
94
|
+
* this lazily so test files that import this module without running
|
|
95
|
+
* the autostart side-effect don't construct an iframe-bound App.
|
|
96
|
+
*/
|
|
97
|
+
function createDefaultApp() {
|
|
98
|
+
const app = new App(APP_INFO, APP_CAPABILITIES, { autoResize: true });
|
|
99
|
+
const transport = new PostMessageTransport(window.parent, window.parent);
|
|
100
|
+
return { app, transport };
|
|
101
|
+
}
|
|
44
102
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
103
|
+
* Module-level connected App handle. Set by `bootProduction` /
|
|
104
|
+
* `bootSelfContained` once `app.connect()` resolves; consumed by the
|
|
105
|
+
* outbound dispatch path (`tools/call` via `app.callServerTool`) so
|
|
106
|
+
* spec-compliant frame routing is used in place of the legacy
|
|
107
|
+
* hand-rolled JSON-RPC pump.
|
|
50
108
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* per-call.
|
|
109
|
+
* `null` between the moment the module loads and the moment one of
|
|
110
|
+
* the boot paths assigns it — `dispatchWiredAction`'s callsites are
|
|
111
|
+
* gated on this, so calls fired pre-boot drop with a console warning.
|
|
55
112
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
let currentApp = null;
|
|
114
|
+
/**
|
|
115
|
+
* Assign the module-level App handle. Replaces any previous handle —
|
|
116
|
+
* production runs bootSequence exactly once per iframe lifecycle, so
|
|
117
|
+
* a replace only happens in tests that reuse the module across
|
|
118
|
+
* scenarios (and want each spec to bind its own App).
|
|
119
|
+
*
|
|
120
|
+
* @internal — production callers are the boot paths
|
|
121
|
+
* (`bootProduction`, `bootSelfContained`); tests inject directly to
|
|
122
|
+
* drive outbound `tools/call` through a `MockTransport`-bound App
|
|
123
|
+
* without invoking the full boot pipeline.
|
|
124
|
+
*/
|
|
125
|
+
export function setCurrentApp(app) {
|
|
126
|
+
currentApp = app;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Read the connected App handle. Returns `null` when neither boot
|
|
130
|
+
* path has run yet. Outbound dispatch sites that depend on it MUST
|
|
131
|
+
* handle the null case (typically by logging + dropping the call).
|
|
132
|
+
*/
|
|
133
|
+
function getCurrentApp() {
|
|
134
|
+
return currentApp;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* @internal — exported for unit tests to reset module state between
|
|
138
|
+
* scenarios.
|
|
139
|
+
*/
|
|
140
|
+
export function __resetAppForTest() {
|
|
141
|
+
currentApp = null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Connect an App over its transport; map any thrown error to the
|
|
145
|
+
* `ConnectAppResult` shape so callers don't need an `instanceof Error`
|
|
146
|
+
* dance to fill `UI_INITIALIZE_FAILED`.
|
|
147
|
+
*/
|
|
148
|
+
async function connectApp(app, transport) {
|
|
149
|
+
try {
|
|
150
|
+
await app.connect(transport);
|
|
151
|
+
return { ok: true };
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
message: err instanceof Error ? err.message : String(err),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
94
159
|
}
|
|
95
160
|
/**
|
|
96
161
|
* Notify the parent that the renderer is alive + ready to receive
|
|
@@ -123,8 +188,9 @@ function postBootFailure(reason, message) {
|
|
|
123
188
|
}
|
|
124
189
|
}
|
|
125
190
|
export async function bootSequence(opts) {
|
|
126
|
-
const { doc,
|
|
191
|
+
const { doc, app, transport, notifyParent, renderer: rendererHooks, onProtocolError, onObserve, onLifecycle } = opts;
|
|
127
192
|
const connectFn = opts.connectFn ?? connectViaRegistry;
|
|
193
|
+
const toolResultTimeoutMs = opts.toolResultTimeoutMs ?? POSTMESSAGE_BOOT_TIMEOUT_MS;
|
|
128
194
|
// Emit typed {@link ProtocolError} for every bootstrap-failure site
|
|
129
195
|
// that surfaces a `RendererBootFailedMessage`. The narrow
|
|
130
196
|
// postMessage envelope stays (parent compatibility); the typed
|
|
@@ -150,16 +216,12 @@ export async function bootSequence(opts) {
|
|
|
150
216
|
onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
|
|
151
217
|
};
|
|
152
218
|
const refs = ensureStatusDom(doc);
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
let stackModel = new StackModel();
|
|
160
|
-
// Initial empty-placeholder render via the canonical helper.
|
|
161
|
-
// `refreshStackDom` is the public seam handlers use after fold.
|
|
162
|
-
refreshStackDom(refs, stackModel);
|
|
219
|
+
// The mounted render is established after the first ack lands —
|
|
220
|
+
// populated by `applyAck` below + read back by every channel handler
|
|
221
|
+
// that needs `propsSpec` / `streamSpec`. Tracked as a closure-scoped
|
|
222
|
+
// ref so the failure-path returns can carry the same value through
|
|
223
|
+
// `BootSequenceResult`.
|
|
224
|
+
let mountedRender = null;
|
|
163
225
|
setStatus(refs, 'Negotiating with host…', 'connecting');
|
|
164
226
|
notifyParent({ type: 'ggui:renderer-ready', version: RENDERER_VERSION });
|
|
165
227
|
// Lifecycle `mounting` — the renderer is alive + status DOM is up,
|
|
@@ -168,223 +230,303 @@ export async function bootSequence(opts) {
|
|
|
168
230
|
// re-emission is the host's concern (per protocol, host treats
|
|
169
231
|
// duplicate same-state envelopes as a no-op).
|
|
170
232
|
onLifecycle?.(makeLifecycleEvent('mounting'));
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
233
|
+
// Install the spec-canonical toolresult listener on App BEFORE
|
|
234
|
+
// calling `app.connect(transport)`. App's `_assertHandlerTiming`
|
|
235
|
+
// warns if the first handler for a one-shot event registers AFTER
|
|
236
|
+
// the `ui/initialize`→`ui/notifications/initialized` handshake
|
|
237
|
+
// completes (the host may have already fired the notification by
|
|
238
|
+
// then). Registering early is the spec-canonical answer.
|
|
239
|
+
//
|
|
240
|
+
// The Promise resolves on the FIRST inbound `ui/notifications/tool-result`
|
|
241
|
+
// notification carrying valid `_meta["ai.ggui/render"]`. The race
|
|
242
|
+
// versus the synchronous `__GGUI_META__` tier is resolved AFTER the
|
|
243
|
+
// App handshake settles — whichever yields first wins; the listener
|
|
244
|
+
// is removed on resolve.
|
|
245
|
+
//
|
|
246
|
+
// Skipped entirely when `preResolvedMeta` is set (autostart caught
|
|
247
|
+
// the toolresult or read the global early) — no listener installed,
|
|
248
|
+
// no race to run.
|
|
249
|
+
const toolResultPromise = opts.preResolvedMeta !== undefined
|
|
250
|
+
? Promise.resolve(null)
|
|
251
|
+
: awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
|
|
252
|
+
const initResult = await connectApp(app, transport);
|
|
253
|
+
if (!initResult.ok) {
|
|
254
|
+
setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
|
|
255
|
+
emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
|
|
256
|
+
return { ok: false, mountedRender };
|
|
257
|
+
}
|
|
258
|
+
// Connected — expose the App on the module-level slot so outbound
|
|
259
|
+
// `tools/call` (dispatchWiredAction, channel-transport router)
|
|
260
|
+
// routes through `app.callServerTool` instead of the legacy raw
|
|
261
|
+
// postMessage pump. Idempotent: re-boot with the same App is a
|
|
262
|
+
// no-op; a different App throws (the iframe is single-tenant).
|
|
263
|
+
setCurrentApp(app);
|
|
264
|
+
// Slice-meta resolution — spec-canonical primary, no in-house
|
|
265
|
+
// Reading-B (retired Phase 1.19b.3 with the App-class swap; the
|
|
266
|
+
// McpUiInitializeResult schema doesn't define `toolOutput`).
|
|
267
|
+
//
|
|
268
|
+
// Tier 0 preResolvedMeta — autostart-layer pre-resolution. When
|
|
269
|
+
// the autostart's own toolresult race or inline `__GGUI_META__`
|
|
270
|
+
// parse caught the meta before bootSequence ran, it threads
|
|
271
|
+
// the parsed slice meta here so we don't re-await the same
|
|
272
|
+
// delivery. Skips every tier below.
|
|
273
|
+
//
|
|
274
|
+
// Tier 1 parseMetaFromGlobal — synchronous `__GGUI_META__` inline
|
|
275
|
+
// global. Self-contained shells (`buildSelfContainedHtml`,
|
|
276
|
+
// per-session resource shells) populate this before this
|
|
277
|
+
// bundle's `<script type="module">` evaluates.
|
|
278
|
+
// Opportunistic — its absence (the common case for
|
|
279
|
+
// postMessage-delivered hosts) never surfaces as a parse
|
|
280
|
+
// failure to the caller.
|
|
281
|
+
//
|
|
282
|
+
// Tier 2 spec-canonical `ui/notifications/tool-result` — observed
|
|
283
|
+
// by App via `addEventListener('toolresult', …)`. The ONLY
|
|
284
|
+
// remaining post-handshake path, used by every spec-strict
|
|
285
|
+
// host (`<AppRenderer>`, ChatGPT MCP-Apps connector,
|
|
286
|
+
// claude.ai post-spec, and any future host that issues
|
|
287
|
+
// `ui/initialize` without echoing `toolOutput`).
|
|
288
|
+
//
|
|
289
|
+
// When both tiers fail, we surface the Tier 2 timeout/failure reason
|
|
290
|
+
// (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
|
|
291
|
+
// and its absence is the diagnostic worth showing.
|
|
292
|
+
let parsed;
|
|
293
|
+
if (opts.preResolvedMeta !== undefined) {
|
|
294
|
+
parsed = { ok: true, meta: opts.preResolvedMeta };
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const inline = parseMetaFromGlobal();
|
|
298
|
+
if (inline.ok) {
|
|
299
|
+
parsed = inline;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const fromToolResult = await toolResultPromise;
|
|
303
|
+
if (fromToolResult !== null) {
|
|
304
|
+
parsed = { ok: true, meta: fromToolResult };
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
parsed = {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: 'MISSING_META_GGUI_BOOTSTRAP',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// hostContext is captured opportunistically from
|
|
315
|
+
// `app.getHostContext()` — populated by App's `ui/initialize`
|
|
316
|
+
// response capture + kept fresh by the `hostcontextchanged`
|
|
317
|
+
// notification handler App ships internally. Apply the RAW context
|
|
318
|
+
// to the iframe DOM via the spec-canonical ext-apps helpers (theme
|
|
319
|
+
// + style variables + fonts) regardless of whether projection
|
|
320
|
+
// picked up new fields. Projection drops theme / styles (they live
|
|
321
|
+
// in ggui's own theming pipeline); the DOM-apply path is what
|
|
322
|
+
// surfaces them to LLM-generated UI so host-native primitives
|
|
323
|
+
// render consistently with the rest of chat.
|
|
324
|
+
const rawHostContext = app.getHostContext();
|
|
325
|
+
if (rawHostContext !== undefined) {
|
|
326
|
+
applyHostContextStyling(rawHostContext);
|
|
327
|
+
}
|
|
328
|
+
if (parsed.ok && parsed.hostContext === undefined) {
|
|
329
|
+
const hostContext = projectHostContext(rawHostContext);
|
|
330
|
+
if (hostContext !== undefined) {
|
|
331
|
+
parsed = { ...parsed, hostContext };
|
|
332
|
+
}
|
|
177
333
|
}
|
|
178
|
-
const parsed = parseBootstrap(initResp.result);
|
|
179
334
|
if (!parsed.ok) {
|
|
180
|
-
const message = `
|
|
335
|
+
const message = `slice-meta parse failed: ${parsed.reason}`;
|
|
181
336
|
setStatus(refs, message, 'error');
|
|
182
337
|
emitBootFailure(parsed.reason, message);
|
|
183
|
-
return { ok: false,
|
|
338
|
+
return { ok: false, mountedRender };
|
|
184
339
|
}
|
|
340
|
+
// Single render slice — post-Phase-B the wire merged the
|
|
341
|
+
// `ai.ggui/session` + `ai.ggui/stack-item` pair into one
|
|
342
|
+
// `ai.ggui/render` slice. The parser surfaces it directly on
|
|
343
|
+
// `parsed.meta`.
|
|
344
|
+
const meta = parsed.meta;
|
|
185
345
|
// Install the precompiled, eval-free contract validators shipped on
|
|
186
346
|
// the bootstrap BEFORE any wire traffic is validated. Self-contained
|
|
187
347
|
// ESM modules — no `__ggui__` dependency, so this need not wait for
|
|
188
348
|
// `installGlobalRegistry`. A failed load leaves the validation seam
|
|
189
349
|
// to fall back to in-iframe compilation (CSP-blocked, but no worse
|
|
190
350
|
// than pre-A4).
|
|
191
|
-
setActiveValidatorSet(await
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Late-bound send surface for canvas's
|
|
210
|
-
// outbound `canvas_navigated` envelopes. The triad's `manager`
|
|
211
|
-
// (constructed inside triadWiring.setup below) is the producer; we
|
|
212
|
-
// capture it here so the canvas callbacks — installed at mount time
|
|
213
|
-
// BEFORE setup — can dispatch through the buffered shim once the
|
|
214
|
-
// WS is up. Calls during the bind gap silently no-op (the user
|
|
215
|
-
// can't physically click back-arrow before the canvas is rendered).
|
|
216
|
-
let canvasOutboundSend = null;
|
|
217
|
-
if (parsed.bootstrap.canvasMode === true && opts.canvasMount !== undefined) {
|
|
218
|
-
canvasMountHandle = opts.canvasMount({
|
|
219
|
-
mountTarget: refs.stack,
|
|
220
|
-
callbacks: {
|
|
221
|
-
// Back-gesture → live-channel
|
|
222
|
-
// `canvas_navigated` outbound. The server updates
|
|
223
|
-
// `session.activeStackItemId` so ggui_consume's active-pipe
|
|
224
|
-
// resolution stays aligned with the user's nav focus.
|
|
225
|
-
onBackGesture: ({ previousActiveItemId, activeItemId }) => {
|
|
226
|
-
canvasOutboundSend?.({
|
|
227
|
-
type: 'canvas_navigated',
|
|
228
|
-
payload: {
|
|
229
|
-
sessionId: parsed.bootstrap.sessionId,
|
|
230
|
-
previousActiveItemId,
|
|
231
|
-
activeItemId,
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
},
|
|
235
|
-
// Display-mode escalation → spec
|
|
236
|
-
// postMessage to the host. The escalation policy decides
|
|
237
|
-
// which mode to request based on host capabilities + content
|
|
238
|
-
// state;
|
|
239
|
-
// this just forwards the decision via the existing
|
|
240
|
-
// `requestDisplayModeInParent` helper (paired audit
|
|
241
|
-
// envelope fires automatically). Hosts that don't speak the
|
|
242
|
-
// spec drop the postMessage silently, so the request is
|
|
243
|
-
// safe to fire unconditionally — the policy already gates
|
|
244
|
-
// on `availableDisplayModes`.
|
|
245
|
-
onRequestDisplayMode: (mode) => {
|
|
246
|
-
requestDisplayModeInParent({
|
|
247
|
-
toolName: 'ggui_canvas_shell',
|
|
248
|
-
mode,
|
|
249
|
-
sessionId: parsed.bootstrap.sessionId,
|
|
250
|
-
appId: parsed.bootstrap.appId,
|
|
251
|
-
});
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
renderIntoEl = canvasMountHandle.contentEl;
|
|
256
|
-
}
|
|
257
|
-
// Triad wiring — when supplied, the handler routes frames through
|
|
258
|
-
// StackRenderer + WireConfig + StreamBus. When absent, the placeholder
|
|
259
|
-
// path runs (boot.test.ts relies on the latter to keep its import
|
|
260
|
-
// graph tiny). The triad's channelRegistry is the dispatch surface
|
|
261
|
-
// for every WS frame; `connectFn` registers handshake handlers on
|
|
262
|
-
// top of the existing registry then binds the transport.
|
|
263
|
-
// When canvas-mode is active, build the
|
|
264
|
-
// lifecycle-payload sink that translates wire-shape
|
|
265
|
-
// `CanvasLifecyclePayload` envelopes into the animator-event
|
|
266
|
-
// shape and publishes on the mount's event bus. The triad's `data`
|
|
267
|
-
// channel handler invokes this for every envelope on the reserved
|
|
268
|
-
// `_ggui:lifecycle` channel.
|
|
269
|
-
const canvasLifecycleSink = canvasMountHandle !== null
|
|
270
|
-
? canvasMountHandle.events.publishLifecycle.bind(canvasMountHandle.events)
|
|
271
|
-
: undefined;
|
|
272
|
-
const triad = triadWiring !== undefined
|
|
273
|
-
? triadWiring.setup({
|
|
274
|
-
bootstrap: parsed.bootstrap,
|
|
275
|
-
stackModel,
|
|
276
|
-
renderInto: renderIntoEl,
|
|
351
|
+
setActiveValidatorSet(await loadCompiledValidatorsFromUrl(meta.validatorsUrl));
|
|
352
|
+
// Pin — this iframe binds to exactly one render id for its lifetime
|
|
353
|
+
// (post-render-identity-collapse each iframe = one mounted render,
|
|
354
|
+
// per [[kill-displaymode-divergence]]). The bootstrap's `renderId`
|
|
355
|
+
// is the pin; the render handler drops any frame addressed
|
|
356
|
+
// elsewhere.
|
|
357
|
+
const pinnedRenderId = meta.renderId;
|
|
358
|
+
// Renderer wiring — when supplied, the handler routes frames through
|
|
359
|
+
// the single-render mount surface + WireConfig + StreamBus. When
|
|
360
|
+
// absent, the placeholder path runs (boot.test.ts relies on the
|
|
361
|
+
// latter to keep its import graph tiny). The renderer's
|
|
362
|
+
// channelRegistry is the dispatch surface for every WS frame;
|
|
363
|
+
// `connectFn` registers handshake handlers on top of the existing
|
|
364
|
+
// registry then binds the transport.
|
|
365
|
+
const renderer = rendererHooks !== undefined
|
|
366
|
+
? rendererHooks.setup({
|
|
367
|
+
meta,
|
|
368
|
+
renderInto: refs.stack,
|
|
277
369
|
statusRefs: refs,
|
|
278
370
|
...(onObserve !== undefined ? { onObserve } : {}),
|
|
279
|
-
...(canvasLifecycleSink !== undefined
|
|
280
|
-
? { onCanvasLifecycle: canvasLifecycleSink }
|
|
281
|
-
: {}),
|
|
282
371
|
})
|
|
283
372
|
: null;
|
|
284
|
-
setStatus(refs, `Connecting to ${
|
|
285
|
-
// Boot-without-
|
|
373
|
+
setStatus(refs, `Connecting to ${meta.renderId}…`, 'connecting');
|
|
374
|
+
// Boot-without-renderer path: we still need a ChannelRegistry to
|
|
286
375
|
// receive frames, because the registry is the only dispatch
|
|
287
|
-
// surface. Build a minimal one with just the `
|
|
288
|
-
// handler so non-
|
|
289
|
-
//
|
|
290
|
-
|
|
376
|
+
// surface. Build a minimal one with just the `render` placeholder
|
|
377
|
+
// handler so non-renderer consumers (boot.test.ts) can observe
|
|
378
|
+
// bootstrap-orchestration outcomes without paying React import
|
|
379
|
+
// cost. The handler logs status but does not mount React.
|
|
380
|
+
const placeholderRegistry = renderer === null
|
|
291
381
|
? createPlaceholderRegistry({
|
|
292
|
-
|
|
293
|
-
stackModel,
|
|
382
|
+
meta,
|
|
294
383
|
statusRefs: refs,
|
|
384
|
+
pinnedRenderId,
|
|
295
385
|
})
|
|
296
386
|
: null;
|
|
297
|
-
const activeRegistry =
|
|
298
|
-
// Bind the canvas's outbound send surface to
|
|
299
|
-
// the triad's buffered-send shim. The shim queues frames pre-WS;
|
|
300
|
-
// they flush automatically once `attachManager` swaps in the real
|
|
301
|
-
// socket. No need to await — the canvas's back-arrow won't fire
|
|
302
|
-
// until the user can physically click, which requires render +
|
|
303
|
-
// layout that always come after this binding.
|
|
304
|
-
if (canvasMountHandle !== null && triad !== null) {
|
|
305
|
-
canvasOutboundSend = (msg) => triad.manager.send(msg);
|
|
306
|
-
}
|
|
307
|
-
// Subscribe the canvas mount to local
|
|
308
|
-
// host-context-emitter updates so the display-mode escalation
|
|
309
|
-
// policy reconciles against the latest host capabilities. Fires
|
|
310
|
-
// once on
|
|
311
|
-
// subscribe with the seeded projection (if any) AND on every
|
|
312
|
-
// `host-context-changed` notification. Captures the local handle
|
|
313
|
-
// so the subscription is scoped to this boot pass — re-mounts
|
|
314
|
-
// would re-subscribe.
|
|
315
|
-
if (canvasMountHandle !== null) {
|
|
316
|
-
const localHandle = canvasMountHandle;
|
|
317
|
-
const unsubscribeHostContext = subscribeHostContextLocal((projection) => {
|
|
318
|
-
localHandle.setAvailableDisplayModes(projection.availableDisplayModes);
|
|
319
|
-
localHandle.setDisplayMode(projection.currentDisplayMode);
|
|
320
|
-
});
|
|
321
|
-
localHandle.registerCleanup(unsubscribeHostContext);
|
|
322
|
-
}
|
|
387
|
+
const activeRegistry = renderer?.channelRegistry ?? placeholderRegistry;
|
|
323
388
|
/**
|
|
324
|
-
* Apply an ack's
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* by:
|
|
389
|
+
* Apply an ack's render snapshot to the runtime — when the ack
|
|
390
|
+
* carries a render matching `pinnedRenderId`, mount it through the
|
|
391
|
+
* renderer. Used by:
|
|
328
392
|
*
|
|
329
393
|
* - The initial bootSequence path (first ack after subscribe).
|
|
330
394
|
* - The WS reconnect-with-rebootstrap path (every subsequent ack
|
|
331
395
|
* when the underlying WSTransport reconnects + re-fires
|
|
332
|
-
* subscribe). A
|
|
333
|
-
* window flows back through here.
|
|
396
|
+
* subscribe). A render or update that landed during the
|
|
397
|
+
* dropout window flows back through here.
|
|
334
398
|
*
|
|
335
|
-
* Idempotent on identical inputs — `
|
|
336
|
-
*
|
|
337
|
-
*
|
|
399
|
+
* Idempotent on identical inputs — `applyRender` patches in place
|
|
400
|
+
* via {@link RenderItemHandle.update} when called with the same
|
|
401
|
+
* render id; `channelTransport.applyRender` is server-side
|
|
402
|
+
* idempotent on the (renderId, channelName) tuple.
|
|
338
403
|
*/
|
|
339
|
-
const
|
|
340
|
-
|
|
404
|
+
const applyAck = async (ackPayload) => {
|
|
405
|
+
const target = ackPayload.render;
|
|
406
|
+
if (target === undefined)
|
|
407
|
+
return;
|
|
408
|
+
if (target.id !== pinnedRenderId) {
|
|
409
|
+
// Server's snapshot is for a different render — likely a stale
|
|
410
|
+
// re-subscribe after the server pruned ours. Nothing to mount.
|
|
341
411
|
return;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// `activeStackItemId` for state it produced. Outbound
|
|
352
|
-
// `canvas_navigated` originates only from the `onBackGesture`
|
|
353
|
-
// callback (user-driven pop). `onNavChange` remains available for
|
|
354
|
-
// future programmatic navigation triggers that originate inside
|
|
355
|
-
// the canvas without a back-gesture.
|
|
356
|
-
canvasMountHandle?.setStack(ackPayload.stack);
|
|
357
|
-
if (triad !== null) {
|
|
358
|
-
await triad.stackRenderer.applyStack(stackModel.snapshot());
|
|
359
|
-
const snapshot = stackModel.snapshot();
|
|
360
|
-
const top = snapshot[snapshot.length - 1];
|
|
361
|
-
if (top !== undefined &&
|
|
362
|
-
top.type !== 'mcpApps' &&
|
|
363
|
-
top.type !== 'system') {
|
|
364
|
-
triad.channelTransport.applyStackItem({
|
|
365
|
-
stackItemId: top.id,
|
|
366
|
-
...(top.streamSpec !== undefined
|
|
367
|
-
? { streamSpec: top.streamSpec }
|
|
412
|
+
}
|
|
413
|
+
mountedRender = target;
|
|
414
|
+
if (renderer !== null) {
|
|
415
|
+
await renderer.applyRender(target);
|
|
416
|
+
if (target.type !== 'mcpApps' && target.type !== 'system') {
|
|
417
|
+
renderer.channelTransport.applyRender({
|
|
418
|
+
renderId: target.id,
|
|
419
|
+
...(target.streamSpec !== undefined
|
|
420
|
+
? { streamSpec: target.streamSpec }
|
|
368
421
|
: {}),
|
|
369
422
|
});
|
|
370
423
|
}
|
|
371
424
|
}
|
|
372
425
|
};
|
|
426
|
+
// `code-ready` is emitted exactly once per boot. The static seed mount
|
|
427
|
+
// emits it; the WS-ack reconcile path then becomes a silent repaint.
|
|
428
|
+
// Without this guard a static+live meta would fire `code-ready` twice
|
|
429
|
+
// (seed + ack), double-triggering host selectors / accessibility scanners.
|
|
430
|
+
let codeReadyEmitted = false;
|
|
431
|
+
const emitCodeReadyOnce = () => {
|
|
432
|
+
if (codeReadyEmitted)
|
|
433
|
+
return;
|
|
434
|
+
codeReadyEmitted = true;
|
|
435
|
+
onLifecycle?.(makeLifecycleEvent('code-ready', { renderId: meta.renderId }));
|
|
436
|
+
};
|
|
437
|
+
// Mode discriminators. The mount surface is DECOUPLED from the live
|
|
438
|
+
// channel: static content (codeUrl/kind) paints immediately with no WS;
|
|
439
|
+
// the live trio (wsUrl+wsToken) is an OPTIONAL enhancement that
|
|
440
|
+
// delivers props_update / data / re-render frames. A bootstrap with
|
|
441
|
+
// NEITHER has nothing to show and nowhere to subscribe.
|
|
442
|
+
const hasStaticContent = (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
|
|
443
|
+
(typeof meta.kind === 'string' && meta.kind.length > 0);
|
|
444
|
+
const hasLiveTrio = typeof meta.wsUrl === 'string' &&
|
|
445
|
+
meta.wsUrl.length > 0 &&
|
|
446
|
+
typeof meta.wsToken === 'string' &&
|
|
447
|
+
meta.wsToken.length > 0;
|
|
448
|
+
// ── Static seed mount — zero-round-trip paint, no WS required. ──────
|
|
449
|
+
// The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
|
|
450
|
+
// ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
|
|
451
|
+
// instant first paint for first-party hosts that ALSO open a WS (the
|
|
452
|
+
// WS ack then reconciles in place — `applyRender` is idempotent on the
|
|
453
|
+
// pinned renderId, and the ack's componentCode is byte-identical to the
|
|
454
|
+
// seed's `codeUrl` bytes, so it's a props-only update, no remount).
|
|
455
|
+
if (renderer !== null && hasStaticContent) {
|
|
456
|
+
// Await the 3rd-party gadget merge before first paint when the
|
|
457
|
+
// bootstrap declares operator-registered packages — otherwise a
|
|
458
|
+
// component importing a non-STDLIB gadget hits an undefined shim and
|
|
459
|
+
// crashes. STDLIB-only bootstraps resolve this promise synchronously.
|
|
460
|
+
if (meta.gadgets !== undefined && meta.gadgets.length > 0) {
|
|
461
|
+
await renderer.composedGadgets;
|
|
462
|
+
}
|
|
463
|
+
let seed = null;
|
|
464
|
+
try {
|
|
465
|
+
seed = await buildRenderSeedInput(meta);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
469
|
+
if (!hasLiveTrio) {
|
|
470
|
+
// No WS fallback — a failed static fetch is terminal.
|
|
471
|
+
setStatus(refs, `static mount failed: ${message}`, 'error');
|
|
472
|
+
emitBootFailure('UI_INITIALIZE_FAILED', message);
|
|
473
|
+
return { ok: false, mountedRender };
|
|
474
|
+
}
|
|
475
|
+
// A live trio is present — the WS ack will deliver the render.
|
|
476
|
+
// The seed was best-effort; fall through to subscribe.
|
|
477
|
+
seed = null;
|
|
478
|
+
}
|
|
479
|
+
if (seed !== null) {
|
|
480
|
+
await renderer.applyRender(seed);
|
|
481
|
+
mountedRender = seed;
|
|
482
|
+
emitCodeReadyOnce();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// ── No live channel → static-only host. ────────────────────────────
|
|
486
|
+
// The static seed already painted (or there was nothing to paint).
|
|
487
|
+
// A bootstrap with neither static content nor a live trio is a
|
|
488
|
+
// misconfiguration — surface a typed boot failure.
|
|
489
|
+
if (!hasLiveTrio) {
|
|
490
|
+
if (mountedRender === null) {
|
|
491
|
+
const message = 'bootstrap carries neither static content (codeUrl/kind) nor a live trio (wsUrl/wsToken)';
|
|
492
|
+
setStatus(refs, message, 'error');
|
|
493
|
+
emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
|
|
494
|
+
return { ok: false, mountedRender };
|
|
495
|
+
}
|
|
496
|
+
// Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
|
|
497
|
+
// / Claude Desktop). The INITIAL host theme/style/fonts were already
|
|
498
|
+
// applied at boot (applyHostContextStyling, above); attach the
|
|
499
|
+
// spec-canonical `hostcontextchanged` listener so a post-boot host
|
|
500
|
+
// theme toggle still re-paints the iframe. There is no WS to echo
|
|
501
|
+
// context back to a server, so the emitter's `send` is a no-op —
|
|
502
|
+
// `seed()` needs it only to initialize emitter state, and
|
|
503
|
+
// `handleHostContextChangedParams` applies the DOM styling BEFORE the
|
|
504
|
+
// (no-op) echo. WS hosts get the real echo on the live-trio path below.
|
|
505
|
+
if (parsed.hostContext !== undefined) {
|
|
506
|
+
seedHostContext({
|
|
507
|
+
renderId: meta.renderId,
|
|
508
|
+
send: () => { },
|
|
509
|
+
initial: parsed.hostContext,
|
|
510
|
+
});
|
|
511
|
+
attachHostContextListener({ app });
|
|
512
|
+
}
|
|
513
|
+
setConnectedStatus(refs);
|
|
514
|
+
return { ok: true, mountedRender };
|
|
515
|
+
}
|
|
516
|
+
// ── Live-channel subscribe (conditional enhancement). ──────────────
|
|
373
517
|
let handle;
|
|
374
518
|
try {
|
|
375
519
|
handle = await connectFn({
|
|
376
|
-
|
|
520
|
+
meta,
|
|
377
521
|
registry: activeRegistry,
|
|
378
522
|
onStatusChange: (status) => {
|
|
379
|
-
setStatus(refs, status === 'connected'
|
|
380
|
-
? `Connected (${stackModel.size()} item${stackModel.size() === 1 ? '' : 's'}).`
|
|
381
|
-
: `Connection ${status}…`, status);
|
|
523
|
+
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
382
524
|
// Propagate WS status to the per-channel transport router so
|
|
383
525
|
// it can flip WS-bound channels into polling
|
|
384
526
|
// fallback (on disconnect) and re-send `channel_subscribe`
|
|
385
|
-
// on reconnect. No-op when
|
|
386
|
-
if (
|
|
387
|
-
|
|
527
|
+
// on reconnect. No-op when renderer wiring is absent.
|
|
528
|
+
if (renderer !== null) {
|
|
529
|
+
renderer.channelTransport.onWsStatusChange(status);
|
|
388
530
|
}
|
|
389
531
|
},
|
|
390
532
|
// Forward typed errors from subscribe through the runtime's
|
|
@@ -398,17 +540,38 @@ export async function bootSequence(opts) {
|
|
|
398
540
|
...(onObserve !== undefined ? { onObserve } : {}),
|
|
399
541
|
// Reconnect-with-rebootstrap — on every ack received AFTER the
|
|
400
542
|
// initial handshake settled, reapply the server's authoritative
|
|
401
|
-
// `
|
|
543
|
+
// `render` snapshot. A render or update that landed during a WS
|
|
402
544
|
// dropout window restores here without an agent re-prompt.
|
|
403
545
|
onResubscribeAck: (ack) => {
|
|
404
|
-
void
|
|
546
|
+
void applyAck(ack);
|
|
405
547
|
},
|
|
548
|
+
// R7 — registry-level events-polling fallback. Composed once at
|
|
549
|
+
// bind time from `meta.pollingUrl` (server-stamped wsToken-
|
|
550
|
+
// gated /api/renders/<id>/events URL) + `meta.lastSequence`
|
|
551
|
+
// (cursor seed). FailoverHandle uses this when WS reaches
|
|
552
|
+
// 'failed'; absent → no polling fallback (WS-only mode).
|
|
553
|
+
//
|
|
554
|
+
// Same cursor model as the WS subscribe `sinceSequence` replay
|
|
555
|
+
// path — switching transports does not lose events.
|
|
556
|
+
...(typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
|
|
557
|
+
? {
|
|
558
|
+
polling: buildEventsPolling({
|
|
559
|
+
baseUrl: meta.pollingUrl,
|
|
560
|
+
...(meta.lastSequence !== undefined
|
|
561
|
+
? { initialSinceSequence: meta.lastSequence }
|
|
562
|
+
: {}),
|
|
563
|
+
}),
|
|
564
|
+
}
|
|
565
|
+
: {}),
|
|
406
566
|
});
|
|
407
567
|
}
|
|
408
568
|
catch (err) {
|
|
409
|
-
|
|
410
|
-
|
|
569
|
+
// UPGRADE_REQUIRED is TERMINAL even when static content already
|
|
570
|
+
// painted — the mounted code may be wire-incompatible with this
|
|
571
|
+
// runtime, so surfacing the upgrade prompt wins over a stale mount.
|
|
411
572
|
if (isUpgradeRequiredErrorLike(err)) {
|
|
573
|
+
if (renderer !== null)
|
|
574
|
+
rendererHooks?.teardown?.(renderer);
|
|
412
575
|
const message = err.message;
|
|
413
576
|
setStatus(refs, message, 'upgrade-required');
|
|
414
577
|
// `UPGRADE_REQUIRED` already emits a typed `version` error via
|
|
@@ -416,87 +579,96 @@ export async function bootSequence(opts) {
|
|
|
416
579
|
// here carries the coarse-grained reason for hosts that only
|
|
417
580
|
// pattern-match `kind: 'bootstrap'`.
|
|
418
581
|
emitBootFailure('UPGRADE_REQUIRED', message);
|
|
419
|
-
return { ok: false,
|
|
582
|
+
return { ok: false, mountedRender };
|
|
420
583
|
}
|
|
421
584
|
const message = err instanceof Error ? err.message : String(err);
|
|
585
|
+
if (mountedRender !== null) {
|
|
586
|
+
// DEGRADE — a transport/auth failure AFTER a static seed already
|
|
587
|
+
// painted. Keep the mounted content, skip live updates (props_update
|
|
588
|
+
// simply stops arriving), and do NOT tear down or surface a
|
|
589
|
+
// bootstrap failure. connectFn already emitted the typed
|
|
590
|
+
// `subscribe-failed` observability event on its own onObserve path.
|
|
591
|
+
setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
|
|
592
|
+
emitCodeReadyOnce();
|
|
593
|
+
return { ok: true, mountedRender };
|
|
594
|
+
}
|
|
595
|
+
if (renderer !== null)
|
|
596
|
+
rendererHooks?.teardown?.(renderer);
|
|
422
597
|
setStatus(refs, `WS handshake failed: ${message}`, 'error');
|
|
423
598
|
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
424
|
-
return { ok: false,
|
|
599
|
+
return { ok: false, mountedRender };
|
|
425
600
|
}
|
|
426
|
-
// Attach the live transport handle to the
|
|
601
|
+
// Attach the live transport handle to the renderer — flushes any
|
|
427
602
|
// buffered outbound frames (feedback / action) that were queued
|
|
428
603
|
// while the subscribe handshake completed. `handle.handle.send` is
|
|
429
604
|
// the canonical send surface for the bound WS transport.
|
|
430
|
-
if (
|
|
431
|
-
|
|
605
|
+
if (renderer !== null && rendererHooks?.attachManager !== undefined) {
|
|
606
|
+
rendererHooks.attachManager(renderer, { send: (msg) => handle.handle.send(msg) });
|
|
432
607
|
}
|
|
433
|
-
// seed the host-context emitter with the
|
|
434
|
-
//
|
|
435
|
-
// `
|
|
608
|
+
// seed the host-context emitter with the projection captured from
|
|
609
|
+
// `app.getHostContext()` (the App class's spec-canonical
|
|
610
|
+
// `ui/initialize` capture), and install the `hostcontextchanged`
|
|
436
611
|
// notification listener so subsequent live updates also echo to
|
|
437
612
|
// the server. Both calls are idempotent and no-op when the host
|
|
438
613
|
// didn't emit a HostContext (parsed.hostContext === undefined).
|
|
439
614
|
if (parsed.hostContext !== undefined) {
|
|
440
615
|
seedHostContext({
|
|
441
|
-
|
|
616
|
+
renderId: meta.renderId,
|
|
442
617
|
send: (msg) => handle.handle.send(msg),
|
|
443
618
|
initial: parsed.hostContext,
|
|
444
619
|
});
|
|
445
|
-
|
|
620
|
+
// Bind via App's spec-canonical `hostcontextchanged` event surface.
|
|
621
|
+
// App's onEventDispatch pre-merges the params into its internal
|
|
622
|
+
// `_hostContext` before our handler runs, so `app.getHostContext()`
|
|
623
|
+
// is always fresh by the time we project + WS-echo.
|
|
624
|
+
attachHostContextListener({ app });
|
|
446
625
|
}
|
|
447
|
-
// First ack —
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// mirror per-card lifecycle on the outer element if it's keyed by
|
|
462
|
-
// stack item id.
|
|
463
|
-
onLifecycle?.(makeLifecycleEvent('code-ready', {
|
|
464
|
-
...(parsed.bootstrap.stackItemId !== undefined
|
|
465
|
-
? { stackItemId: parsed.bootstrap.stackItemId }
|
|
466
|
-
: {}),
|
|
467
|
-
}));
|
|
468
|
-
return { ok: true, stack: stackModel };
|
|
626
|
+
// First ack — apply the server's render snapshot (when matching
|
|
627
|
+
// `pinnedRenderId`) and mount it. Reuses the same `applyAck` helper
|
|
628
|
+
// the reconnect-rebootstrap path uses — so a server-restart-driven
|
|
629
|
+
// snapshot replay and the first-boot snapshot apply flow through
|
|
630
|
+
// one implementation.
|
|
631
|
+
await applyAck(handle.ack);
|
|
632
|
+
setConnectedStatus(refs);
|
|
633
|
+
// Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
|
|
634
|
+
// (a static+live meta already fired it from the seed mount, so this is
|
|
635
|
+
// a no-op there); for a live-only meta this is the first + only emit.
|
|
636
|
+
// Hosts pinning selectors on `code-ready` (E2E specs, accessibility
|
|
637
|
+
// scanners) re-resolve here.
|
|
638
|
+
emitCodeReadyOnce();
|
|
639
|
+
return { ok: true, mountedRender };
|
|
469
640
|
}
|
|
470
641
|
/**
|
|
471
|
-
* Build a minimal `ChannelRegistry` for boot paths without
|
|
642
|
+
* Build a minimal `ChannelRegistry` for boot paths without renderer
|
|
472
643
|
* wiring (boot.test.ts + the C7a placeholder-only spec). The registry
|
|
473
|
-
* carries just the `push` handler
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
644
|
+
* carries just the `push` handler — which logs status but does not
|
|
645
|
+
* mount React — so consumers can observe bootstrap-orchestration
|
|
646
|
+
* outcomes without paying React import cost. Every other frame type
|
|
647
|
+
* silently drops. Production boots through `bootProduction` which
|
|
648
|
+
* supplies a fully-populated renderer with the rich handler set.
|
|
477
649
|
*/
|
|
478
650
|
function createPlaceholderRegistry(params) {
|
|
479
651
|
const registry = new ChannelRegistry({
|
|
480
652
|
subscribeFrameBuilder: () => ({
|
|
481
653
|
type: 'subscribe',
|
|
482
654
|
payload: {
|
|
483
|
-
|
|
484
|
-
appId: params.
|
|
485
|
-
...(params.
|
|
486
|
-
? {
|
|
655
|
+
renderId: params.meta.renderId,
|
|
656
|
+
appId: params.meta.appId,
|
|
657
|
+
...(params.meta.wsToken !== undefined
|
|
658
|
+
? { wsToken: params.meta.wsToken }
|
|
487
659
|
: {}),
|
|
488
660
|
},
|
|
489
661
|
}),
|
|
490
662
|
});
|
|
491
|
-
registry.register(
|
|
492
|
-
stackModel: params.stackModel,
|
|
663
|
+
registry.register(createRenderHandler({
|
|
493
664
|
statusRefs: params.statusRefs,
|
|
665
|
+
pinnedRenderId: params.pinnedRenderId,
|
|
494
666
|
}));
|
|
495
667
|
return registry;
|
|
496
668
|
}
|
|
497
669
|
/**
|
|
498
|
-
* Frame dispatch lives inside `@ggui-ai/channel
|
|
499
|
-
* `ChannelRegistry`. Every WS frame type (`
|
|
670
|
+
* Frame dispatch lives inside `@ggui-ai/live-channel`'s
|
|
671
|
+
* `ChannelRegistry`. Every WS frame type (`render`, `data`,
|
|
500
672
|
* `props_update`, `drain_ack`, `feedback`, `channel_payload`,
|
|
501
673
|
* `channel_error`, `system`) has a registered handler in
|
|
502
674
|
* `channels/*.ts`; the registry's bound transport routes inbound
|
|
@@ -504,7 +676,7 @@ function createPlaceholderRegistry(params) {
|
|
|
504
676
|
* (`contract-error-emitted`, `auth-required`) are now inside the
|
|
505
677
|
* data + system handlers respectively.
|
|
506
678
|
*
|
|
507
|
-
* The pre-B3b helpers (`handleServerMessage`, `
|
|
679
|
+
* The pre-B3b helpers (`handleServerMessage`, `handleRendererMessage`,
|
|
508
680
|
* `handleObservableMessage`, `emitContractErrorFromDataFrame`,
|
|
509
681
|
* `emitAuthRequiredFromSystemFrame`, `BufferedManagerShim`) lived
|
|
510
682
|
* here and have been retired — see commit message + plan B3b for the
|
|
@@ -530,42 +702,109 @@ function shouldAutostart() {
|
|
|
530
702
|
return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
|
|
531
703
|
}
|
|
532
704
|
/**
|
|
533
|
-
* Read `globalThis.
|
|
534
|
-
* the unified
|
|
705
|
+
* Read `globalThis.__GGUI_META__` synchronously, validate against
|
|
706
|
+
* the unified slice-meta shape, and return the typed meta (or null
|
|
535
707
|
* on absence / malformation).
|
|
536
708
|
*
|
|
537
|
-
* Thin wrapper around {@link
|
|
709
|
+
* Thin wrapper around {@link parseMetaFromGlobal}; preserved for
|
|
538
710
|
* back-compat (downstream consumers + tests). Returns `null` instead
|
|
539
|
-
* of
|
|
540
|
-
* — the autostart resolver only needs the "valid
|
|
541
|
-
* through" signal.
|
|
711
|
+
* of {@link McpAppAiGguiMetaParseResult} to match the historical
|
|
712
|
+
* reader contract — the autostart resolver only needs the "valid
|
|
713
|
+
* meta or fall through" signal.
|
|
714
|
+
*/
|
|
715
|
+
export function readSelfContainedMeta() {
|
|
716
|
+
const result = parseMetaFromGlobal();
|
|
717
|
+
return result.ok ? result.meta : null;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
|
|
721
|
+
* Malformed JSON or a non-object payload is a shape-preserving skip
|
|
722
|
+
* (returns `undefined`) — a bad `propsJson` must never block the mount.
|
|
723
|
+
* `JsonValue` narrows to `JsonObject` via the object/non-null/non-array
|
|
724
|
+
* guard, so no cast is needed.
|
|
542
725
|
*/
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
726
|
+
function parseSeedProps(propsJson) {
|
|
727
|
+
if (propsJson === undefined)
|
|
728
|
+
return undefined;
|
|
729
|
+
try {
|
|
730
|
+
const parsed = JSON.parse(propsJson);
|
|
731
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
732
|
+
return parsed;
|
|
733
|
+
}
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
// Malformed propsJson is a shape-preserving skip — mount with no props.
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
546
740
|
}
|
|
547
741
|
/**
|
|
548
|
-
*
|
|
742
|
+
* Project the inline `__GGUI_META__` bootstrap into a
|
|
743
|
+
* {@link RenderSeedInput} the mount surface can paint immediately,
|
|
744
|
+
* BEFORE the authoritative wire `Render` arrives over the WS — and with
|
|
745
|
+
* no WS at all for spec-compliant MCP-Apps hosts that expose no ggui
|
|
746
|
+
* live channel (claude.ai / ChatGPT / Claude Desktop).
|
|
747
|
+
*
|
|
748
|
+
* Two static-content shapes (the autostart discriminator):
|
|
749
|
+
* - `kind` → a system-card seed (`type:'system'`); no fetch.
|
|
750
|
+
* - `codeUrl` → a compiled-component seed; fetches the
|
|
751
|
+
* content-addressable component bytes.
|
|
752
|
+
*
|
|
753
|
+
* Returns `null` when the meta carries NEITHER (a live-only meta has
|
|
754
|
+
* nothing to seed — the first WS ack mounts it). Throws when a `codeUrl`
|
|
755
|
+
* fetch fails so the caller can surface a typed boot failure. The four
|
|
756
|
+
* server-assigned ledger fields are intentionally absent — the first
|
|
757
|
+
* ack reconciles the seed to a full `Render` (no fabrication).
|
|
758
|
+
*/
|
|
759
|
+
export async function buildRenderSeedInput(meta) {
|
|
760
|
+
const props = parseSeedProps(meta.propsJson);
|
|
761
|
+
// System-card mode — `kind` keyed against the built-in registry.
|
|
762
|
+
if (meta.kind !== undefined) {
|
|
763
|
+
return {
|
|
764
|
+
id: meta.renderId,
|
|
765
|
+
appId: meta.appId,
|
|
766
|
+
type: 'system',
|
|
767
|
+
kind: meta.kind,
|
|
768
|
+
...(props !== undefined ? { props } : {}),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
// Compiled-component mode — fetch the content-addressable bytes.
|
|
772
|
+
if (meta.codeUrl === undefined)
|
|
773
|
+
return null;
|
|
774
|
+
const res = await fetch(meta.codeUrl);
|
|
775
|
+
if (!res.ok) {
|
|
776
|
+
throw new Error(`buildRenderSeedInput: codeUrl fetch failed (${res.status}): ${meta.codeUrl}`);
|
|
777
|
+
}
|
|
778
|
+
const componentCode = await res.text();
|
|
779
|
+
return {
|
|
780
|
+
id: meta.renderId,
|
|
781
|
+
appId: meta.appId,
|
|
782
|
+
componentCode,
|
|
783
|
+
...(props !== undefined ? { props } : {}),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Extract a {@link SelfContainedMcpAppAiGguiMeta} from a
|
|
549
788
|
* `ui/notifications/tool-result` JSON-RPC params payload — the
|
|
550
789
|
* postMessage delivery shape MCP Apps hosts (Claude Desktop, claude.ai
|
|
551
790
|
* web) use to push the active tool's `_meta` to the iframe.
|
|
552
791
|
*
|
|
553
|
-
* Thin wrapper around {@link
|
|
554
|
-
* back-compat motivation as {@link
|
|
792
|
+
* Thin wrapper around {@link parseMetaFromToolResult}; same
|
|
793
|
+
* back-compat motivation as {@link readSelfContainedMeta}.
|
|
555
794
|
*
|
|
556
795
|
* Owning this extraction inside the runtime (instead of in the shell
|
|
557
796
|
* HTML) is the architectural cure for the "shell-side validator
|
|
558
797
|
* lagged the protocol" bug class: shell + runtime drift becomes
|
|
559
|
-
* impossible because the shell never inspects the
|
|
798
|
+
* impossible because the shell never inspects the slice-meta shape.
|
|
560
799
|
*/
|
|
561
|
-
export function
|
|
562
|
-
const result =
|
|
563
|
-
return result.ok ? result.
|
|
800
|
+
export function extractMetaFromToolResult(params) {
|
|
801
|
+
const result = parseMetaFromToolResult(params);
|
|
802
|
+
return result.ok ? result.meta : null;
|
|
564
803
|
}
|
|
565
804
|
/**
|
|
566
805
|
* Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
|
|
567
806
|
* minimal shell populates while messages arrive between
|
|
568
|
-
* shell-load and runtime-load. Returns the first valid
|
|
807
|
+
* shell-load and runtime-load. Returns the first valid slice meta
|
|
569
808
|
* found; later params (if any) are dropped (each new tool-result
|
|
570
809
|
* supersedes the previous).
|
|
571
810
|
*
|
|
@@ -581,25 +820,58 @@ function readPendingToolResults() {
|
|
|
581
820
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
582
821
|
return null;
|
|
583
822
|
for (const params of raw) {
|
|
584
|
-
const
|
|
585
|
-
if (
|
|
586
|
-
return
|
|
823
|
+
const meta = extractMetaFromToolResult(params);
|
|
824
|
+
if (meta !== null)
|
|
825
|
+
return meta;
|
|
587
826
|
}
|
|
588
827
|
return null;
|
|
589
828
|
}
|
|
590
829
|
/**
|
|
591
|
-
* Listen for a `ui/notifications/tool-result`
|
|
592
|
-
*
|
|
593
|
-
* after `timeoutMs`; resolves `null` on timeout
|
|
594
|
-
* fall through to a legacy
|
|
595
|
-
* {@link bootProduction}).
|
|
830
|
+
* Listen for a `ui/notifications/tool-result` notification via the
|
|
831
|
+
* App's spec-canonical event surface and resolve to the extracted
|
|
832
|
+
* slice meta. Times out after `timeoutMs`; resolves `null` on timeout
|
|
833
|
+
* so the caller can fall through to a legacy boot path.
|
|
596
834
|
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
835
|
+
* Used by `bootSequence` for the spec-canonical Tier 2 fallback when
|
|
836
|
+
* the synchronous `__GGUI_META__` inline global yields nothing. Must
|
|
837
|
+
* be called BEFORE `app.connect(transport)` — App's
|
|
838
|
+
* `_assertHandlerTiming` warns when the first handler for a one-shot
|
|
839
|
+
* event registers after the handshake.
|
|
840
|
+
*/
|
|
841
|
+
function awaitToolResultMetaFromApp(app, timeoutMs) {
|
|
842
|
+
return new Promise((resolve) => {
|
|
843
|
+
let settled = false;
|
|
844
|
+
const handler = (params) => {
|
|
845
|
+
if (settled)
|
|
846
|
+
return;
|
|
847
|
+
const meta = extractMetaFromToolResult(params);
|
|
848
|
+
if (meta === null)
|
|
849
|
+
return;
|
|
850
|
+
settled = true;
|
|
851
|
+
app.removeEventListener('toolresult', handler);
|
|
852
|
+
clearTimeout(timer);
|
|
853
|
+
resolve(meta);
|
|
854
|
+
};
|
|
855
|
+
app.addEventListener('toolresult', handler);
|
|
856
|
+
const timer = setTimeout(() => {
|
|
857
|
+
if (settled)
|
|
858
|
+
return;
|
|
859
|
+
settled = true;
|
|
860
|
+
app.removeEventListener('toolresult', handler);
|
|
861
|
+
resolve(null);
|
|
862
|
+
}, timeoutMs);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Pre-handshake variant — listens to raw `window.message` for the
|
|
867
|
+
* autostart layer. Used BEFORE the App is constructed, so this can't
|
|
868
|
+
* route through `app.addEventListener`. Pairs with the minimal-shell
|
|
869
|
+
* pattern: the shell buffers any tool-results that arrived BEFORE
|
|
870
|
+
* runtime load (read via {@link readPendingToolResults}); this
|
|
871
|
+
* listener catches the ones that arrive AFTER the bundle parses but
|
|
872
|
+
* BEFORE bootSequence runs.
|
|
601
873
|
*/
|
|
602
|
-
function
|
|
874
|
+
function awaitToolResultMeta(timeoutMs) {
|
|
603
875
|
if (typeof window === 'undefined')
|
|
604
876
|
return Promise.resolve(null);
|
|
605
877
|
return new Promise((resolve) => {
|
|
@@ -620,64 +892,35 @@ function awaitToolResultBootstrap(timeoutMs) {
|
|
|
620
892
|
m.method !== 'ui/notifications/tool-result') {
|
|
621
893
|
return;
|
|
622
894
|
}
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
settle(
|
|
895
|
+
const meta = extractMetaFromToolResult(m.params);
|
|
896
|
+
if (meta !== null)
|
|
897
|
+
settle(meta);
|
|
626
898
|
};
|
|
627
899
|
window.addEventListener('message', onMessage);
|
|
628
900
|
const timer = setTimeout(() => settle(null), timeoutMs);
|
|
629
901
|
});
|
|
630
902
|
}
|
|
631
903
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* Owning this in the runtime (instead of duplicated in every shell
|
|
638
|
-
* builder) is the same architectural fix as
|
|
639
|
-
* {@link extractBootstrapFromToolResult}: anything that observes
|
|
640
|
-
* mounted-component state belongs alongside the mount, not in the
|
|
641
|
-
* shell.
|
|
904
|
+
* Module-level guard for the App-mediated post-mount toolresult
|
|
905
|
+
* re-listener. Ensures exactly one persistent
|
|
906
|
+
* `app.addEventListener('toolresult', …)` registration even when
|
|
907
|
+
* `bootSelfContained` is called multiple times (e.g. an agent fires a
|
|
908
|
+
* second `ggui_render` and we re-mount).
|
|
642
909
|
*/
|
|
643
|
-
|
|
644
|
-
if (typeof window === 'undefined')
|
|
645
|
-
return;
|
|
646
|
-
let lastH = 0;
|
|
647
|
-
const emit = () => {
|
|
648
|
-
const docBody = window.document.body;
|
|
649
|
-
const h = Math.max(rootEl instanceof HTMLElement ? rootEl.scrollHeight : 0, docBody.scrollHeight, 200);
|
|
650
|
-
if (h === lastH)
|
|
651
|
-
return;
|
|
652
|
-
lastH = h;
|
|
653
|
-
try {
|
|
654
|
-
window.parent.postMessage({
|
|
655
|
-
jsonrpc: '2.0',
|
|
656
|
-
method: 'ui/notifications/size-changed',
|
|
657
|
-
params: { height: h },
|
|
658
|
-
}, '*');
|
|
659
|
-
}
|
|
660
|
-
catch {
|
|
661
|
-
// Detached parent — swallow (matches postBootFailure posture).
|
|
662
|
-
}
|
|
663
|
-
};
|
|
664
|
-
emit();
|
|
665
|
-
if (typeof ResizeObserver === 'function') {
|
|
666
|
-
const ro = new ResizeObserver(emit);
|
|
667
|
-
ro.observe(rootEl);
|
|
668
|
-
ro.observe(window.document.body);
|
|
669
|
-
}
|
|
670
|
-
else {
|
|
671
|
-
setInterval(emit, 500);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
910
|
+
let postMountListenerInstalled = false;
|
|
674
911
|
/**
|
|
675
|
-
* Module-level
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
912
|
+
* Module-level handle to the active renderer's `applyRender`, published
|
|
913
|
+
* by `bootProduction`'s `setup()` once the renderer is built. The
|
|
914
|
+
* module-level {@link installPostMountListener} resolves this to re-mount
|
|
915
|
+
* on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount
|
|
916
|
+
* stack, no second WS. This is the no-WS live-re-render channel for
|
|
917
|
+
* spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
|
|
918
|
+
* that re-broadcast the tool-result instead of pushing a WS frame.
|
|
919
|
+
*
|
|
920
|
+
* `null` until a renderer is published (e.g. unit tests that drive
|
|
921
|
+
* `bootSequence` with no renderer never set it → the listener no-ops).
|
|
679
922
|
*/
|
|
680
|
-
let
|
|
923
|
+
let activeApplyRender = null;
|
|
681
924
|
/**
|
|
682
925
|
* Module-level guard for {@link installAnchorClickInterceptor}. Same
|
|
683
926
|
* rationale as {@link postMountListenerInstalled}: re-mounts are
|
|
@@ -690,7 +933,7 @@ let anchorClickInterceptInstalled = false;
|
|
|
690
933
|
* Module-level guard for {@link installFullscreenInterceptors}. The
|
|
691
934
|
* fullscreen interceptors REPLACE prototype methods on `Element` and
|
|
692
935
|
* `Document`; re-overriding on every re-mount would chain wrappers
|
|
693
|
-
* and (if the captured `args` differ) leak stale `
|
|
936
|
+
* and (if the captured `args` differ) leak stale `renderId`/`appId`.
|
|
694
937
|
* The guard ensures exactly one prototype patch.
|
|
695
938
|
*/
|
|
696
939
|
let fullscreenInterceptInstalled = false;
|
|
@@ -792,9 +1035,22 @@ export function formatWiredActionDataInline(data) {
|
|
|
792
1035
|
*/
|
|
793
1036
|
/**
|
|
794
1037
|
* Post an arbitrary JSON-RPC envelope to the iframe's parent
|
|
795
|
-
* window. Internal helper shared across {@link
|
|
796
|
-
*
|
|
797
|
-
* `
|
|
1038
|
+
* window. Internal helper shared across {@link emitAudit} (the
|
|
1039
|
+
* fire-and-forget `tools/call` audit envelope) and any other
|
|
1040
|
+
* non-spec-canonical `ggui:*` outbound envelope that doesn't have an
|
|
1041
|
+
* App method equivalent. Detached parent → silent drop (non-fatal).
|
|
1042
|
+
*
|
|
1043
|
+
* Spec-canonical MCP-Apps notifications whose params are FULLY
|
|
1044
|
+
* described by the spec schema (`ui/update-model-context`,
|
|
1045
|
+
* `ui/open-link`, `ui/request-display-mode`) flow through the App
|
|
1046
|
+
* method helpers below — they round-trip via the bound `Transport`
|
|
1047
|
+
* and follow the spec's request/response shape.
|
|
1048
|
+
*
|
|
1049
|
+
* `ui/message` is the exception: its doorbell carries a content-block
|
|
1050
|
+
* `_meta` extension that the host's closed `McpUiMessageRequestSchema`
|
|
1051
|
+
* parse would strip (and empty the text). It posts its `ui/message`
|
|
1052
|
+
* frame through this raw helper instead — see {@link
|
|
1053
|
+
* emitUserActionDoorbell} for the full rationale.
|
|
798
1054
|
*/
|
|
799
1055
|
function postToParent(envelope) {
|
|
800
1056
|
if (typeof window === 'undefined')
|
|
@@ -807,77 +1063,117 @@ function postToParent(envelope) {
|
|
|
807
1063
|
}
|
|
808
1064
|
}
|
|
809
1065
|
/**
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
* iframe-
|
|
814
|
-
*
|
|
1066
|
+
* Outbound MCP-Apps notification shims — fire the spec-canonical App
|
|
1067
|
+
* method when the module-level App handle is set, otherwise drop
|
|
1068
|
+
* silently. Each method is a fire-and-forget request from the
|
|
1069
|
+
* iframe-runtime's perspective: production never awaits the host's
|
|
1070
|
+
* ack, so we use `void` + `.catch(noop)` to suppress unhandled
|
|
1071
|
+
* rejections.
|
|
815
1072
|
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* — moving it here keeps the channel-transport router pure (no
|
|
824
|
-
* `window.addEventListener` inside).
|
|
1073
|
+
* Pre-connect call site safety: every production caller fires after
|
|
1074
|
+
* the boot pipeline has installed an App (anchor clicks, fullscreen
|
|
1075
|
+
* gestures, context-observer ticks, dispatch fan-outs all gate on
|
|
1076
|
+
* post-mount lifecycle). The no-op fallback exists for unit tests
|
|
1077
|
+
* that exercise these helpers without invoking `bootSequence` —
|
|
1078
|
+
* the absence of side effects there is the test's signal that the
|
|
1079
|
+
* caller did its part.
|
|
825
1080
|
*/
|
|
826
|
-
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
function ensurePostRpcToParentListener() {
|
|
830
|
-
if (postRpcToParentInited)
|
|
1081
|
+
function callAppUpdateModelContext(params) {
|
|
1082
|
+
const app = getCurrentApp();
|
|
1083
|
+
if (app === null)
|
|
831
1084
|
return;
|
|
832
|
-
|
|
1085
|
+
void app.updateModelContext(params).catch(() => {
|
|
1086
|
+
// Detached / host-rejected — drop silently per the helper contract.
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
function callAppOpenLink(params) {
|
|
1090
|
+
const app = getCurrentApp();
|
|
1091
|
+
if (app === null)
|
|
833
1092
|
return;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
const data = event.data;
|
|
837
|
-
if (data === null || typeof data !== 'object')
|
|
838
|
-
return;
|
|
839
|
-
const id = data.id;
|
|
840
|
-
if (typeof id !== 'number')
|
|
841
|
-
return;
|
|
842
|
-
// Filter for RESPONSES — must carry `result` or `error`. Bare
|
|
843
|
-
// requests with `method` set are not responses. This matters in
|
|
844
|
-
// `/r/<shortCode>` top-level mode where `window.parent === window`,
|
|
845
|
-
// so the outbound `tools/call` echoes back through this same
|
|
846
|
-
// listener before any relay's response arrives; without the filter
|
|
847
|
-
// the pending promise resolves with the request itself and the
|
|
848
|
-
// real response is dropped.
|
|
849
|
-
if (!('result' in data) && !('error' in data))
|
|
850
|
-
return;
|
|
851
|
-
const resolver = postRpcToParentPending.get(id);
|
|
852
|
-
if (resolver === undefined)
|
|
853
|
-
return;
|
|
854
|
-
postRpcToParentPending.delete(id);
|
|
855
|
-
resolver(data);
|
|
1093
|
+
void app.openLink(params).catch(() => {
|
|
1094
|
+
// Detached / host-rejected — drop silently per the helper contract.
|
|
856
1095
|
});
|
|
857
1096
|
}
|
|
858
|
-
function
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1097
|
+
function callAppRequestDisplayMode(params) {
|
|
1098
|
+
const app = getCurrentApp();
|
|
1099
|
+
if (app === null)
|
|
1100
|
+
return;
|
|
1101
|
+
void app.requestDisplayMode(params).catch(() => {
|
|
1102
|
+
// Detached / host-rejected — drop silently per the helper contract.
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Production {@link ContextSnapshotPoster} — the seam the
|
|
1107
|
+
* context-observer factories consume. Splits the two destinations:
|
|
1108
|
+
*
|
|
1109
|
+
* - `postUpdateModelContext` → spec-canonical
|
|
1110
|
+
* `app.updateModelContext(...)` (via {@link callAppUpdateModelContext}).
|
|
1111
|
+
* - `postContextMirror` → raw `tools/call ggui_runtime_sync_context`
|
|
1112
|
+
* via {@link postToParent} (the host-relay mirror path; not yet
|
|
1113
|
+
* migrated to `app.callServerTool` — see emitAudit for the same
|
|
1114
|
+
* posture).
|
|
1115
|
+
*/
|
|
1116
|
+
const productionContextSnapshotPoster = {
|
|
1117
|
+
postUpdateModelContext: (params) => {
|
|
1118
|
+
callAppUpdateModelContext(params);
|
|
1119
|
+
},
|
|
1120
|
+
postContextMirror: (params) => {
|
|
1121
|
+
postToParent({
|
|
1122
|
+
jsonrpc: '2.0',
|
|
1123
|
+
id: Math.floor(Math.random() * 1e9),
|
|
1124
|
+
method: 'tools/call',
|
|
1125
|
+
params: {
|
|
1126
|
+
name: 'ggui_runtime_sync_context',
|
|
1127
|
+
arguments: {
|
|
1128
|
+
renderId: params.renderId,
|
|
1129
|
+
appId: params.appId,
|
|
1130
|
+
snapshot: params.snapshot,
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
862
1133
|
});
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
/**
|
|
1137
|
+
* Outbound `tools/call` shim. Routes through the spec-canonical
|
|
1138
|
+
* `app.callServerTool` API on the module-level App handle. Production
|
|
1139
|
+
* always has the handle set (bootSequence / bootSelfContained call
|
|
1140
|
+
* `setCurrentApp(app)` after handshake); tests that exercise dispatch
|
|
1141
|
+
* routing install one explicitly via {@link setCurrentApp} bound to
|
|
1142
|
+
* a `MockTransport`.
|
|
1143
|
+
*
|
|
1144
|
+
* Returns a `JsonRpcResponse`-shaped object for source-compatibility
|
|
1145
|
+
* with the previous direct postMessage path: callers parse
|
|
1146
|
+
* `resp.result.structuredContent` to read submit_action's `{ok, code,
|
|
1147
|
+
* consumerPresent}` envelope. The App branch wraps the parsed
|
|
1148
|
+
* `CallToolResult` in `{result: ...}`.
|
|
1149
|
+
*
|
|
1150
|
+
* Drops with an error envelope when no App is bound — the dispatch
|
|
1151
|
+
* pipeline classifies that as a transport error and routes to the
|
|
1152
|
+
* `ui/message` fallback.
|
|
1153
|
+
*/
|
|
1154
|
+
async function callServerToolSpec(toolName, args) {
|
|
1155
|
+
const app = getCurrentApp();
|
|
1156
|
+
if (app === null) {
|
|
1157
|
+
return {
|
|
1158
|
+
error: {
|
|
1159
|
+
message: 'callServerToolSpec: no App bound — call setCurrentApp() first',
|
|
1160
|
+
},
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
const result = await app.callServerTool({
|
|
1165
|
+
name: toolName,
|
|
1166
|
+
arguments: args,
|
|
1167
|
+
});
|
|
1168
|
+
return { jsonrpc: '2.0', result: result };
|
|
1169
|
+
}
|
|
1170
|
+
catch (err) {
|
|
1171
|
+
return {
|
|
1172
|
+
error: {
|
|
1173
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
863
1176
|
}
|
|
864
|
-
ensurePostRpcToParentListener();
|
|
865
|
-
return new Promise((resolve) => {
|
|
866
|
-
const id = postRpcToParentNextId;
|
|
867
|
-
postRpcToParentNextId += 1;
|
|
868
|
-
postRpcToParentPending.set(id, resolve);
|
|
869
|
-
try {
|
|
870
|
-
window.parent.postMessage({ jsonrpc: '2.0', id, method, params: params ?? {} }, '*');
|
|
871
|
-
}
|
|
872
|
-
catch (err) {
|
|
873
|
-
postRpcToParentPending.delete(id);
|
|
874
|
-
resolve({
|
|
875
|
-
error: {
|
|
876
|
-
message: err instanceof Error ? err.message : String(err),
|
|
877
|
-
},
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
});
|
|
881
1177
|
}
|
|
882
1178
|
/**
|
|
883
1179
|
* Fire a single canonical action envelope (`tools/call
|
|
@@ -904,10 +1200,7 @@ export function emitAudit(args) {
|
|
|
904
1200
|
arguments: {
|
|
905
1201
|
kind: args.kind,
|
|
906
1202
|
payload: args.payload,
|
|
907
|
-
|
|
908
|
-
...(args.stackItemId !== undefined
|
|
909
|
-
? { stackItemId: args.stackItemId }
|
|
910
|
-
: {}),
|
|
1203
|
+
renderId: args.renderId,
|
|
911
1204
|
appId: args.appId,
|
|
912
1205
|
actionId: args.actionId,
|
|
913
1206
|
firedAt: args.firedAt,
|
|
@@ -1091,105 +1384,113 @@ function readLocalUiContext() {
|
|
|
1091
1384
|
return out;
|
|
1092
1385
|
}
|
|
1093
1386
|
/**
|
|
1094
|
-
* Emit a `ui/message`
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1387
|
+
* Emit the `ai.ggui/userAction` PURE DOORBELL on a `ui/message`
|
|
1388
|
+
* envelope. Fires when `submit_action` succeeded BUT the server
|
|
1389
|
+
* reported `consumerPresent: false` — no `ggui_consume` long-poll is
|
|
1390
|
+
* currently listening on this render's pipe (the agent's persistent
|
|
1391
|
+
* consume loop has ended, e.g. after a page reload). The gesture is
|
|
1392
|
+
* ALREADY on the pipe from the just-completed `submit_action` append;
|
|
1393
|
+
* this doorbell only wakes a fresh agent turn so it calls
|
|
1394
|
+
* `ggui_consume({renderId})` to drain it.
|
|
1100
1395
|
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1396
|
+
* SINGLE SOURCE OF TRUTH: the pipe. This carries ONLY a pointer to the
|
|
1397
|
+
* render — never the action payload or uiContext. Carrying the payload
|
|
1398
|
+
* here would let the agent act on the inline copy AND drain the pipe =
|
|
1399
|
+
* a double-trigger (the action fires twice). Pointer-only ⇒ the agent
|
|
1400
|
+
* retrieves the gesture EXCLUSIVELY via `ggui_consume`, so it's
|
|
1401
|
+
* exactly-once by construction.
|
|
1104
1402
|
*/
|
|
1105
|
-
function
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1403
|
+
function emitUserActionDoorbell(args) {
|
|
1404
|
+
// THE DIRECTIVE LIVES IN THIS TEXT. Every host (claude.ai,
|
|
1405
|
+
// chatgpt.com, ggui-aware SDKs) forwards the `ui/message` text to the
|
|
1406
|
+
// model verbatim — none of them runs a ggui-side synthesizer. So the
|
|
1407
|
+
// iframe-runtime is the single directive-authoring point. The
|
|
1408
|
+
// wording below is the empirically-validated XML-tagged
|
|
1409
|
+
// imperative-first shape (REQUIRED FIRST TOOL CALL + `<ggui_directive>`
|
|
1410
|
+
// tags + gesture framing) that was previously synthesized
|
|
1411
|
+
// server-side. The imperative-first phrasing — "Call <tool> NOW. ...
|
|
1412
|
+
// Do not respond conversationally." — is load-bearing for Gemini,
|
|
1413
|
+
// which otherwise summarizes the situation back to the user instead
|
|
1414
|
+
// of dispatching the prepared tool call.
|
|
1415
|
+
//
|
|
1416
|
+
// It is deliberately GENERIC: it names the render pointer, never the
|
|
1417
|
+
// action specifics. Naming what the user did would tempt the agent to
|
|
1418
|
+
// act before consuming = the double-trigger this whole shape exists
|
|
1419
|
+
// to kill. The agent gets the actionable data EXCLUSIVELY from
|
|
1420
|
+
// `ggui_consume`.
|
|
1421
|
+
//
|
|
1422
|
+
// Prose + XML tags only, no fenced ```json``` block: a verbatim copy
|
|
1423
|
+
// of a tool-call wire shape trips claude.ai's prompt-injection
|
|
1424
|
+
// classifier even on the user-trusted `ui/message` channel.
|
|
1425
|
+
const nextArgs = JSON.stringify({ renderId: args.renderId });
|
|
1426
|
+
const text = [
|
|
1427
|
+
`Your REQUIRED FIRST TOOL CALL is ggui_consume with arguments ${nextArgs}. Call it NOW to retrieve and process the pending interaction. Do not respond conversationally; do not summarize. Issue the tool call as your next action.`,
|
|
1428
|
+
'',
|
|
1429
|
+
`<ggui_directive kind="user-action">`,
|
|
1430
|
+
` <render_id>${args.renderId}</render_id>`,
|
|
1431
|
+
` <next_tool>ggui_consume</next_tool>`,
|
|
1432
|
+
` <next_args>${nextArgs}</next_args>`,
|
|
1433
|
+
`</ggui_directive>`,
|
|
1434
|
+
'',
|
|
1435
|
+
`The user interacted with render ${args.renderId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_update on the SAME renderId (${args.renderId}).`,
|
|
1436
|
+
].join('\n');
|
|
1437
|
+
// Structured mirror of the directive for ggui-aware programmatic
|
|
1438
|
+
// consumers. OPTIONAL — nothing in the loop depends on a server-side
|
|
1439
|
+
// parse of this; an `_meta`-agnostic host acts on the text above
|
|
1440
|
+
// alone. Typed against the protocol interface (no runtime guard
|
|
1441
|
+
// exists — the shape is locked at compile time here).
|
|
1442
|
+
const description = `User interacted with render ${args.renderId}; call ggui_consume to retrieve and process it.`;
|
|
1443
|
+
const userAction = {
|
|
1444
|
+
kind: 'user-action',
|
|
1445
|
+
description,
|
|
1446
|
+
renderId: args.renderId,
|
|
1447
|
+
actionId: args.actionId,
|
|
1448
|
+
submittedAt: args.submittedAt,
|
|
1449
|
+
intent: args.intent,
|
|
1450
|
+
nextStep: {
|
|
1451
|
+
tool: 'ggui_consume',
|
|
1452
|
+
args: { renderId: args.renderId },
|
|
1146
1453
|
},
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1454
|
+
};
|
|
1455
|
+
// RAW postMessage — NOT `app.sendMessage`. The doorbell MUST bypass
|
|
1456
|
+
// the App's `ui/message` request path because the host validates the
|
|
1457
|
+
// incoming request through the spec's CLOSED `McpUiMessageRequestSchema`
|
|
1458
|
+
// (`Protocol.setRequestHandler` → `parseWithCompat`). That schema's
|
|
1459
|
+
// `content` array is the spec `ContentBlockSchema`, which has no place
|
|
1460
|
+
// for our content-block `_meta` extension — the parse strips the
|
|
1461
|
+
// extension AND (as observed on the first live post-reload doorbell)
|
|
1462
|
+
// can leave the host's `handleAppMessage` with an empty
|
|
1463
|
+
// `content[0].text`, so it rejects the doorbell with `isError` and no
|
|
1464
|
+
// fresh agent turn fires.
|
|
1465
|
+
//
|
|
1466
|
+
// `postToParent` posts the JSON-RPC frame verbatim to the parent, so
|
|
1467
|
+
// BOTH `content[0].text` (the load-bearing directive every host
|
|
1468
|
+
// forwards to the model) AND `content[0]._meta["ai.ggui/userAction"]`
|
|
1469
|
+
// (the optional structured mirror) survive intact. This is the same
|
|
1470
|
+
// deliberate raw-postMessage decision `ui/message` carried in #275 —
|
|
1471
|
+
// the userAction-collapse refactor regressed it onto `app.sendMessage`.
|
|
1472
|
+
//
|
|
1473
|
+
// Spec-canonical shape: `_meta` lives on the CONTENT BLOCK (the spec
|
|
1474
|
+
// closes `params._meta` via `additionalProperties: false`, but each
|
|
1475
|
+
// content block has its own `_meta: { [key: string]: unknown }` open
|
|
1476
|
+
// record — the proper extension point). Namespaced under
|
|
1477
|
+
// `ai.ggui/userAction` to match our other protocol extensions
|
|
1478
|
+
// (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
|
|
1170
1479
|
postToParent({
|
|
1171
1480
|
jsonrpc: '2.0',
|
|
1172
1481
|
id: Math.floor(Math.random() * 1e9),
|
|
1173
1482
|
method: 'ui/message',
|
|
1174
1483
|
params: {
|
|
1175
1484
|
role: 'user',
|
|
1176
|
-
content: [
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
stackItemId: args.stackItemId,
|
|
1183
|
-
actionId: args.actionId,
|
|
1184
|
-
submittedAt: args.submittedAt,
|
|
1185
|
-
intent: args.intent,
|
|
1186
|
-
nextStep: {
|
|
1187
|
-
tool: 'ggui_consume',
|
|
1188
|
-
args: { stackItemId: args.stackItemId },
|
|
1189
|
-
},
|
|
1485
|
+
content: [
|
|
1486
|
+
{
|
|
1487
|
+
type: 'text',
|
|
1488
|
+
text,
|
|
1489
|
+
_meta: {
|
|
1490
|
+
'ai.ggui/userAction': userAction,
|
|
1190
1491
|
},
|
|
1191
1492
|
},
|
|
1192
|
-
|
|
1493
|
+
],
|
|
1193
1494
|
},
|
|
1194
1495
|
});
|
|
1195
1496
|
}
|
|
@@ -1197,7 +1498,7 @@ function emitUserActionQueued(args) {
|
|
|
1197
1498
|
export function dispatchWiredAction(args) {
|
|
1198
1499
|
if (typeof window === 'undefined')
|
|
1199
1500
|
return;
|
|
1200
|
-
const { toolName, intent, data,
|
|
1501
|
+
const { toolName, intent, data, renderId, appId } = args;
|
|
1201
1502
|
const firedAt = new Date().toISOString();
|
|
1202
1503
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
1203
1504
|
const inlineData = formatWiredActionDataInline(data);
|
|
@@ -1207,25 +1508,20 @@ export function dispatchWiredAction(args) {
|
|
|
1207
1508
|
// LLM's widget-context surface regardless of which downstream path
|
|
1208
1509
|
// catches the event (pipe or ui/message). Fire-and-forget; no
|
|
1209
1510
|
// response needed.
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
appId,
|
|
1225
|
-
})}`,
|
|
1226
|
-
},
|
|
1227
|
-
],
|
|
1228
|
-
},
|
|
1511
|
+
callAppUpdateModelContext({
|
|
1512
|
+
content: [
|
|
1513
|
+
{
|
|
1514
|
+
type: 'text',
|
|
1515
|
+
text: `[ggui:pending-action] ${JSON.stringify({
|
|
1516
|
+
actionId,
|
|
1517
|
+
intent,
|
|
1518
|
+
data: data ?? null,
|
|
1519
|
+
firedAt,
|
|
1520
|
+
renderId,
|
|
1521
|
+
appId,
|
|
1522
|
+
})}`,
|
|
1523
|
+
},
|
|
1524
|
+
],
|
|
1229
1525
|
});
|
|
1230
1526
|
// (1.5) Toast — pending state. User sees "→ Sending: archive"
|
|
1231
1527
|
// immediately so they know the click was registered, even when
|
|
@@ -1238,32 +1534,32 @@ export function dispatchWiredAction(args) {
|
|
|
1238
1534
|
// `consumerPresent`:
|
|
1239
1535
|
// - true (or undefined): toast stays `pending`; drain_ack will
|
|
1240
1536
|
// dismiss it when the agent's ggui_consume drains the event.
|
|
1241
|
-
// - false: no
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1537
|
+
// - false: no `ggui_consume` long-poll is currently listening
|
|
1538
|
+
// (the agent's persistent consume loop has ended — e.g. after a
|
|
1539
|
+
// page reload). Emit the `ai.ggui/userAction` PURE DOORBELL on a
|
|
1540
|
+
// `ui/message` so a fresh agent turn calls `ggui_consume` to
|
|
1541
|
+
// drain the gesture we just enqueued. No timer, no rescue, no
|
|
1542
|
+
// payload — the pipe is the single source of truth.
|
|
1244
1543
|
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1544
|
+
// On any non-success outcome (PIPE_NOT_FOUND, transport error, host
|
|
1545
|
+
// has no relay) the gesture could NOT be enqueued; there is nothing
|
|
1546
|
+
// to point a doorbell at, so we surface a toast and stop. Post-reload
|
|
1547
|
+
// recovery for the abort-aware long-poll is a separate server-side
|
|
1548
|
+
// concern (#292); this client never inlines the action payload.
|
|
1249
1549
|
void (async () => {
|
|
1250
1550
|
let resp = null;
|
|
1251
1551
|
try {
|
|
1252
|
-
resp = await
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
actionData: data ?? null,
|
|
1259
|
-
uiContext,
|
|
1260
|
-
},
|
|
1261
|
-
sessionId,
|
|
1262
|
-
...(stackItemId !== undefined ? { stackItemId } : {}),
|
|
1263
|
-
appId,
|
|
1264
|
-
actionId,
|
|
1265
|
-
firedAt,
|
|
1552
|
+
resp = await callServerToolSpec(toolName, {
|
|
1553
|
+
kind: 'dispatch',
|
|
1554
|
+
payload: {
|
|
1555
|
+
intent,
|
|
1556
|
+
actionData: data ?? null,
|
|
1557
|
+
uiContext,
|
|
1266
1558
|
},
|
|
1559
|
+
renderId,
|
|
1560
|
+
appId,
|
|
1561
|
+
actionId,
|
|
1562
|
+
firedAt,
|
|
1267
1563
|
});
|
|
1268
1564
|
}
|
|
1269
1565
|
catch {
|
|
@@ -1272,37 +1568,25 @@ export function dispatchWiredAction(args) {
|
|
|
1272
1568
|
}
|
|
1273
1569
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
1274
1570
|
const consumerPresent = extractConsumerPresent(resp);
|
|
1275
|
-
if (consumerPresent === false
|
|
1571
|
+
if (consumerPresent === false) {
|
|
1276
1572
|
showActionToast(`💬 ${intent}${dataPart} — agent not listening, sent to chat`, 'action_required');
|
|
1277
|
-
|
|
1573
|
+
emitUserActionDoorbell({
|
|
1278
1574
|
intent,
|
|
1279
|
-
|
|
1575
|
+
renderId,
|
|
1280
1576
|
actionId,
|
|
1281
1577
|
submittedAt: firedAt,
|
|
1282
1578
|
});
|
|
1283
1579
|
return;
|
|
1284
1580
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
// drain. Treat as a one-shot success.
|
|
1288
|
-
showActionToast(`✓ ${intent} queued for agent`, 'success');
|
|
1289
|
-
}
|
|
1290
|
-
// Otherwise toast stays `pending`; drain_ack listener dismisses
|
|
1581
|
+
// consumerPresent is true (or undefined — agnostic host stripped
|
|
1582
|
+
// the field). Toast stays `pending`; drain_ack listener dismisses
|
|
1291
1583
|
// it when ggui_consume drains the event.
|
|
1292
1584
|
return;
|
|
1293
1585
|
}
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
intent,
|
|
1299
|
-
actionData: data ?? null,
|
|
1300
|
-
uiContext,
|
|
1301
|
-
actionId,
|
|
1302
|
-
stackItemId: stackItemId ?? '',
|
|
1303
|
-
submittedAt: firedAt,
|
|
1304
|
-
...(nextStep !== undefined ? { nextStep } : {}),
|
|
1305
|
-
});
|
|
1586
|
+
// Enqueue failed (pipe gone / transport error). The gesture is not
|
|
1587
|
+
// on any pipe, so a doorbell would point at an empty queue. Surface
|
|
1588
|
+
// the failure as a toast; no `ui/message` is emitted.
|
|
1589
|
+
showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
|
|
1306
1590
|
})();
|
|
1307
1591
|
}
|
|
1308
1592
|
/**
|
|
@@ -1316,7 +1600,7 @@ export function dispatchWiredAction(args) {
|
|
|
1316
1600
|
* PIPE-2 design note: Pattern α deliberately does NOT fire
|
|
1317
1601
|
* `submit_action` — a pipe append would queue the gesture for the
|
|
1318
1602
|
* agent's `ggui_consume` long-poll, causing double-processing
|
|
1319
|
-
* (host's tool relay AND agent's reaction).
|
|
1603
|
+
* (host's tool relay AND agent's reaction). RenderInspector loses
|
|
1320
1604
|
* Pattern α observability for now; re-add via a dedicated audit-only
|
|
1321
1605
|
* gesture kind if operators need it.
|
|
1322
1606
|
*
|
|
@@ -1360,11 +1644,28 @@ export function fireDirectToolCall(args) {
|
|
|
1360
1644
|
*
|
|
1361
1645
|
* @internal — exported for unit tests + production reuse.
|
|
1362
1646
|
*/
|
|
1647
|
+
export function resolveDispatchToolName() {
|
|
1648
|
+
// Operator escape hatch — `window.__GGUI_DISPATCH_TOOL__` lets a
|
|
1649
|
+
// host override the receiver tool name when running ggui under a
|
|
1650
|
+
// server that publishes the spec-canonical receiver under a
|
|
1651
|
+
// different name (the agent SDK extension surface). Default:
|
|
1652
|
+
// `ggui_runtime_submit_action` per the OSS handler at
|
|
1653
|
+
// `@ggui-ai/mcp-server-handlers::createGguiSubmitActionHandler`.
|
|
1654
|
+
if (typeof window === 'undefined')
|
|
1655
|
+
return 'ggui_runtime_submit_action';
|
|
1656
|
+
const override = window.__GGUI_DISPATCH_TOOL__;
|
|
1657
|
+
return typeof override === 'string' && override.length > 0
|
|
1658
|
+
? override
|
|
1659
|
+
: 'ggui_runtime_submit_action';
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* @internal — exported for unit tests + production reuse.
|
|
1663
|
+
*/
|
|
1363
1664
|
export function routeDispatch(args) {
|
|
1364
|
-
const { actionName, data,
|
|
1365
|
-
const tool =
|
|
1665
|
+
const { actionName, data, meta, dispatchToolName } = args;
|
|
1666
|
+
const tool = meta.actionNextSteps?.[actionName];
|
|
1366
1667
|
const appCallable = typeof tool === 'string' &&
|
|
1367
|
-
(
|
|
1668
|
+
(meta.appCallableTools ?? []).includes(tool);
|
|
1368
1669
|
if (appCallable && tool !== undefined) {
|
|
1369
1670
|
fireDirectToolCall({
|
|
1370
1671
|
targetToolName: tool,
|
|
@@ -1372,20 +1673,18 @@ export function routeDispatch(args) {
|
|
|
1372
1673
|
});
|
|
1373
1674
|
}
|
|
1374
1675
|
else {
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
// `
|
|
1676
|
+
// Pattern β: enqueue the gesture onto the render's pending-event
|
|
1677
|
+
// pipe via `submit_action`. The agent retrieves it EXCLUSIVELY via
|
|
1678
|
+
// `ggui_consume` — the gesture never travels inline, so there is no
|
|
1679
|
+
// `nextStep` hint to forward (the doorbell carries only a pointer to
|
|
1680
|
+
// the render). This branch is also where
|
|
1681
|
+
// `actionSpec[*].dispatch.kind === 'agent'` lands.
|
|
1381
1682
|
dispatchWiredAction({
|
|
1382
1683
|
toolName: dispatchToolName,
|
|
1383
1684
|
intent: actionName,
|
|
1384
1685
|
data,
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
appId: bootstrap.appId,
|
|
1388
|
-
...(tool !== undefined ? { nextStep: tool } : {}),
|
|
1686
|
+
renderId: meta.renderId,
|
|
1687
|
+
appId: meta.appId,
|
|
1389
1688
|
});
|
|
1390
1689
|
}
|
|
1391
1690
|
}
|
|
@@ -1398,7 +1697,7 @@ export function routeDispatch(args) {
|
|
|
1398
1697
|
export function openLinkInParent(args) {
|
|
1399
1698
|
if (typeof window === 'undefined')
|
|
1400
1699
|
return;
|
|
1401
|
-
const { toolName, url,
|
|
1700
|
+
const { toolName, url, renderId, appId } = args;
|
|
1402
1701
|
if (typeof url !== 'string' || url.length === 0) {
|
|
1403
1702
|
throw new RangeError('wire.openLink(url): `url` must be a non-empty string.');
|
|
1404
1703
|
}
|
|
@@ -1408,18 +1707,12 @@ export function openLinkInParent(args) {
|
|
|
1408
1707
|
toolName,
|
|
1409
1708
|
kind: 'openLink',
|
|
1410
1709
|
payload: { url },
|
|
1411
|
-
|
|
1412
|
-
stackItemId,
|
|
1710
|
+
renderId,
|
|
1413
1711
|
appId,
|
|
1414
1712
|
actionId,
|
|
1415
1713
|
firedAt,
|
|
1416
1714
|
});
|
|
1417
|
-
|
|
1418
|
-
jsonrpc: '2.0',
|
|
1419
|
-
id: Math.floor(Math.random() * 1e9),
|
|
1420
|
-
method: 'ui/open-link',
|
|
1421
|
-
params: { url },
|
|
1422
|
-
});
|
|
1715
|
+
callAppOpenLink({ url });
|
|
1423
1716
|
}
|
|
1424
1717
|
/**
|
|
1425
1718
|
* `ui/request-display-mode` direct dispatch helper — used by the
|
|
@@ -1431,25 +1724,19 @@ export function openLinkInParent(args) {
|
|
|
1431
1724
|
export function requestDisplayModeInParent(args) {
|
|
1432
1725
|
if (typeof window === 'undefined')
|
|
1433
1726
|
return;
|
|
1434
|
-
const { toolName, mode,
|
|
1727
|
+
const { toolName, mode, renderId, appId } = args;
|
|
1435
1728
|
const firedAt = new Date().toISOString();
|
|
1436
1729
|
const actionId = fnv1aHex(`requestDisplayMode|${mode}|${firedAt}`);
|
|
1437
1730
|
emitAudit({
|
|
1438
1731
|
toolName,
|
|
1439
1732
|
kind: 'requestDisplayMode',
|
|
1440
1733
|
payload: { mode },
|
|
1441
|
-
|
|
1442
|
-
stackItemId,
|
|
1734
|
+
renderId,
|
|
1443
1735
|
appId,
|
|
1444
1736
|
actionId,
|
|
1445
1737
|
firedAt,
|
|
1446
1738
|
});
|
|
1447
|
-
|
|
1448
|
-
jsonrpc: '2.0',
|
|
1449
|
-
id: Math.floor(Math.random() * 1e9),
|
|
1450
|
-
method: 'ui/request-display-mode',
|
|
1451
|
-
params: { mode },
|
|
1452
|
-
});
|
|
1739
|
+
callAppRequestDisplayMode({ mode });
|
|
1453
1740
|
}
|
|
1454
1741
|
/**
|
|
1455
1742
|
* Native-idiom interceptor for anchor clicks.
|
|
@@ -1494,7 +1781,7 @@ export function installAnchorClickInterceptor(args) {
|
|
|
1494
1781
|
if (typeof document === 'undefined')
|
|
1495
1782
|
return;
|
|
1496
1783
|
anchorClickInterceptInstalled = true;
|
|
1497
|
-
const { dispatchToolName,
|
|
1784
|
+
const { dispatchToolName, renderId, appId } = args;
|
|
1498
1785
|
const onClick = (event) => {
|
|
1499
1786
|
if (event.defaultPrevented)
|
|
1500
1787
|
return;
|
|
@@ -1539,8 +1826,7 @@ export function installAnchorClickInterceptor(args) {
|
|
|
1539
1826
|
openLinkInParent({
|
|
1540
1827
|
toolName: dispatchToolName,
|
|
1541
1828
|
url: absoluteHref,
|
|
1542
|
-
|
|
1543
|
-
stackItemId,
|
|
1829
|
+
renderId,
|
|
1544
1830
|
appId,
|
|
1545
1831
|
});
|
|
1546
1832
|
};
|
|
@@ -1580,7 +1866,7 @@ export function installAnchorClickInterceptor(args) {
|
|
|
1580
1866
|
*
|
|
1581
1867
|
* Idempotent: the {@link fullscreenInterceptInstalled} guard prevents
|
|
1582
1868
|
* re-mounts from chaining wrappers (which would also leak the prior
|
|
1583
|
-
* mount's `
|
|
1869
|
+
* mount's `renderId`/`appId` if they ever differed).
|
|
1584
1870
|
*/
|
|
1585
1871
|
/** @internal — exported for unit tests. */
|
|
1586
1872
|
export function installFullscreenInterceptors(args) {
|
|
@@ -1592,13 +1878,12 @@ export function installFullscreenInterceptors(args) {
|
|
|
1592
1878
|
return;
|
|
1593
1879
|
}
|
|
1594
1880
|
fullscreenInterceptInstalled = true;
|
|
1595
|
-
const { dispatchToolName,
|
|
1881
|
+
const { dispatchToolName, renderId, appId } = args;
|
|
1596
1882
|
Element.prototype.requestFullscreen = function (_options) {
|
|
1597
1883
|
requestDisplayModeInParent({
|
|
1598
1884
|
toolName: dispatchToolName,
|
|
1599
1885
|
mode: 'fullscreen',
|
|
1600
|
-
|
|
1601
|
-
stackItemId,
|
|
1886
|
+
renderId,
|
|
1602
1887
|
appId,
|
|
1603
1888
|
});
|
|
1604
1889
|
return Promise.resolve();
|
|
@@ -1607,8 +1892,7 @@ export function installFullscreenInterceptors(args) {
|
|
|
1607
1892
|
requestDisplayModeInParent({
|
|
1608
1893
|
toolName: dispatchToolName,
|
|
1609
1894
|
mode: 'inline',
|
|
1610
|
-
|
|
1611
|
-
stackItemId,
|
|
1895
|
+
renderId,
|
|
1612
1896
|
appId,
|
|
1613
1897
|
});
|
|
1614
1898
|
return Promise.resolve();
|
|
@@ -1625,533 +1909,155 @@ export function __resetInterceptorsForTest() {
|
|
|
1625
1909
|
fullscreenInterceptInstalled = false;
|
|
1626
1910
|
}
|
|
1627
1911
|
/**
|
|
1628
|
-
* Install a persistent `
|
|
1629
|
-
* `ui/notifications/tool-result` notifications arriving
|
|
1630
|
-
* initial mount. Each new tool-result that carries a
|
|
1631
|
-
* bootstrap (different
|
|
1632
|
-
* re-mount via {@link bootSelfContained}. This closes the boot-only-
|
|
1912
|
+
* Install a persistent `app.addEventListener('toolresult', …)` listener
|
|
1913
|
+
* that catches `ui/notifications/tool-result` notifications arriving
|
|
1914
|
+
* AFTER the initial mount. Each new tool-result that carries a
|
|
1915
|
+
* different bootstrap (different renderId / codeUrl / kind) triggers
|
|
1916
|
+
* a re-mount via {@link bootSelfContained}. This closes the boot-only-
|
|
1633
1917
|
* listener gap that prevented live re-render when an agent issued
|
|
1634
|
-
* a second `
|
|
1918
|
+
* a second `ggui_render` to the same render-resource.
|
|
1635
1919
|
*
|
|
1636
1920
|
* Idempotent: subsequent calls no-op via {@link postMountListenerInstalled}.
|
|
1637
1921
|
*
|
|
1638
|
-
* Why module-level vs scoped to a single mount: bootSelfContained
|
|
1639
|
-
*
|
|
1640
|
-
* single mount cycle. The
|
|
1922
|
+
* Why module-level vs scoped to a single mount: bootSelfContained may
|
|
1923
|
+
* itself trigger a re-mount, and the listener should outlive any
|
|
1924
|
+
* single mount cycle. The guard ensures we don't stack listeners
|
|
1641
1925
|
* across re-mounts.
|
|
1926
|
+
*
|
|
1927
|
+
* Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
|
|
1928
|
+
* `window.addEventListener('message', …)` is gone; App handles every
|
|
1929
|
+
* inbound `ui/notifications/tool-result` envelope and dispatches via
|
|
1930
|
+
* its event system. Per-iframe single-tenancy means the App handle
|
|
1931
|
+
* comes from `getCurrentApp()` — set by bootSelfContained's own
|
|
1932
|
+
* connect path before this helper runs.
|
|
1642
1933
|
*/
|
|
1643
1934
|
function installPostMountListener() {
|
|
1644
1935
|
if (postMountListenerInstalled)
|
|
1645
1936
|
return;
|
|
1646
|
-
|
|
1647
|
-
|
|
1937
|
+
const app = getCurrentApp();
|
|
1938
|
+
if (app === null)
|
|
1939
|
+
return; // pre-connect; caller bug, swallow safely
|
|
1648
1940
|
postMountListenerInstalled = true;
|
|
1649
|
-
let
|
|
1650
|
-
|
|
1651
|
-
const
|
|
1652
|
-
if (
|
|
1653
|
-
data === undefined ||
|
|
1654
|
-
data.jsonrpc !== '2.0' ||
|
|
1655
|
-
data.method !== 'ui/notifications/tool-result') {
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1658
|
-
const bootstrap = extractBootstrapFromToolResult(data.params);
|
|
1659
|
-
if (bootstrap === null)
|
|
1941
|
+
let lastMetaKey = null;
|
|
1942
|
+
app.addEventListener('toolresult', (params) => {
|
|
1943
|
+
const meta = extractMetaFromToolResult(params);
|
|
1944
|
+
if (meta === null)
|
|
1660
1945
|
return;
|
|
1661
1946
|
// Cheap dedupe — the host may emit the same tool-result more
|
|
1662
1947
|
// than once (claude.ai re-broadcasts on iframe re-attach).
|
|
1663
|
-
// Re-mounting the same
|
|
1948
|
+
// Re-mounting the same slice meta would flicker without changing
|
|
1664
1949
|
// anything visible.
|
|
1665
1950
|
//
|
|
1666
1951
|
// `liveTrio` is load-bearing in the dedupe key: hosts (sample-agent,
|
|
1667
|
-
// claude.ai) often emit the initial
|
|
1952
|
+
// claude.ai) often emit the initial meta WITHOUT the wsUrl+token
|
|
1668
1953
|
// pair (the Anthropic SDK strips `_meta` from tool results), then
|
|
1669
1954
|
// refetch + re-emit the FULL envelope. The two envelopes share
|
|
1670
|
-
//
|
|
1955
|
+
// renderId/kind/codeUrl/propsJson but differ on the live trio —
|
|
1671
1956
|
// without trio in the key, the second arrival deduped silently and
|
|
1672
1957
|
// bootSelfContained never opened the WS, so `ggui_update` props_update
|
|
1673
1958
|
// frames fanned to zero subscribers.
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
bootstrap.token.length > 0
|
|
1959
|
+
const liveTrio = typeof meta.wsUrl === 'string' &&
|
|
1960
|
+
meta.wsUrl.length > 0 &&
|
|
1961
|
+
typeof meta.wsToken === 'string' &&
|
|
1962
|
+
meta.wsToken.length > 0
|
|
1679
1963
|
? 'live'
|
|
1680
1964
|
: '-';
|
|
1681
1965
|
const key = [
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
codeUrl ?? '-',
|
|
1685
|
-
|
|
1966
|
+
meta.renderId,
|
|
1967
|
+
meta.kind ?? '-',
|
|
1968
|
+
meta.codeUrl ?? '-',
|
|
1969
|
+
meta.propsJson ?? '-',
|
|
1686
1970
|
liveTrio,
|
|
1687
1971
|
].join('|');
|
|
1688
|
-
if (key ===
|
|
1972
|
+
if (key === lastMetaKey)
|
|
1689
1973
|
return;
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
//
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
appId: bootstrap.appId,
|
|
1712
|
-
stackItemId: bootstrap.stackItemId,
|
|
1713
|
-
}
|
|
1714
|
-
: undefined;
|
|
1715
|
-
reemitLastContextValues(postToParent, activeSlotNames, reemitIdentity);
|
|
1974
|
+
lastMetaKey = key;
|
|
1975
|
+
// Re-mount through the EXISTING renderer's `applyRender` (published
|
|
1976
|
+
// module-level by bootProduction's setup) — NOT a fresh boot. One
|
|
1977
|
+
// mount surface, one WS; the prior stale-closure / second-WS race
|
|
1978
|
+
// (the #290 root cause) is structurally impossible. Project the new
|
|
1979
|
+
// tool-result meta into a seed and re-apply it.
|
|
1980
|
+
const apply = activeApplyRender;
|
|
1981
|
+
if (apply === null)
|
|
1982
|
+
return; // no renderer published yet — no-op safely
|
|
1983
|
+
void buildRenderSeedInput(meta).then((seed) => {
|
|
1984
|
+
if (seed === null)
|
|
1985
|
+
return;
|
|
1986
|
+
void apply(seed).then(() => {
|
|
1987
|
+
// Re-emit last-known contextSpec values after a successful
|
|
1988
|
+
// re-mount — keeps the LLM context fresh after a host-driven
|
|
1989
|
+
// reconnect; the new mount's SingleSlotProviders take over via
|
|
1990
|
+
// the regular debounced flow once the component mutates values.
|
|
1991
|
+
// Filter to slot names declared by the FRESHLY mounted contract.
|
|
1992
|
+
const activeSlotNames = new Set((meta.contextSlots ?? []).map((s) => s.name));
|
|
1993
|
+
reemitLastContextValues(productionContextSnapshotPoster, activeSlotNames, { renderId: meta.renderId, appId: meta.appId });
|
|
1994
|
+
});
|
|
1716
1995
|
});
|
|
1717
|
-
};
|
|
1718
|
-
window.addEventListener('message', onMessage);
|
|
1996
|
+
});
|
|
1719
1997
|
}
|
|
1720
1998
|
/**
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
* once the bootstrap is in hand.
|
|
1999
|
+
* Detect a live-channel bootstrap shape inlined onto `__GGUI_META__`.
|
|
2000
|
+
* The first-party render shells (`/r/<shortCode>`,
|
|
2001
|
+
* `ui://ggui/render/<renderId>`, the embedded-ui RenderViewer's thin
|
|
2002
|
+
* shell) populate this synchronously before the runtime loads.
|
|
1726
2003
|
*
|
|
1727
|
-
*
|
|
1728
|
-
* `
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
* still observe the terminal state.
|
|
1732
|
-
*
|
|
1733
|
-
* The implementation lives next to `bootProduction` so dynamic-imports
|
|
1734
|
-
* for the heavy module graph happen exactly once across both code
|
|
1735
|
-
* paths — the postMessage path's `import('react')` etc. resolves
|
|
1736
|
-
* against the same module instance.
|
|
1737
|
-
*/
|
|
1738
|
-
async function bootSelfContained(doc, bootstrap) {
|
|
1739
|
-
// Lifecycle: `mounting` first, paired with `ggui:renderer-ready` so
|
|
1740
|
-
// outer-DOM observers see the same sequence as the postMessage path.
|
|
1741
|
-
postRendererReady();
|
|
1742
|
-
postLifecycleToParent(makeLifecycleEvent('mounting'));
|
|
1743
|
-
// Resolve the mount container. Hosts inline `<div id="ggui-root">`
|
|
1744
|
-
// in the self-contained shell; if absent (defensive), append one to
|
|
1745
|
-
// the body so the React root has somewhere to mount.
|
|
1746
|
-
let container = doc.getElementById('ggui-root');
|
|
1747
|
-
if (container === null) {
|
|
1748
|
-
container = doc.createElement('div');
|
|
1749
|
-
container.id = 'ggui-root';
|
|
1750
|
-
doc.body.appendChild(container);
|
|
1751
|
-
}
|
|
1752
|
-
try {
|
|
1753
|
-
// Install precompiled, eval-free contract validators before any
|
|
1754
|
-
// wire dispatch can run. Self-contained ESM modules — no bundler /
|
|
1755
|
-
// `__ggui__` dependency. System-card bootstraps carry none (the
|
|
1756
|
-
// loader returns the empty set); harmless to call unconditionally.
|
|
1757
|
-
setActiveValidatorSet(await loadCompiledValidators(bootstrap.compiledValidators));
|
|
1758
|
-
// Parse propsJson up-front — both branches (system card + compiled
|
|
1759
|
-
// component) consume props from the same field, just in different
|
|
1760
|
-
// shapes downstream.
|
|
1761
|
-
let props;
|
|
1762
|
-
if (bootstrap.propsJson !== undefined) {
|
|
1763
|
-
try {
|
|
1764
|
-
const parsed = JSON.parse(bootstrap.propsJson);
|
|
1765
|
-
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1766
|
-
props = parsed;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
catch {
|
|
1770
|
-
// Malformed propsJson is shape-preserving-skip.
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
// System-card branch — kind keyed against the built-in registry.
|
|
1774
|
-
// No componentCode evaluation, no `globalThis.__ggui__` shim install,
|
|
1775
|
-
// no GguiWireProvider (system cards are render-only and don't
|
|
1776
|
-
// dispatch wire actions). The registry handles unknown kinds via
|
|
1777
|
-
// a typed fallback, so a new server emitting a kind this runtime
|
|
1778
|
-
// doesn't know still surfaces something visible.
|
|
1779
|
-
if (bootstrap.kind !== undefined) {
|
|
1780
|
-
const [reactMod, reactDomClient, systemCardsMod] = await Promise.all([
|
|
1781
|
-
import('react'),
|
|
1782
|
-
import('react-dom/client'),
|
|
1783
|
-
import('./system-cards/index.js'),
|
|
1784
|
-
]);
|
|
1785
|
-
const root = reactDomClient.createRoot(container);
|
|
1786
|
-
root.render(reactMod.createElement(systemCardsMod.SystemCardHost, { kind: bootstrap.kind, props: props ?? {}, themeId: bootstrap.themeId }));
|
|
1787
|
-
postLifecycleToParent(makeLifecycleEvent('code-ready', {
|
|
1788
|
-
...(bootstrap.stackItemId !== undefined
|
|
1789
|
-
? { stackItemId: bootstrap.stackItemId }
|
|
1790
|
-
: {}),
|
|
1791
|
-
}));
|
|
1792
|
-
installResizeObserver(container);
|
|
1793
|
-
installPostMountListener();
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
// Compiled-component branch. Fetch the bytes from `codeUrl`
|
|
1797
|
-
// (content-addressable, immutable cache). T3-1 (2026-05-13) retired
|
|
1798
|
-
// the inline base64 `componentCode` channel — every static-component
|
|
1799
|
-
// bootstrap is delivered via the URL.
|
|
1800
|
-
if (bootstrap.codeUrl === undefined) {
|
|
1801
|
-
throw new Error('bootSelfContained: bootstrap missing codeUrl (static-component mode requires the URL channel)');
|
|
1802
|
-
}
|
|
1803
|
-
const res = await fetch(bootstrap.codeUrl);
|
|
1804
|
-
if (!res.ok) {
|
|
1805
|
-
throw new Error(`bootSelfContained: codeUrl fetch failed (${res.status}): ${bootstrap.codeUrl}`);
|
|
1806
|
-
}
|
|
1807
|
-
const componentCode = await res.text();
|
|
1808
|
-
// Dynamic-imports — same set as `bootProduction`, kept here so a
|
|
1809
|
-
// postMessage path running in parallel hits the module cache.
|
|
1810
|
-
const [reactMod, reactDomClient, designPrimitives, designComponents, designCompositions, designInteract, designTokens, wireMod, gadgetsMod,] = await Promise.all([
|
|
1811
|
-
import('react'),
|
|
1812
|
-
import('react-dom/client'),
|
|
1813
|
-
import('@ggui-ai/design/primitives'),
|
|
1814
|
-
import('@ggui-ai/design/components'),
|
|
1815
|
-
import('@ggui-ai/design/compositions'),
|
|
1816
|
-
import('@ggui-ai/design/interact'),
|
|
1817
|
-
import('@ggui-ai/design/tokens'),
|
|
1818
|
-
import('@ggui-ai/wire'),
|
|
1819
|
-
// Pre-load STDLIB gadget hooks so the data-URL shim resolves
|
|
1820
|
-
// `import { useGeolocation } from '@ggui-ai/gadgets'` to a real
|
|
1821
|
-
// callable at iframe boot. Without this, `__ggui__.gadgets` is
|
|
1822
|
-
// empty and every hook lookup returns undefined, crashing the
|
|
1823
|
-
// component.
|
|
1824
|
-
import('@ggui-ai/gadgets'),
|
|
1825
|
-
]);
|
|
1826
|
-
const [globalsMod, reactRendererMod, gadgetLoaderMod] = await Promise.all([
|
|
1827
|
-
import('./globals.js'),
|
|
1828
|
-
import('./react-renderer.js'),
|
|
1829
|
-
import('./gadget-loader.js'),
|
|
1830
|
-
]);
|
|
1831
|
-
// Compose the gadget-package registry: STDLIB seed PLUS any
|
|
1832
|
-
// operator-registered packages from the bootstrap. This path is
|
|
1833
|
-
// async, so we await the merge BEFORE installing — the per-package
|
|
1834
|
-
// data-URL shims see a fully-populated catalog from first render.
|
|
1835
|
-
// (The WS-driven path uses an in-place mutation fallback because
|
|
1836
|
-
// its `setup` callback is synchronous.)
|
|
1837
|
-
const composedGadgets = await gadgetLoaderMod.loadGadgetRegistry(gadgetsMod, bootstrap.gadgets ?? []);
|
|
1838
|
-
// Install `globalThis.__ggui__` BEFORE mountReactRoot — the data-
|
|
1839
|
-
// URL shim rewrite reads it synchronously during module load.
|
|
1840
|
-
// Same TOCTOU-critical ordering as the WS-driven path.
|
|
1841
|
-
globalsMod.installGlobalRegistry({
|
|
1842
|
-
react: reactMod,
|
|
1843
|
-
reactDom: reactDomClient,
|
|
1844
|
-
primitives: designPrimitives,
|
|
1845
|
-
components: designComponents,
|
|
1846
|
-
compositions: designCompositions,
|
|
1847
|
-
interact: designInteract,
|
|
1848
|
-
tokens: designTokens,
|
|
1849
|
-
wire: wireMod,
|
|
1850
|
-
// Per-package gadget registry — STDLIB namespace merged with
|
|
1851
|
-
// operator-registered packages (composed above).
|
|
1852
|
-
gadgets: composedGadgets,
|
|
1853
|
-
// Server-filtered public env values for wrapper hooks to read
|
|
1854
|
-
// via `getPublicEnv(key)`. Absent ⇒ empty record; wrappers
|
|
1855
|
-
// needing values throw at hook-mount.
|
|
1856
|
-
publicEnv: bootstrap.publicEnv ?? {},
|
|
1857
|
-
});
|
|
1858
|
-
// Synthesize one React.createContext(default) per declared
|
|
1859
|
-
// contextSpec slot and register under
|
|
1860
|
-
// `globalThis.__ggui__.contexts[contextName]`. Idempotent: already-
|
|
1861
|
-
// registered context names REUSE the existing Context (the LLM's
|
|
1862
|
-
// destructured references must stay stable across re-mounts). Boot-
|
|
1863
|
-
// ordering: AFTER installGlobalRegistry, BEFORE mountReactRoot, so
|
|
1864
|
-
// the boilerplate's `globalThis.__ggui__.contexts` destructure
|
|
1865
|
-
// resolves on the first mount paint.
|
|
1866
|
-
const registry = globalsMod.getGlobalRegistry();
|
|
1867
|
-
const resolvedSlots = registry !== undefined && bootstrap.contextSlots !== undefined
|
|
1868
|
-
? installContextRegistry(registry.contexts, reactMod, bootstrap.contextSlots)
|
|
1869
|
-
: [];
|
|
1870
|
-
// Build a minimal WireConfig and pass it through `renderWrapper` so
|
|
1871
|
-
// generated components that call `useAction` / `useStream` resolve
|
|
1872
|
-
// their context. Without this, every self-contained mount whose
|
|
1873
|
-
// component touches `@ggui-ai/wire` throws "useWireContext must be
|
|
1874
|
-
// used within a WireProvider".
|
|
1875
|
-
//
|
|
1876
|
-
// Dispatch funnels through {@link dispatchWiredAction} — the
|
|
1877
|
-
// empirically-validated three-message bridge (`tools/call` +
|
|
1878
|
-
// `ui/update-model-context` + `ui/message`). See helper docstring
|
|
1879
|
-
// for the spec §401 / §1032 reasoning. An earlier single-message
|
|
1880
|
-
// dispatch only fired #1 and the LLM never saw the click on
|
|
1881
|
-
// claude.ai (the host-side scope:['app'] firewall blocks
|
|
1882
|
-
// tool-result feedback from reaching the model). Empirically
|
|
1883
|
-
// confirmed in a protocol probe.
|
|
1884
|
-
const dispatchToolName = typeof window !== 'undefined' &&
|
|
1885
|
-
typeof window.__GGUI_DISPATCH_TOOL__ === 'string'
|
|
1886
|
-
? window
|
|
1887
|
-
.__GGUI_DISPATCH_TOOL__
|
|
1888
|
-
: 'ggui_runtime_submit_action';
|
|
1889
|
-
const wireConfig = {
|
|
1890
|
-
app: { appId: bootstrap.appId, appName: bootstrap.appId },
|
|
1891
|
-
session: { sessionId: bootstrap.sessionId, isConnected: true },
|
|
1892
|
-
auth: { isAuthenticated: false },
|
|
1893
|
-
dispatch: (actionName, data) => {
|
|
1894
|
-
// Per-action routing — extracted to {@link routeDispatch} as
|
|
1895
|
-
// a pure helper so production + the dispatch-routing unit
|
|
1896
|
-
// tests share one code path. Pattern α: same-server,
|
|
1897
|
-
// app-visible target tool → direct `tools/call`. Pattern β:
|
|
1898
|
-
// anything else → 3-message bridge.
|
|
1899
|
-
routeDispatch({
|
|
1900
|
-
actionName,
|
|
1901
|
-
data,
|
|
1902
|
-
bootstrap: {
|
|
1903
|
-
sessionId: bootstrap.sessionId,
|
|
1904
|
-
appId: bootstrap.appId,
|
|
1905
|
-
...(bootstrap.stackItemId !== undefined
|
|
1906
|
-
? { stackItemId: bootstrap.stackItemId }
|
|
1907
|
-
: {}),
|
|
1908
|
-
...(bootstrap.appCallableTools !== undefined
|
|
1909
|
-
? { appCallableTools: bootstrap.appCallableTools }
|
|
1910
|
-
: {}),
|
|
1911
|
-
...(bootstrap.actionNextSteps !== undefined
|
|
1912
|
-
? { actionNextSteps: bootstrap.actionNextSteps }
|
|
1913
|
-
: {}),
|
|
1914
|
-
},
|
|
1915
|
-
dispatchToolName,
|
|
1916
|
-
});
|
|
1917
|
-
},
|
|
1918
|
-
// Subscribe + wired-tools are no-ops in self-contained mode
|
|
1919
|
-
// (no live channel). Generated components that subscribe
|
|
1920
|
-
// receive nothing; the contract stays honest.
|
|
1921
|
-
subscribe: () => () => { },
|
|
1922
|
-
// `callWiredTool` is retired — `agentTools` is now a catalog
|
|
1923
|
-
// the AGENT invokes, not a component hook surface.
|
|
1924
|
-
// The `openLink` and `requestDisplayMode` host-control primitives
|
|
1925
|
-
// ride native idioms (anchor click + Element.requestFullscreen) —
|
|
1926
|
-
// see `installAnchorClickInterceptor` and
|
|
1927
|
-
// `installFullscreenInterceptors` below. No first-class
|
|
1928
|
-
// chat-shortcut primitive in v1; chat-shortcut UX degrades to the
|
|
1929
|
-
// Pattern β consent prompt.
|
|
1930
|
-
};
|
|
1931
|
-
// Runtime owns Provider tree + useState per slot. ContextStateHost
|
|
1932
|
-
// composes one SingleSlotProvider per declared slot around the
|
|
1933
|
-
// user's component, so the user's `useGguiContext` reads each
|
|
1934
|
-
// slot's live `[value, setValue]` tuple. An earlier design had
|
|
1935
|
-
// the boilerplate emit useState + Provider INSIDE the user
|
|
1936
|
-
// component while the runtime mounted observers as SIBLINGS —
|
|
1937
|
-
// every observer read the createContext default, never the live
|
|
1938
|
-
// state. Hoisting both useState and Provider into the runtime
|
|
1939
|
-
// makes the boilerplate's destructure line resolve to the live
|
|
1940
|
-
// tuple by construction.
|
|
1941
|
-
const ContextStateHost = createContextStateHost({
|
|
1942
|
-
react: reactMod,
|
|
1943
|
-
postToParent,
|
|
1944
|
-
consoleWarn: typeof console !== 'undefined' && typeof console.warn === 'function'
|
|
1945
|
-
? console.warn.bind(console)
|
|
1946
|
-
: undefined,
|
|
1947
|
-
...(bootstrap.stackItemId !== undefined
|
|
1948
|
-
? {
|
|
1949
|
-
identity: {
|
|
1950
|
-
sessionId: bootstrap.sessionId,
|
|
1951
|
-
appId: bootstrap.appId,
|
|
1952
|
-
stackItemId: bootstrap.stackItemId,
|
|
1953
|
-
},
|
|
1954
|
-
}
|
|
1955
|
-
: {}),
|
|
1956
|
-
});
|
|
1957
|
-
const renderWrapper = (mountedComponent) => reactMod.createElement(wireMod.GguiWireProvider, {
|
|
1958
|
-
config: wireConfig,
|
|
1959
|
-
children: reactMod.createElement(ContextStateHost, {
|
|
1960
|
-
slots: resolvedSlots,
|
|
1961
|
-
children: mountedComponent,
|
|
1962
|
-
}),
|
|
1963
|
-
});
|
|
1964
|
-
// Native-idiom interceptors. Install BEFORE mountReactRoot so the
|
|
1965
|
-
// listeners are live the instant any generated component begins
|
|
1966
|
-
// rendering. Both helpers are idempotent (module-level guards) —
|
|
1967
|
-
// re-mounts via `installPostMountListener` re-call this without
|
|
1968
|
-
// stacking.
|
|
1969
|
-
installAnchorClickInterceptor({
|
|
1970
|
-
dispatchToolName,
|
|
1971
|
-
sessionId: bootstrap.sessionId,
|
|
1972
|
-
...(bootstrap.stackItemId !== undefined
|
|
1973
|
-
? { stackItemId: bootstrap.stackItemId }
|
|
1974
|
-
: {}),
|
|
1975
|
-
appId: bootstrap.appId,
|
|
1976
|
-
});
|
|
1977
|
-
installFullscreenInterceptors({
|
|
1978
|
-
dispatchToolName,
|
|
1979
|
-
sessionId: bootstrap.sessionId,
|
|
1980
|
-
...(bootstrap.stackItemId !== undefined
|
|
1981
|
-
? { stackItemId: bootstrap.stackItemId }
|
|
1982
|
-
: {}),
|
|
1983
|
-
appId: bootstrap.appId,
|
|
1984
|
-
});
|
|
1985
|
-
const mountOpts = {
|
|
1986
|
-
stackItem: {
|
|
1987
|
-
...(bootstrap.stackItemId !== undefined ? { id: bootstrap.stackItemId } : {}),
|
|
1988
|
-
componentCode,
|
|
1989
|
-
...(props !== undefined ? { props } : {}),
|
|
1990
|
-
},
|
|
1991
|
-
renderWrapper,
|
|
1992
|
-
...(bootstrap.themeId !== undefined ? { themeId: bootstrap.themeId } : {}),
|
|
1993
|
-
...(bootstrap.themeMode !== undefined ? { themeMode: bootstrap.themeMode } : {}),
|
|
1994
|
-
// GG.8.2 — operator-registered 3rd-party gadget packages so the
|
|
1995
|
-
// rewriter resolves each direct gadget import to its per-package
|
|
1996
|
-
// shim. STDLIB `@ggui-ai/gadgets` is always rewritten regardless.
|
|
1997
|
-
...(bootstrap.gadgets !== undefined
|
|
1998
|
-
? { gadgetPackages: bootstrap.gadgets.map((g) => g.package) }
|
|
1999
|
-
: {}),
|
|
2000
|
-
};
|
|
2001
|
-
const mount = await reactRendererMod.mountReactRoot(container, mountOpts);
|
|
2002
|
-
postLifecycleToParent(makeLifecycleEvent('code-ready', {
|
|
2003
|
-
...(bootstrap.stackItemId !== undefined
|
|
2004
|
-
? { stackItemId: bootstrap.stackItemId }
|
|
2005
|
-
: {}),
|
|
2006
|
-
}));
|
|
2007
|
-
installResizeObserver(container);
|
|
2008
|
-
installPostMountListener();
|
|
2009
|
-
// B4 — when the bootstrap carries a live trio (wsUrl + token),
|
|
2010
|
-
// open a WS subscription so `ggui_update` props_update frames
|
|
2011
|
-
// reach the iframe. Without this the McpAppIframe-nested mount
|
|
2012
|
-
// never subscribed and `ggui_update` silently dropped on the
|
|
2013
|
-
// server's outbound side.
|
|
2014
|
-
//
|
|
2015
|
-
// Targeted scope: this path mounts a SINGLE component (not the
|
|
2016
|
-
// full stack), so the only frame type we need is `props_update`
|
|
2017
|
-
// matching THIS stackItemId. Other frame types are silently
|
|
2018
|
-
// dropped — no handler registered means the registry's dispatch
|
|
2019
|
-
// is a no-op for them.
|
|
2020
|
-
//
|
|
2021
|
-
// The subscribe call is fire-and-forget; failure is logged but
|
|
2022
|
-
// doesn't break the mount (the static component renders fine
|
|
2023
|
-
// without live updates). When the WS goes down, mount.update()
|
|
2024
|
-
// simply stops firing — gracefully degrades.
|
|
2025
|
-
if (typeof bootstrap.wsUrl === 'string' &&
|
|
2026
|
-
bootstrap.wsUrl.length > 0 &&
|
|
2027
|
-
typeof bootstrap.token === 'string' &&
|
|
2028
|
-
bootstrap.token.length > 0 &&
|
|
2029
|
-
bootstrap.stackItemId !== undefined) {
|
|
2030
|
-
const targetStackItemId = bootstrap.stackItemId;
|
|
2031
|
-
const subscribeRegistry = new ChannelRegistry({
|
|
2032
|
-
subscribeFrameBuilder: () => ({
|
|
2033
|
-
type: 'subscribe',
|
|
2034
|
-
payload: {
|
|
2035
|
-
sessionId: bootstrap.sessionId,
|
|
2036
|
-
appId: bootstrap.appId,
|
|
2037
|
-
bootstrap: bootstrap.token,
|
|
2038
|
-
},
|
|
2039
|
-
}),
|
|
2040
|
-
});
|
|
2041
|
-
// Register only the `props_update` handler — `connectViaRegistry`
|
|
2042
|
-
// registers `ack` + `error` for the handshake. Other frame
|
|
2043
|
-
// types arriving on the WS will be no-ops (no handler).
|
|
2044
|
-
subscribeRegistry.register({
|
|
2045
|
-
type: 'props_update',
|
|
2046
|
-
onMessage: (payload) => {
|
|
2047
|
-
const shaped = payload;
|
|
2048
|
-
if (shaped.stackItemId !== targetStackItemId)
|
|
2049
|
-
return;
|
|
2050
|
-
if (shaped.props === null || typeof shaped.props !== 'object')
|
|
2051
|
-
return;
|
|
2052
|
-
void mount.update({
|
|
2053
|
-
...mountOpts,
|
|
2054
|
-
stackItem: {
|
|
2055
|
-
...mountOpts.stackItem,
|
|
2056
|
-
props: shaped.props,
|
|
2057
|
-
},
|
|
2058
|
-
});
|
|
2059
|
-
},
|
|
2060
|
-
});
|
|
2061
|
-
void connectViaRegistry({
|
|
2062
|
-
bootstrap,
|
|
2063
|
-
registry: subscribeRegistry,
|
|
2064
|
-
onStatusChange: () => {
|
|
2065
|
-
/* no-op — placeholder UI is not used in self-contained mode */
|
|
2066
|
-
},
|
|
2067
|
-
}).catch((err) => {
|
|
2068
|
-
// Self-contained mode tolerates WS failure — the component
|
|
2069
|
-
// still renders with initial props; only live updates stop.
|
|
2070
|
-
// eslint-disable-next-line no-console -- operator-visible degradation hint
|
|
2071
|
-
console.warn('[ggui:bootSelfContained] live-update WS subscribe failed; props_update frames will not reach the mount:', err instanceof Error ? err.message : String(err));
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
catch (err) {
|
|
2076
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2077
|
-
// Reuse the existing failure envelope. The reason is a synthesized
|
|
2078
|
-
// value because this code path is wholly outside the
|
|
2079
|
-
// `BootstrapParseFailureReason` set (no postMessage parse failed
|
|
2080
|
-
// — there was no postMessage). Hosts pattern-matching legacy
|
|
2081
|
-
// reason strings see a new value and ignore it; new hosts can
|
|
2082
|
-
// route on the explicit code.
|
|
2083
|
-
postBootFailure('UI_INITIALIZE_FAILED', `self-contained mount failed: ${message}`);
|
|
2084
|
-
postLifecycleToParent(makeLifecycleEvent('error', {
|
|
2085
|
-
error: { code: 'SELF_CONTAINED_MOUNT_FAILED', message },
|
|
2086
|
-
}));
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
/**
|
|
2090
|
-
* Detect a `GguiBootstrapMeta` shape inlined onto `__GGUI_BOOTSTRAP__`.
|
|
2091
|
-
* The first-party session shells (`/s/<shortCode>`,
|
|
2092
|
-
* `ui://ggui/session/<sessionId>`, the embedded-ui SessionViewer's
|
|
2093
|
-
* thin shell) populate this synchronously before the runtime loads —
|
|
2094
|
-
* but the shape is the WS-driven bootstrap (`wsUrl`+`token`+
|
|
2095
|
-
* `sessionId`+`appId`+`runtimeUrl`), NOT a `SelfContainedBootstrap`.
|
|
2004
|
+
* Post-Phase-B the global carries a slice ENVELOPE (same shape as the
|
|
2005
|
+
* wire `_meta`): `{ "ai.ggui/render": {...} }`. A live-channel shell
|
|
2006
|
+
* omits static content (no codeUrl / kind) and ships wsUrl+token
|
|
2007
|
+
* inside the render slice instead.
|
|
2096
2008
|
*
|
|
2097
2009
|
* This predicate exists so the autostart path can distinguish
|
|
2098
|
-
* "shell-inlined a
|
|
2010
|
+
* "shell-inlined a live bootstrap, run `bootProduction` immediately"
|
|
2099
2011
|
* from "no bootstrap yet, race the tool-result postMessage with a
|
|
2100
2012
|
* 30s timeout". Without the distinction, OSS shells that never emit
|
|
2101
2013
|
* a separate `ui/notifications/tool-result` (because the bootstrap
|
|
2102
2014
|
* is delivered via the `ui/initialize` Reading-B path) hang at
|
|
2103
2015
|
* `mounting` for the full 30s before `code-ready`.
|
|
2104
2016
|
*/
|
|
2105
|
-
function
|
|
2017
|
+
function readLiveBootstrapShape() {
|
|
2106
2018
|
if (typeof window === 'undefined')
|
|
2107
2019
|
return false;
|
|
2108
2020
|
const raw = window
|
|
2109
|
-
.
|
|
2021
|
+
.__GGUI_META__;
|
|
2110
2022
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
2111
2023
|
return false;
|
|
2112
2024
|
}
|
|
2113
2025
|
const bag = raw;
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2026
|
+
const renderRaw = bag['ai.ggui/render'];
|
|
2027
|
+
if (renderRaw === null || typeof renderRaw !== 'object' || Array.isArray(renderRaw)) {
|
|
2028
|
+
return false;
|
|
2029
|
+
}
|
|
2030
|
+
const renderBag = renderRaw;
|
|
2031
|
+
return (typeof renderBag['wsUrl'] === 'string' &&
|
|
2032
|
+
renderBag['wsUrl'].length > 0 &&
|
|
2033
|
+
typeof renderBag['wsToken'] === 'string' &&
|
|
2034
|
+
renderBag['wsToken'].length > 0 &&
|
|
2035
|
+
typeof renderBag['renderId'] === 'string' &&
|
|
2036
|
+
renderBag['renderId'].length > 0 &&
|
|
2037
|
+
typeof renderBag['appId'] === 'string' &&
|
|
2038
|
+
renderBag['appId'].length > 0);
|
|
2122
2039
|
}
|
|
2123
2040
|
/**
|
|
2124
2041
|
* Hand off to `bootProduction` with the standard wiring (postRendererReady
|
|
2125
2042
|
* + postBootFailure + observability + lifecycle). Extracted so both the
|
|
2126
|
-
*
|
|
2043
|
+
* live-channel-inlined fast path and the post-tool-result-timeout
|
|
2127
2044
|
* fallback share one call site — keeps the WS-driven boot semantics
|
|
2128
2045
|
* single-sourced.
|
|
2046
|
+
*
|
|
2047
|
+
* `preResolvedMeta` is threaded in from the autostart resolver when
|
|
2048
|
+
* it has already discovered slice meta — via inline `__GGUI_META__`
|
|
2049
|
+
* global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
|
|
2050
|
+
* early `ui/notifications/tool-result` postMessage. Threading skips
|
|
2051
|
+
* `bootSequence`'s internal resolver chain (which would otherwise
|
|
2052
|
+
* re-await the same postMessage or re-parse the same global), saving
|
|
2053
|
+
* up to the 30s postMessage timeout for spec-strict hosts.
|
|
2129
2054
|
*/
|
|
2130
|
-
function runBootProduction() {
|
|
2131
|
-
const
|
|
2132
|
-
const caller = makeJsonRpcCaller();
|
|
2133
|
-
// Per MCP Apps spec (specification/2026-01-26/apps.mdx:554-563),
|
|
2134
|
-
// ui/initialize params are { appInfo, appCapabilities,
|
|
2135
|
-
// protocolVersion } — all three required. Spec-compliant
|
|
2136
|
-
// hosts (claude.ai's validator) reject empty params with
|
|
2137
|
-
// "Invalid input" for each missing field.
|
|
2138
|
-
return caller('ui/initialize', {
|
|
2139
|
-
appInfo: { name: 'ggui-iframe-runtime', version: '1.0.0' },
|
|
2140
|
-
// Declare the display modes ggui supports so spec-compliant
|
|
2141
|
-
// hosts know `ui/request-display-mode` requests for these
|
|
2142
|
-
// values are honored. The runtime emits `request-display-mode`
|
|
2143
|
-
// from the `Element.requestFullscreen` interceptor + the
|
|
2144
|
-
// canvas-mode display-mode escalation policy; both target this
|
|
2145
|
-
// enum.
|
|
2146
|
-
appCapabilities: {
|
|
2147
|
-
availableDisplayModes: ['inline', 'fullscreen', 'pip'],
|
|
2148
|
-
},
|
|
2149
|
-
protocolVersion: '2026-01-26',
|
|
2150
|
-
});
|
|
2151
|
-
};
|
|
2055
|
+
function runBootProduction(preResolvedMeta) {
|
|
2056
|
+
const { app, transport } = createDefaultApp();
|
|
2152
2057
|
void bootProduction({
|
|
2153
2058
|
doc: document,
|
|
2154
|
-
|
|
2059
|
+
app,
|
|
2060
|
+
transport,
|
|
2155
2061
|
notifyParent: (msg) => {
|
|
2156
2062
|
if (msg.type === 'ggui:renderer-ready') {
|
|
2157
2063
|
postRendererReady();
|
|
@@ -2161,104 +2067,47 @@ function runBootProduction() {
|
|
|
2161
2067
|
},
|
|
2162
2068
|
onObserve: postObservabilityToParent,
|
|
2163
2069
|
onLifecycle: postLifecycleToParent,
|
|
2070
|
+
...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
|
|
2164
2071
|
});
|
|
2165
2072
|
}
|
|
2166
2073
|
if (shouldAutostart() && typeof window !== 'undefined') {
|
|
2167
|
-
// Boot-source resolution
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
//
|
|
2174
|
-
// this synchronously before the runtime loads. Zero round-
|
|
2175
|
-
// trip; no postMessage.
|
|
2074
|
+
// Boot-source resolution — collapsed post-consolidation. The
|
|
2075
|
+
// static-vs-live FORK is gone: every meta flows through the ONE
|
|
2076
|
+
// unified path (`runBootProduction` → `bootProduction` →
|
|
2077
|
+
// `bootSequence`), which decides internally whether to seed-mount
|
|
2078
|
+
// static content (codeUrl/kind), open a WS subscribe (wsUrl+wsToken),
|
|
2079
|
+
// or both. The autostart layer's only job is to RESOLVE the meta from
|
|
2080
|
+
// one of three delivery channels:
|
|
2176
2081
|
//
|
|
2082
|
+
// 1. Inline `__GGUI_META__` global — first-party shells populate
|
|
2083
|
+
// this synchronously before this bundle evaluates.
|
|
2177
2084
|
// 2. Buffered `__GGUI_PENDING_TOOL_RESULTS__` — minimal-shell
|
|
2178
|
-
// pattern
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
//
|
|
2184
|
-
// 3. Live postMessage listener — `awaitToolResultBootstrap`
|
|
2185
|
-
// catches tool-results that arrive AFTER the runtime is
|
|
2186
|
-
// ready (the more common case on Claude Desktop, where the
|
|
2187
|
-
// iframe loads before the agent's tool call resolves).
|
|
2085
|
+
// pattern (postMessage arrived before the bundle parsed).
|
|
2086
|
+
// 3. Live `ui/notifications/tool-result` postMessage — the
|
|
2087
|
+
// spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
|
|
2088
|
+
// ChatGPT, claude.ai). Caught meta threads through as
|
|
2089
|
+
// `preResolvedMeta` so `bootSequence` doesn't re-await it.
|
|
2188
2090
|
//
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
// or transform — they only deliver. That's the cure for the
|
|
2197
|
-
// "shell-side validator lagged the protocol" bug class.
|
|
2198
|
-
// T3-1 (2026-05-13) — bootSelfContained requires static content
|
|
2199
|
-
// (codeUrl or kind) to mount. Live-only bootstraps (wsUrl+token
|
|
2200
|
-
// without static content) MUST go through `bootProduction` so the
|
|
2201
|
-
// stack item arrives via the live-channel WS subscribe. Pre-T3-1 the
|
|
2202
|
-
// inline `componentCode` channel made every push static, so
|
|
2203
|
-
// bootSelfContained always had something to mount; post-drop, we
|
|
2204
|
-
// dispatch on the discriminator explicitly.
|
|
2205
|
-
const inline = readSelfContainedBootstrap();
|
|
2206
|
-
const inlineHasStatic = inline !== null
|
|
2207
|
-
&& (typeof inline.codeUrl === 'string'
|
|
2208
|
-
|| typeof inline.kind === 'string');
|
|
2209
|
-
if (inline !== null && inlineHasStatic) {
|
|
2210
|
-
void bootSelfContained(document, inline);
|
|
2211
|
-
}
|
|
2212
|
-
else if (inline !== null) {
|
|
2213
|
-
// Live-only inline bootstrap — hand to bootProduction so it
|
|
2214
|
-
// re-issues ui/initialize and walks the WS path.
|
|
2215
|
-
runBootProduction();
|
|
2091
|
+
// `readLiveBootstrapShape` still short-circuits the 30s tool-result
|
|
2092
|
+
// wait when a live-channel envelope is already inlined — OSS embedded
|
|
2093
|
+
// shells that never emit a separate tool-result would otherwise hang
|
|
2094
|
+
// at `mounting` for the full timeout.
|
|
2095
|
+
const inline = readSelfContainedMeta();
|
|
2096
|
+
if (inline !== null) {
|
|
2097
|
+
runBootProduction(inline);
|
|
2216
2098
|
}
|
|
2217
2099
|
else {
|
|
2218
2100
|
const buffered = readPendingToolResults();
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|| typeof buffered.kind === 'string');
|
|
2222
|
-
if (buffered !== null && bufferedHasStatic) {
|
|
2223
|
-
void bootSelfContained(document, buffered);
|
|
2101
|
+
if (buffered !== null) {
|
|
2102
|
+
runBootProduction(buffered);
|
|
2224
2103
|
}
|
|
2225
|
-
else if (
|
|
2104
|
+
else if (readLiveBootstrapShape()) {
|
|
2226
2105
|
runBootProduction();
|
|
2227
2106
|
}
|
|
2228
2107
|
else {
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
// `readSelfContainedBootstrap` returned null, but it IS the
|
|
2233
|
-
// signal that a first-party shell (`/s/<shortCode>`,
|
|
2234
|
-
// `ui://ggui/session/<sessionId>`) has already inlined the WS-
|
|
2235
|
-
// driven boot envelope. Skip the 30s tool-result wait and hand
|
|
2236
|
-
// off to `bootProduction` directly — `bootProduction` re-issues
|
|
2237
|
-
// `ui/initialize` to the host, which re-emits the same bootstrap
|
|
2238
|
-
// back via Reading-B, and the WS path proceeds without delay.
|
|
2239
|
-
// Without this short-circuit, the OSS embedded-ui (which never
|
|
2240
|
-
// sends a separate `ui/notifications/tool-result`) hangs at
|
|
2241
|
-
// `mounting` for the full 30s timeout before `code-ready`.
|
|
2242
|
-
const hasGguiBootstrapMeta = readGguiBootstrapMetaShape();
|
|
2243
|
-
if (hasGguiBootstrapMeta) {
|
|
2244
|
-
runBootProduction();
|
|
2245
|
-
}
|
|
2246
|
-
else {
|
|
2247
|
-
// Race a postMessage listener against the legacy production
|
|
2248
|
-
// boot. Tool-result wins if it arrives within the timeout;
|
|
2249
|
-
// otherwise we hand off to the WS-driven path.
|
|
2250
|
-
const POSTMESSAGE_BOOT_TIMEOUT_MS = 30_000;
|
|
2251
|
-
void awaitToolResultBootstrap(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageBootstrap) => {
|
|
2252
|
-
const hasStatic = postMessageBootstrap !== null
|
|
2253
|
-
&& (typeof postMessageBootstrap.codeUrl === 'string'
|
|
2254
|
-
|| typeof postMessageBootstrap.kind === 'string');
|
|
2255
|
-
if (postMessageBootstrap !== null && hasStatic) {
|
|
2256
|
-
void bootSelfContained(document, postMessageBootstrap);
|
|
2257
|
-
return;
|
|
2258
|
-
}
|
|
2259
|
-
runBootProduction();
|
|
2260
|
-
});
|
|
2261
|
-
}
|
|
2108
|
+
void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageMeta) => {
|
|
2109
|
+
runBootProduction(postMessageMeta ?? undefined);
|
|
2110
|
+
});
|
|
2262
2111
|
}
|
|
2263
2112
|
}
|
|
2264
2113
|
}
|
|
@@ -2283,10 +2132,15 @@ async function bootProduction(opts) {
|
|
|
2283
2132
|
import('./globals.js'),
|
|
2284
2133
|
import('./gadget-loader.js'),
|
|
2285
2134
|
]);
|
|
2286
|
-
//
|
|
2287
|
-
// config on demand inside bootSequence.
|
|
2288
|
-
const
|
|
2289
|
-
setup: ({
|
|
2135
|
+
// Renderer wiring hook — constructs buses + single-render mount surface
|
|
2136
|
+
// + wire config on demand inside bootSequence.
|
|
2137
|
+
const renderer = {
|
|
2138
|
+
setup: ({ meta, renderInto, statusRefs, onObserve }) => {
|
|
2139
|
+
// Post-Phase-B `meta` is the flat render slice — `renderId` /
|
|
2140
|
+
// `appId` / `runtimeUrl` / `wsUrl` / `wsToken` / `themeId` /
|
|
2141
|
+
// `gadgets` / `publicEnv` / `contextSlots` / `actionNextSteps` /
|
|
2142
|
+
// `appCallableTools` / `streamWebSocketLocalTools` all live
|
|
2143
|
+
// directly on `meta`.
|
|
2290
2144
|
// Compose the gadget registry: STDLIB seed PLUS any
|
|
2291
2145
|
// operator-registered wrappers carried on the bootstrap.
|
|
2292
2146
|
// Awaited inside the synchronous `setup` callback via an IIFE
|
|
@@ -2295,13 +2149,13 @@ async function bootProduction(opts) {
|
|
|
2295
2149
|
// is fast (already-resolved bundles hit the module cache).
|
|
2296
2150
|
//
|
|
2297
2151
|
// For now we fall back to the STDLIB-only seed if the bootstrap
|
|
2298
|
-
// omits `gadgets`. The install happens BEFORE any
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2152
|
+
// omits `gadgets`. The install happens BEFORE any render mounts
|
|
2153
|
+
// so `rewrite-imports.ts` reads a fully-populated registry on
|
|
2154
|
+
// the first `loadModule` call.
|
|
2301
2155
|
const composedGadgets = gadgetLoaderMod
|
|
2302
2156
|
// Empty registrations → resolves to a Promise of the STDLIB
|
|
2303
2157
|
// seed only; the boot path doesn't wait on wrapper imports.
|
|
2304
|
-
.loadGadgetRegistry(gadgetsMod,
|
|
2158
|
+
.loadGadgetRegistry(gadgetsMod, meta.gadgets ?? []);
|
|
2305
2159
|
// Install with the synchronous STDLIB seed first; the WS-driven
|
|
2306
2160
|
// boot path doesn't await wrapper loads here (the registry
|
|
2307
2161
|
// install is TOCTOU-critical and synchronous). The Promise's
|
|
@@ -2322,8 +2176,8 @@ async function bootProduction(opts) {
|
|
|
2322
2176
|
gadgets: { '@ggui-ai/gadgets': gadgetsMod },
|
|
2323
2177
|
// Public env values from the bootstrap. The WS-driven path
|
|
2324
2178
|
// receives bootstrap synchronously via
|
|
2325
|
-
// setup({
|
|
2326
|
-
publicEnv:
|
|
2179
|
+
// setup({meta, ...}) so installing here is correct.
|
|
2180
|
+
publicEnv: meta.publicEnv ?? {},
|
|
2327
2181
|
});
|
|
2328
2182
|
// Merge 3rd-party package namespaces into the LIVE slot object —
|
|
2329
2183
|
// not a slot replacement. The per-package data-URL shims read
|
|
@@ -2351,24 +2205,19 @@ async function bootProduction(opts) {
|
|
|
2351
2205
|
// SingleSlotProvider per declared slot) hoists useState into the
|
|
2352
2206
|
// runtime so `useGguiContext(slot)` reads the live tuple.
|
|
2353
2207
|
const registry = globalsMod.getGlobalRegistry();
|
|
2354
|
-
const resolvedSlots = registry !== undefined &&
|
|
2355
|
-
? installContextRegistry(registry.contexts, reactMod,
|
|
2208
|
+
const resolvedSlots = registry !== undefined && meta.contextSlots !== undefined
|
|
2209
|
+
? installContextRegistry(registry.contexts, reactMod, meta.contextSlots)
|
|
2356
2210
|
: [];
|
|
2357
2211
|
const ContextStateHost = createContextStateHost({
|
|
2358
2212
|
react: reactMod,
|
|
2359
|
-
|
|
2213
|
+
poster: productionContextSnapshotPoster,
|
|
2360
2214
|
consoleWarn: typeof console !== 'undefined' && typeof console.warn === 'function'
|
|
2361
2215
|
? console.warn.bind(console)
|
|
2362
2216
|
: undefined,
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
appId: bootstrap.appId,
|
|
2368
|
-
stackItemId: bootstrap.stackItemId,
|
|
2369
|
-
},
|
|
2370
|
-
}
|
|
2371
|
-
: {}),
|
|
2217
|
+
identity: {
|
|
2218
|
+
renderId: meta.renderId,
|
|
2219
|
+
appId: meta.appId,
|
|
2220
|
+
},
|
|
2372
2221
|
});
|
|
2373
2222
|
const streamBus = new StreamBus();
|
|
2374
2223
|
// Buffered send shim — the real WS handle isn't available until
|
|
@@ -2396,64 +2245,165 @@ async function bootProduction(opts) {
|
|
|
2396
2245
|
}
|
|
2397
2246
|
},
|
|
2398
2247
|
};
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2248
|
+
// Spec-canonical outbound dispatch. The WS pipe is for streamSpec
|
|
2249
|
+
// subscriptions ONLY (inbound `ggui_emit` fanout + `props_update` +
|
|
2250
|
+
// `render` + `data` + `feedback` + `drain_ack` + `channel_payload`).
|
|
2251
|
+
// Outbound user actions go through the MCP-Apps host relay per
|
|
2252
|
+
// spec §401: postMessage `tools/call:ggui_runtime_submit_action`
|
|
2253
|
+
// to the parent → `AppRenderer.onCallTool` → sample agent's
|
|
2254
|
+
// `/relay/tools-call` → ggui MCP server →
|
|
2255
|
+
// `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
|
|
2256
|
+
// → `ggui_consume` wakes the agent. The server's WS
|
|
2257
|
+
// `handleInboundAction` writes to the session ledger only — no
|
|
2258
|
+
// downstream consumer — so the WS action path silently drops
|
|
2259
|
+
// clicks. `routeDispatch` is the same helper `bootSelfContained`
|
|
2260
|
+
// uses; threading it here aligns LIVE-mode with self-contained.
|
|
2261
|
+
// Single mounted render — populated by `applyRender` on the first
|
|
2262
|
+
// render frame. The wire config + data channel handler read it
|
|
2263
|
+
// through `currentRender`-returning thunks so they always see the
|
|
2264
|
+
// latest snapshot without holding stale refs.
|
|
2265
|
+
let currentRender = null;
|
|
2266
|
+
let renderHandle = null;
|
|
2267
|
+
const dispatchToolName = resolveDispatchToolName();
|
|
2268
|
+
// Native-idiom interceptors — the replacement for the retired
|
|
2269
|
+
// openLink / requestDisplayMode wire primitives. Installed here
|
|
2270
|
+
// (module-guarded, idempotent) so EVERY component render mounted
|
|
2271
|
+
// through this single surface — the WS-ack render, the inline
|
|
2272
|
+
// static seed, and re-mounts — has anchor-click + fullscreen
|
|
2273
|
+
// capture live before the first `applyRender`. Pre-consolidation
|
|
2274
|
+
// these lived only in `bootSelfContained`; the WS-driven path
|
|
2275
|
+
// installed neither, so anchor clicks + fullscreen requests in a
|
|
2276
|
+
// live-rendered component silently no-op'd.
|
|
2277
|
+
installAnchorClickInterceptor({
|
|
2278
|
+
dispatchToolName,
|
|
2279
|
+
renderId: meta.renderId,
|
|
2280
|
+
appId: meta.appId,
|
|
2281
|
+
});
|
|
2282
|
+
installFullscreenInterceptors({
|
|
2283
|
+
dispatchToolName,
|
|
2284
|
+
renderId: meta.renderId,
|
|
2285
|
+
appId: meta.appId,
|
|
2286
|
+
});
|
|
2287
|
+
const rootConfig = buildRootWireConfig({
|
|
2288
|
+
renderId: meta.renderId,
|
|
2289
|
+
appId: meta.appId,
|
|
2290
|
+
getCurrentRender: () => currentRender,
|
|
2403
2291
|
manager,
|
|
2404
2292
|
streamBus,
|
|
2405
2293
|
...(onObserve !== undefined ? { onObserve } : {}),
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
// not on the stack item shape.
|
|
2430
|
-
if (item.type === 'mcpApps' || item.type === 'system')
|
|
2431
|
-
return null;
|
|
2432
|
-
return buildScopedConfig({
|
|
2433
|
-
stackItemId: item.id,
|
|
2434
|
-
...(item.actionSpec !== undefined ? { actionSpec: item.actionSpec } : {}),
|
|
2435
|
-
});
|
|
2436
|
-
},
|
|
2437
|
-
// Wrap every per-item React mount in
|
|
2438
|
-
// `<ContextStateHost slots={resolvedSlots}>` so contextSpec
|
|
2439
|
-
// values flow through `ui/update-model-context` exactly like
|
|
2440
|
-
// the self-contained path. mcpApps + system items skip the
|
|
2441
|
-
// wrap (their renderers don't run user component code that
|
|
2442
|
-
// reads contexts). When `resolvedSlots` is empty
|
|
2443
|
-
// ContextStateHost short-circuits to a Fragment, so the wrap
|
|
2444
|
-
// is free for items with no contextSpec.
|
|
2445
|
-
getOuterWrapper: (item) => {
|
|
2446
|
-
if (item.type === 'mcpApps' || item.type === 'system')
|
|
2447
|
-
return undefined;
|
|
2448
|
-
return (mountedTree) => reactMod.createElement(ContextStateHost, {
|
|
2449
|
-
slots: resolvedSlots,
|
|
2450
|
-
children: mountedTree,
|
|
2294
|
+
onDispatchEnvelope: (envelope) => {
|
|
2295
|
+
if (envelope.type !== 'data:submit')
|
|
2296
|
+
return;
|
|
2297
|
+
const payload = envelope.payload;
|
|
2298
|
+
if (payload === undefined
|
|
2299
|
+
|| typeof payload.action !== 'string'
|
|
2300
|
+
|| payload.action.length === 0) {
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
routeDispatch({
|
|
2304
|
+
actionName: payload.action,
|
|
2305
|
+
data: payload.data,
|
|
2306
|
+
meta: {
|
|
2307
|
+
renderId: meta.renderId,
|
|
2308
|
+
appId: meta.appId,
|
|
2309
|
+
...(meta.appCallableTools !== undefined
|
|
2310
|
+
? { appCallableTools: meta.appCallableTools }
|
|
2311
|
+
: {}),
|
|
2312
|
+
...(meta.actionNextSteps !== undefined
|
|
2313
|
+
? { actionNextSteps: meta.actionNextSteps }
|
|
2314
|
+
: {}),
|
|
2315
|
+
},
|
|
2316
|
+
dispatchToolName,
|
|
2451
2317
|
});
|
|
2452
2318
|
},
|
|
2453
|
-
|
|
2454
|
-
|
|
2319
|
+
});
|
|
2320
|
+
// Build the wrap factory used by every mount React tree:
|
|
2321
|
+
// `<ContextStateHost slots={resolvedSlots}>` so contextSpec
|
|
2322
|
+
// values flow through `ui/update-model-context` exactly like the
|
|
2323
|
+
// self-contained path. mcpApps + system renders skip the wrap
|
|
2324
|
+
// (their renderers don't run user component code that reads
|
|
2325
|
+
// contexts). When `resolvedSlots` is empty ContextStateHost
|
|
2326
|
+
// short-circuits to a Fragment, so the wrap is free for renders
|
|
2327
|
+
// with no contextSpec.
|
|
2328
|
+
const buildOuterWrapper = (render) => {
|
|
2329
|
+
if (render.type === 'mcpApps' || render.type === 'system')
|
|
2330
|
+
return undefined;
|
|
2331
|
+
return (mountedTree) => reactMod.createElement(ContextStateHost, {
|
|
2332
|
+
slots: resolvedSlots,
|
|
2333
|
+
children: mountedTree,
|
|
2334
|
+
});
|
|
2335
|
+
};
|
|
2336
|
+
// Build the scoped wire config for the active render. mcpApps +
|
|
2337
|
+
// system renders get NO wire config — their iframe / built-in
|
|
2338
|
+
// host has its own contract (adapter-boundary rule).
|
|
2339
|
+
// Post-render-identity-collapse the WireConfig is bound to the
|
|
2340
|
+
// single render at boot, so there's no per-render scope factory
|
|
2341
|
+
// — every dispatch resolves through `getCurrentRender`.
|
|
2342
|
+
const buildScopedWireFor = (render) => {
|
|
2343
|
+
if (render.type === 'mcpApps' || render.type === 'system')
|
|
2344
|
+
return null;
|
|
2345
|
+
return rootConfig;
|
|
2455
2346
|
};
|
|
2456
|
-
|
|
2347
|
+
// Build the renderer options for the active render. Theme is
|
|
2348
|
+
// forwarded from the bootstrap so the mounted React tree injects
|
|
2349
|
+
// the configured theme's CSS vars (indigo, claudic, etc).
|
|
2350
|
+
// Without this, react-renderer.ts falls back to
|
|
2351
|
+
// `getScopedCssTokens` (no preset) and the iframe renders with
|
|
2352
|
+
// the default ggui theme even when `_meta["ai.ggui/render"].themeId`
|
|
2353
|
+
// is `'indigo'`. Sibling `bootSelfContained` path threads the
|
|
2354
|
+
// same fields onto its `mountOpts` for parity.
|
|
2355
|
+
const buildOpts = (render) => {
|
|
2356
|
+
const wrapOuter = buildOuterWrapper(render);
|
|
2357
|
+
return {
|
|
2358
|
+
render,
|
|
2359
|
+
scopedWireConfig: buildScopedWireFor(render),
|
|
2360
|
+
streamBus,
|
|
2361
|
+
renderId: meta.renderId,
|
|
2362
|
+
// Thread the bootstrap's 3rd-party gadget packages so the import
|
|
2363
|
+
// rewriter resolves non-STDLIB gadget imports even for a static
|
|
2364
|
+
// seed mount (which carries no gadgetDescriptors). A full
|
|
2365
|
+
// WS-delivered Render carries gadgetDescriptors and ignores this.
|
|
2366
|
+
...(meta.gadgets !== undefined
|
|
2367
|
+
? { gadgetPackages: meta.gadgets.map((g) => g.package) }
|
|
2368
|
+
: {}),
|
|
2369
|
+
...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
|
|
2370
|
+
...(meta.themeMode !== undefined
|
|
2371
|
+
? { themeMode: meta.themeMode }
|
|
2372
|
+
: {}),
|
|
2373
|
+
...(wrapOuter !== undefined ? { wrapOuter } : {}),
|
|
2374
|
+
};
|
|
2375
|
+
};
|
|
2376
|
+
/**
|
|
2377
|
+
* Mount-or-update the single render slot. First call mounts the
|
|
2378
|
+
* React tree into `renderInto`; subsequent calls re-apply via
|
|
2379
|
+
* `renderHandle.update` (same kind ⇒ in-place props update; kind
|
|
2380
|
+
* transition ⇒ tear-down + remount via `RenderItemHandle`'s own
|
|
2381
|
+
* lifecycle).
|
|
2382
|
+
*
|
|
2383
|
+
* Shared by the render-frame and props_update channel handlers;
|
|
2384
|
+
* closes over `currentRender` + `renderHandle` so the channel
|
|
2385
|
+
* layer just calls `applyRender(render)` without owning
|
|
2386
|
+
* lifecycle state.
|
|
2387
|
+
*/
|
|
2388
|
+
const applyRender = async (render) => {
|
|
2389
|
+
currentRender = render;
|
|
2390
|
+
if (renderHandle === null) {
|
|
2391
|
+
renderHandle = await mountRender(renderInto, buildOpts(render));
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
await renderHandle.update(buildOpts(render));
|
|
2395
|
+
};
|
|
2396
|
+
// Publish `applyRender` module-level + install the persistent
|
|
2397
|
+
// post-mount tool-result listener. On a host-re-emitted tool-result
|
|
2398
|
+
// (claude.ai re-broadcast; the Anthropic-SDK-strips-_meta refetch),
|
|
2399
|
+
// the listener re-mounts through THIS `applyRender` — no fresh boot,
|
|
2400
|
+
// no second WS. The no-WS live-re-render channel; for WS hosts the
|
|
2401
|
+
// render-frame handler already covers re-render, so it's a
|
|
2402
|
+
// redundant-safe fallback (guarded by the renderId pin + liveTrio
|
|
2403
|
+
// dedupe). Runs only on the production renderer path (tests that
|
|
2404
|
+
// drive bootSequence without a renderer never reach setup()).
|
|
2405
|
+
activeApplyRender = applyRender;
|
|
2406
|
+
installPostMountListener();
|
|
2457
2407
|
// Validator context — A2UI default for `_ggui:preview`; no
|
|
2458
2408
|
// bootstrap-supplied overrides today (the
|
|
2459
2409
|
// `extraReservedValidators` injection slot is reserved for a
|
|
@@ -2461,35 +2411,33 @@ async function bootProduction(opts) {
|
|
|
2461
2411
|
const validatorCtx = {
|
|
2462
2412
|
reservedValidators: mergeReservedValidators(undefined, undefined),
|
|
2463
2413
|
};
|
|
2464
|
-
// Per-channel transport router. Created here so it
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2469
|
-
//
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2472
|
-
// stack-fold).
|
|
2414
|
+
// Per-channel transport router. Created here so it shares the
|
|
2415
|
+
// buffered manager shim (and survives the pre-attachManager send
|
|
2416
|
+
// buffering) + the same StreamBus the wire config emits onto.
|
|
2417
|
+
// The router consults `bootstrap.streamWebSocketLocalTools` to
|
|
2418
|
+
// decide WS-subscribe vs iframe-polling per channel; absent ⇒
|
|
2419
|
+
// universal polling fallback. Activated lazily by the render-
|
|
2420
|
+
// frame handler (which calls `channelTransport.applyRender` on
|
|
2421
|
+
// every render fold).
|
|
2473
2422
|
const channelTransport = createChannelTransportRouter({
|
|
2474
|
-
|
|
2475
|
-
appId:
|
|
2476
|
-
...(
|
|
2423
|
+
renderId: meta.renderId,
|
|
2424
|
+
appId: meta.appId,
|
|
2425
|
+
...(meta.streamWebSocketLocalTools !== undefined
|
|
2477
2426
|
? {
|
|
2478
|
-
streamWebSocketLocalTools:
|
|
2427
|
+
streamWebSocketLocalTools: meta.streamWebSocketLocalTools,
|
|
2479
2428
|
}
|
|
2480
2429
|
: {}),
|
|
2481
2430
|
send: (msg) => manager.send(msg),
|
|
2482
2431
|
toolsCall: async ({ toolName, args }) => {
|
|
2483
|
-
// Iframe-polling transport — `tools/call`
|
|
2484
|
-
// MCP host
|
|
2485
|
-
//
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
});
|
|
2432
|
+
// Iframe-polling transport — `tools/call` against the
|
|
2433
|
+
// parent MCP host via `app.callServerTool` (spec-canonical)
|
|
2434
|
+
// when the App handle is set; falls back to raw postMessage
|
|
2435
|
+
// pre-handshake or in tests. Pattern α direct call (no LLM
|
|
2436
|
+
// consent loop). Returns the tool's structuredContent (or
|
|
2437
|
+
// `content[0]` if that's where the payload landed) as a
|
|
2438
|
+
// JsonValue. On RPC error we throw — the router catches
|
|
2439
|
+
// and silently retries on the next tick.
|
|
2440
|
+
const resp = await callServerToolSpec(toolName, args);
|
|
2493
2441
|
if (resp.error !== undefined) {
|
|
2494
2442
|
throw new Error(resp.error.message ?? 'tools/call failed');
|
|
2495
2443
|
}
|
|
@@ -2514,8 +2462,8 @@ async function bootProduction(opts) {
|
|
|
2514
2462
|
}
|
|
2515
2463
|
: {}),
|
|
2516
2464
|
});
|
|
2517
|
-
// B3b — channel
|
|
2518
|
-
// WS frame type. Each handler closes over the
|
|
2465
|
+
// B3b — live-channel registry owns dispatch for every routable
|
|
2466
|
+
// WS frame type. Each handler closes over the renderer state it
|
|
2519
2467
|
// needs. `bootSequence` calls `connectFn` (default
|
|
2520
2468
|
// `connectViaRegistry`) which registers the `ack` + `error`
|
|
2521
2469
|
// handshake handlers and binds the WS transport — frames then
|
|
@@ -2528,45 +2476,29 @@ async function bootProduction(opts) {
|
|
|
2528
2476
|
subscribeFrameBuilder: () => ({
|
|
2529
2477
|
type: 'subscribe',
|
|
2530
2478
|
payload: {
|
|
2531
|
-
|
|
2532
|
-
appId:
|
|
2533
|
-
...(
|
|
2534
|
-
? {
|
|
2479
|
+
renderId: meta.renderId,
|
|
2480
|
+
appId: meta.appId,
|
|
2481
|
+
...(meta.wsToken !== undefined
|
|
2482
|
+
? { wsToken: meta.wsToken }
|
|
2535
2483
|
: {}),
|
|
2536
2484
|
},
|
|
2537
2485
|
}),
|
|
2538
2486
|
});
|
|
2539
|
-
channelRegistry.register(
|
|
2540
|
-
stackModel,
|
|
2487
|
+
channelRegistry.register(createRenderHandler({
|
|
2541
2488
|
statusRefs,
|
|
2542
|
-
|
|
2489
|
+
pinnedRenderId: meta.renderId,
|
|
2490
|
+
applyRender,
|
|
2543
2491
|
getChannelTransport: () => channelTransport,
|
|
2544
2492
|
}));
|
|
2545
2493
|
channelRegistry.register(createDataHandler({
|
|
2546
|
-
|
|
2494
|
+
getCurrentRender: () => currentRender,
|
|
2547
2495
|
streamBus,
|
|
2548
2496
|
validatorCtx,
|
|
2549
2497
|
...(onObserve !== undefined ? { onObserve } : {}),
|
|
2550
|
-
// Canvas-mode lifecycle envelopes
|
|
2551
|
-
// → AnimatorEventBus publish (via the sink built in
|
|
2552
|
-
// bootSequence from the canvas mount handle).
|
|
2553
|
-
...(onCanvasLifecycle !== undefined
|
|
2554
|
-
? { onCanvasLifecycle }
|
|
2555
|
-
: {}),
|
|
2556
2498
|
}));
|
|
2557
2499
|
channelRegistry.register(createPropsUpdateHandler({
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
// Server stamps `pollingUrl` on the bootstrap envelope when the
|
|
2561
|
-
// bootstrap is minted via `/api/bootstrap/<shortCode>` — this is
|
|
2562
|
-
// the URL the iframe-runtime's `PollingTransport` fetches when
|
|
2563
|
-
// the WebSocket transport is unavailable (host blocks `wss://`)
|
|
2564
|
-
// or fails irrecoverably (FailoverHandle swap). Without this
|
|
2565
|
-
// wired, polling has no URL and live updates silently no-op
|
|
2566
|
-
// outside of WS hosts.
|
|
2567
|
-
...(bootstrap.pollingUrl !== undefined
|
|
2568
|
-
? { pollingUrl: bootstrap.pollingUrl }
|
|
2569
|
-
: {}),
|
|
2500
|
+
getCurrentRender: () => currentRender,
|
|
2501
|
+
applyRender,
|
|
2570
2502
|
}));
|
|
2571
2503
|
channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
2572
2504
|
channelRegistry.register(createFeedbackHandler());
|
|
@@ -2582,11 +2514,13 @@ async function bootProduction(opts) {
|
|
|
2582
2514
|
return {
|
|
2583
2515
|
rootWireConfig: rootConfig,
|
|
2584
2516
|
streamBus,
|
|
2585
|
-
|
|
2517
|
+
applyRender,
|
|
2518
|
+
getCurrentRender: () => currentRender,
|
|
2586
2519
|
validatorCtx,
|
|
2587
2520
|
manager,
|
|
2588
2521
|
channelTransport,
|
|
2589
2522
|
channelRegistry,
|
|
2523
|
+
composedGadgets,
|
|
2590
2524
|
};
|
|
2591
2525
|
},
|
|
2592
2526
|
attachManager: (handle, realManager) => {
|
|
@@ -2597,27 +2531,27 @@ async function bootProduction(opts) {
|
|
|
2597
2531
|
}
|
|
2598
2532
|
},
|
|
2599
2533
|
teardown: (handle) => {
|
|
2600
|
-
|
|
2601
|
-
//
|
|
2602
|
-
//
|
|
2534
|
+
// The React mount lifecycle is owned by the per-setup
|
|
2535
|
+
// `renderHandle` closure; it stays null until the first render
|
|
2536
|
+
// frame lands. Today bootSequence's failure paths fire before
|
|
2537
|
+
// any render frame (handshake errors arrive before the first
|
|
2538
|
+
// ack), so unmounting the React tree from here is unnecessary.
|
|
2539
|
+
// The only thing the teardown hook still owns is the per-channel
|
|
2540
|
+
// polling timer + transport subscription registry — no-op when
|
|
2541
|
+
// no channel was activated.
|
|
2603
2542
|
handle.channelTransport.dispose();
|
|
2604
2543
|
},
|
|
2605
2544
|
};
|
|
2606
|
-
// Canvas-mode mount hook. Constructed
|
|
2607
|
-
// here because React + the canvas modules are already dynamic-
|
|
2608
|
-
// imported above; bootSequence calls it lazily only when the parsed
|
|
2609
|
-
// bootstrap carries `canvasMode: true`. Non-canvas sessions never
|
|
2610
|
-
// pay the canvas-mount cost; canvas sessions never re-import React
|
|
2611
|
-
// separately.
|
|
2612
|
-
const canvasMountMod = await import('./canvas/mount.js');
|
|
2613
|
-
const canvasMount = ({ mountTarget, callbacks }) => canvasMountMod.mountCanvas(mountTarget, { react: reactMod, reactDomClient }, callbacks);
|
|
2614
2545
|
await bootSequence({
|
|
2615
2546
|
doc: opts.doc,
|
|
2616
|
-
|
|
2547
|
+
app: opts.app,
|
|
2548
|
+
transport: opts.transport,
|
|
2617
2549
|
notifyParent: opts.notifyParent,
|
|
2618
|
-
|
|
2619
|
-
canvasMount,
|
|
2550
|
+
renderer,
|
|
2620
2551
|
...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
|
|
2621
2552
|
...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
|
|
2553
|
+
...(opts.preResolvedMeta !== undefined
|
|
2554
|
+
? { preResolvedMeta: opts.preResolvedMeta }
|
|
2555
|
+
: {}),
|
|
2622
2556
|
});
|
|
2623
2557
|
}
|