@ggui-ai/iframe-runtime 0.10.0 → 0.11.0
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/dist/host-palette-bridge.d.ts +43 -0
- package/dist/host-palette-bridge.js +107 -0
- package/dist/iframe-runtime.js +71 -70
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/react-renderer.d.ts +26 -6
- package/dist/react-renderer.js +34 -9
- package/dist/render-item.d.ts +8 -0
- package/dist/render-item.js +4 -0
- package/dist/runtime.d.ts +82 -1
- package/dist/runtime.js +158 -43
- package/package.json +7 -7
package/dist/react-renderer.d.ts
CHANGED
|
@@ -62,17 +62,37 @@ export interface ReactRootMountOptions {
|
|
|
62
62
|
*/
|
|
63
63
|
readonly themeMode?: 'light' | 'dark';
|
|
64
64
|
/**
|
|
65
|
-
* Per-app theme overlay
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
65
|
+
* Per-app theme overlay — a partial set of `--ggui-*` values; unset
|
|
66
|
+
* vars keep their token defaults. Merged INTO the scoped in-body
|
|
67
|
+
* token block after the base block (and after `hostPalette`), which
|
|
68
|
+
* is the only placement that can recolor tree content: a `:root`-only
|
|
69
|
+
* append is cascade-dead for components — the scoped block sits later
|
|
70
|
+
* in document order and re-specifies every token (browser-verified,
|
|
71
|
+
* see the scoped-merge comment in `renderTree`). The `:root` append
|
|
72
|
+
* still happens, but for body chrome only. A structural subset of
|
|
73
|
+
* protocol's `AppTheme` (`name` is display-only, not needed at
|
|
74
|
+
* render). `mode` drives `color-scheme` so native controls /
|
|
75
|
+
* scrollbars / canvas pick up dark vs light.
|
|
76
|
+
*
|
|
77
|
+
* Precedence (ggui#573 ruling): base tokens < `hostPalette` <
|
|
78
|
+
* `appTheme` < `cssOverrides` — the slice's stamped theme always
|
|
79
|
+
* wins; the host palette is the fallback layer beneath it.
|
|
71
80
|
*/
|
|
72
81
|
readonly appTheme?: {
|
|
73
82
|
readonly mode: 'light' | 'dark';
|
|
74
83
|
readonly cssVariables: Record<string, string>;
|
|
75
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* Host-announced palette, already translated onto `--ggui-*` tokens
|
|
87
|
+
* (see `host-palette-bridge.ts` — the spec `--color-*` vocabulary
|
|
88
|
+
* never reaches this layer). Merged into the scoped token block
|
|
89
|
+
* after the base block and BEFORE `appTheme`, so it repaints only
|
|
90
|
+
* the slots no operator theme re-specifies (ggui#572; precedence
|
|
91
|
+
* per the ggui#573 ruling above). Values are sanitized by the
|
|
92
|
+
* bridge before they get here — host input never reaches this
|
|
93
|
+
* string-join raw.
|
|
94
|
+
*/
|
|
95
|
+
readonly hostPalette?: Readonly<Record<string, string>>;
|
|
76
96
|
readonly cssOverrides?: string;
|
|
77
97
|
readonly onError?: (error: Error) => void;
|
|
78
98
|
/**
|
package/dist/react-renderer.js
CHANGED
|
@@ -59,6 +59,18 @@ function makeScopeClass() {
|
|
|
59
59
|
scopeCounter += 1;
|
|
60
60
|
return `ggui-rcr-${scopeCounter}`;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Join a custom-property record into CSS declarations. Values were
|
|
64
|
+
* validated injection-safe upstream (appTheme: write-side
|
|
65
|
+
* `appThemeSchema` + the wire parser; hostPalette: the host-palette
|
|
66
|
+
* bridge's sanitizer), so the string-join is safe — do NOT
|
|
67
|
+
* re-sanitize here.
|
|
68
|
+
*/
|
|
69
|
+
function toCssDecls(vars) {
|
|
70
|
+
return Object.entries(vars)
|
|
71
|
+
.map(([k, v]) => `${k}: ${v};`)
|
|
72
|
+
.join('');
|
|
73
|
+
}
|
|
62
74
|
// =============================================================================
|
|
63
75
|
// Error boundary — port of RCR's internal ErrorBoundary (L68–203).
|
|
64
76
|
// =============================================================================
|
|
@@ -305,10 +317,16 @@ export async function mountReactRoot(container, opts) {
|
|
|
305
317
|
let themeCss = opts.themeId
|
|
306
318
|
? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
|
|
307
319
|
: getScopedCssTokens(scopeClass, opts.themeMode);
|
|
320
|
+
if (opts.hostPalette) {
|
|
321
|
+
// Host palette = the fallback layer (ggui#572, #573 ruling):
|
|
322
|
+
// after the base scoped block so it can repaint at all (the base
|
|
323
|
+
// re-specifies every token), BEFORE the appTheme overlay so the
|
|
324
|
+
// slice's stamped theme wins by document order. Keys + values
|
|
325
|
+
// arrive pre-sanitized from the host-palette bridge.
|
|
326
|
+
themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
|
|
327
|
+
}
|
|
308
328
|
if (opts.appTheme) {
|
|
309
|
-
const decls =
|
|
310
|
-
.map(([k, v]) => `${k}: ${v};`)
|
|
311
|
-
.join('');
|
|
329
|
+
const decls = toCssDecls(opts.appTheme.cssVariables);
|
|
312
330
|
// The overlay must be merged HERE, into the scoped style that
|
|
313
331
|
// mounts INSIDE the scope div — the `:root` append below is
|
|
314
332
|
// cascade-dead for tree content: the in-scope block sits later
|
|
@@ -316,9 +334,11 @@ export async function mountReactRoot(container, opts) {
|
|
|
316
334
|
// specificity the base scoped tokens always win over a head-level
|
|
317
335
|
// override (browser-verified in rnd/gen-ui/beauty/experiments/001,
|
|
318
336
|
// the probe sequence that found this). Appended AFTER the base
|
|
319
|
-
// block and BEFORE `cssOverrides`, so: base <
|
|
320
|
-
//
|
|
321
|
-
// (
|
|
337
|
+
// block + host palette and BEFORE `cssOverrides`, so: base <
|
|
338
|
+
// host palette < operator overlay < explicit overrides
|
|
339
|
+
// (ggui#573: slice wins, host fallback). Values validated
|
|
340
|
+
// injection-safe upstream (same guarantee the `:root` append
|
|
341
|
+
// cites).
|
|
322
342
|
themeCss += `.${scopeClass}{${decls}}`;
|
|
323
343
|
}
|
|
324
344
|
// Also inject theme CSS at `:root` on `document.head`. The scoped
|
|
@@ -334,10 +354,15 @@ export async function mountReactRoot(container, opts) {
|
|
|
334
354
|
let rootCss = opts.themeId
|
|
335
355
|
? getThemeCss(opts.themeId, opts.themeMode)
|
|
336
356
|
: getCssTokens(opts.themeMode);
|
|
357
|
+
if (opts.hostPalette) {
|
|
358
|
+
// BODY-CHROME ONLY, same as the appTheme `:root` append below —
|
|
359
|
+
// and beneath it, mirroring the scoped-block order (#573:
|
|
360
|
+
// slice wins, host fallback). Keeps the embedding shell's
|
|
361
|
+
// body font/color/background coherent with the tree.
|
|
362
|
+
rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
|
|
363
|
+
}
|
|
337
364
|
if (opts.appTheme) {
|
|
338
|
-
const decls =
|
|
339
|
-
.map(([k, v]) => `${k}: ${v};`)
|
|
340
|
-
.join('');
|
|
365
|
+
const decls = toCssDecls(opts.appTheme.cssVariables);
|
|
341
366
|
// BODY-CHROME ONLY. This `:root` append themes the embedding
|
|
342
367
|
// shell's body styles (font/color/background outside the scope
|
|
343
368
|
// div). It does NOT reach tree content — the scoped in-body
|
package/dist/render-item.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface RenderItemOptions {
|
|
|
56
56
|
readonly mode: 'light' | 'dark';
|
|
57
57
|
readonly cssVariables: Record<string, string>;
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
|
|
61
|
+
* host-palette bridge. Forwarded to
|
|
62
|
+
* {@link ReactRootMountOptions.hostPalette}: the fallback layer
|
|
63
|
+
* beneath `appTheme` in the scoped token block (ggui#572, merge
|
|
64
|
+
* order per the ggui#573 ruling — slice wins, host fallback).
|
|
65
|
+
*/
|
|
66
|
+
readonly hostPalette?: Readonly<Record<string, string>>;
|
|
59
67
|
readonly cssOverrides?: string;
|
|
60
68
|
readonly onError?: (err: Error) => void;
|
|
61
69
|
/**
|
package/dist/render-item.js
CHANGED
|
@@ -177,6 +177,9 @@ export async function mountRender(container, opts) {
|
|
|
177
177
|
...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
|
|
178
178
|
...(currentOpts.themeMode !== undefined ? { themeMode: currentOpts.themeMode } : {}),
|
|
179
179
|
...(currentOpts.appTheme !== undefined ? { appTheme: currentOpts.appTheme } : {}),
|
|
180
|
+
...(currentOpts.hostPalette !== undefined
|
|
181
|
+
? { hostPalette: currentOpts.hostPalette }
|
|
182
|
+
: {}),
|
|
180
183
|
...(currentOpts.cssOverrides !== undefined
|
|
181
184
|
? { cssOverrides: currentOpts.cssOverrides }
|
|
182
185
|
: {}),
|
|
@@ -244,6 +247,7 @@ export async function mountRender(container, opts) {
|
|
|
244
247
|
...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
|
|
245
248
|
...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
|
|
246
249
|
...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
|
|
250
|
+
...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
|
|
247
251
|
...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
|
|
248
252
|
...(next.onError !== undefined ? { onError: next.onError } : {}),
|
|
249
253
|
renderWrapper: wrapInScopedProvider,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -97,6 +97,87 @@ export declare function setCurrentApp(app: App): void;
|
|
|
97
97
|
* App); production caller is `buildOpts` inside `bootSequence`.
|
|
98
98
|
*/
|
|
99
99
|
export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the mount's base-stylesheet color mode from the slice, with
|
|
102
|
+
* the host as the final fallback (ggui#589, completing the ggui#551
|
|
103
|
+
* precedence ruling — "slice wins, host is the fallback, absent ≠
|
|
104
|
+
* light"):
|
|
105
|
+
*
|
|
106
|
+
* stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
|
|
107
|
+
* host-announced theme > undefined
|
|
108
|
+
*
|
|
109
|
+
* The middle leg is the ggui#589 fix: a render envelope carrying a
|
|
110
|
+
* per-app theme (`theme: {mode, cssVariables}`) but no top-level
|
|
111
|
+
* `themeMode` is still a slice-stamped mode opinion — before this,
|
|
112
|
+
* the base token ladder ignored it and painted the LIGHT token set
|
|
113
|
+
* under a dark overlay whenever the host (correctly, per the
|
|
114
|
+
* adapter-boundary doctrine in the native host helpers) announced no
|
|
115
|
+
* `hostContext.theme`: a light skeleton in a dark skin. The theme
|
|
116
|
+
* object's own `mode` already drove `color-scheme`; now it also
|
|
117
|
+
* selects the ladder.
|
|
118
|
+
*
|
|
119
|
+
* `undefined` still means "no opinion anywhere" — the renderer's
|
|
120
|
+
* default applies; never coerced to `'light'`.
|
|
121
|
+
*
|
|
122
|
+
* @internal — exported for unit tests; production caller is
|
|
123
|
+
* `buildOpts` inside `bootSequence`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function resolveMountThemeMode(meta: {
|
|
126
|
+
readonly themeMode?: 'light' | 'dark';
|
|
127
|
+
readonly theme?: {
|
|
128
|
+
readonly mode: 'light' | 'dark';
|
|
129
|
+
readonly cssVariables?: Record<string, string>;
|
|
130
|
+
};
|
|
131
|
+
}): 'light' | 'dark' | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
|
|
134
|
+
*
|
|
135
|
+
* stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
|
|
136
|
+
* > undefined (renderer default ladder)
|
|
137
|
+
*
|
|
138
|
+
* The name leg makes a slice theme whose `name` matches a REGISTERED
|
|
139
|
+
* theme id select that theme as the BASE token ladder — the overlay's
|
|
140
|
+
* cssVariables still apply above it, so the base carries the full
|
|
141
|
+
* brand ramp coverage a sparse overlay cannot (the "only the mapped
|
|
142
|
+
* vars carry brand" store-frame class). An unregistered name is
|
|
143
|
+
* harmless by construction: `getScopedThemeCss` falls back to the
|
|
144
|
+
* default theme, which is byte-identical to the no-themeId path
|
|
145
|
+
* (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
|
|
146
|
+
* naming its theme "My Custom" renders exactly as before.
|
|
147
|
+
*
|
|
148
|
+
* @internal — exported for unit tests; production caller is
|
|
149
|
+
* `buildOpts` inside `bootSequence`.
|
|
150
|
+
*/
|
|
151
|
+
export declare function resolveMountThemeId(meta: {
|
|
152
|
+
readonly themeId?: string;
|
|
153
|
+
readonly theme?: {
|
|
154
|
+
readonly name?: string;
|
|
155
|
+
readonly mode?: 'light' | 'dark';
|
|
156
|
+
readonly cssVariables?: Record<string, string>;
|
|
157
|
+
};
|
|
158
|
+
}): string | undefined;
|
|
159
|
+
/**
|
|
160
|
+
* The host's announced palette, read from the connected App's
|
|
161
|
+
* pre-merged hostContext and translated onto `--ggui-*` tokens by the
|
|
162
|
+
* host-palette bridge. `undefined` when no App is connected, the host
|
|
163
|
+
* sent no `styles.variables`, or nothing in it maps/survives
|
|
164
|
+
* sanitization — callers spread-skip the option, exactly like
|
|
165
|
+
* {@link hostAnnouncedThemeMode}.
|
|
166
|
+
*
|
|
167
|
+
* This is the palette input leg of host-theme adaptation (ggui#572,
|
|
168
|
+
* the color analog of #551's mode leg above): the spec `--color-*`
|
|
169
|
+
* variables land as inline custom properties on `<html>` via the
|
|
170
|
+
* canonical ext-apps helper, but nothing ggui renders consumes that
|
|
171
|
+
* vocabulary, and the scoped token block shadows root inheritance
|
|
172
|
+
* anyway — so without this mapping a host palette repaints nothing.
|
|
173
|
+
* `buildOpts` threads it onto the mount options; the renderer merges
|
|
174
|
+
* it as the fallback layer BENEATH the slice's own theme (ggui#573
|
|
175
|
+
* ruling: slice wins, host fallback).
|
|
176
|
+
*
|
|
177
|
+
* @internal — exported for unit tests (`setCurrentApp` injects the
|
|
178
|
+
* App); production caller is `buildOpts` inside `bootSequence`.
|
|
179
|
+
*/
|
|
180
|
+
export declare function hostAnnouncedPalette(): Readonly<Record<string, string>> | undefined;
|
|
100
181
|
/**
|
|
101
182
|
* @internal — exported for unit tests to reset module state between
|
|
102
183
|
* scenarios.
|
|
@@ -733,7 +814,7 @@ export declare function requestDisplayModeInParent(args: {
|
|
|
733
814
|
* or honor an upstream `defaultPrevented` from a higher-priority
|
|
734
815
|
* handler.
|
|
735
816
|
*
|
|
736
|
-
* Idempotent: re-mounts (via `
|
|
817
|
+
* Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
|
|
737
818
|
* helper again; the {@link anchorClickInterceptInstalled} guard makes
|
|
738
819
|
* subsequent calls a no-op.
|
|
739
820
|
*/
|
package/dist/runtime.js
CHANGED
|
@@ -32,6 +32,7 @@ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolRes
|
|
|
32
32
|
import { projectHostContext } from '@ggui-ai/protocol';
|
|
33
33
|
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
34
34
|
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
|
|
35
|
+
import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
|
|
35
36
|
import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
36
37
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
37
38
|
import { RelayIncapableError } from './relay-incapability.js';
|
|
@@ -87,7 +88,7 @@ function dispatchDrainAck(payload) {
|
|
|
87
88
|
// App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
|
|
88
89
|
// JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
|
|
89
90
|
// `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
|
|
90
|
-
// for `tools/call` and a `
|
|
91
|
+
// for `tools/call` and a `installPersistentToolResultListener` for inbound
|
|
91
92
|
// `ui/notifications/tool-result`). Every one of those was a hand-rolled
|
|
92
93
|
// reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
|
|
93
94
|
// `App` class already ships — drift across them produced the Reading-B
|
|
@@ -186,6 +187,80 @@ export function hostAnnouncedThemeMode() {
|
|
|
186
187
|
const theme = currentApp?.getHostContext()?.theme;
|
|
187
188
|
return theme === 'light' || theme === 'dark' ? theme : undefined;
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the mount's base-stylesheet color mode from the slice, with
|
|
192
|
+
* the host as the final fallback (ggui#589, completing the ggui#551
|
|
193
|
+
* precedence ruling — "slice wins, host is the fallback, absent ≠
|
|
194
|
+
* light"):
|
|
195
|
+
*
|
|
196
|
+
* stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
|
|
197
|
+
* host-announced theme > undefined
|
|
198
|
+
*
|
|
199
|
+
* The middle leg is the ggui#589 fix: a render envelope carrying a
|
|
200
|
+
* per-app theme (`theme: {mode, cssVariables}`) but no top-level
|
|
201
|
+
* `themeMode` is still a slice-stamped mode opinion — before this,
|
|
202
|
+
* the base token ladder ignored it and painted the LIGHT token set
|
|
203
|
+
* under a dark overlay whenever the host (correctly, per the
|
|
204
|
+
* adapter-boundary doctrine in the native host helpers) announced no
|
|
205
|
+
* `hostContext.theme`: a light skeleton in a dark skin. The theme
|
|
206
|
+
* object's own `mode` already drove `color-scheme`; now it also
|
|
207
|
+
* selects the ladder.
|
|
208
|
+
*
|
|
209
|
+
* `undefined` still means "no opinion anywhere" — the renderer's
|
|
210
|
+
* default applies; never coerced to `'light'`.
|
|
211
|
+
*
|
|
212
|
+
* @internal — exported for unit tests; production caller is
|
|
213
|
+
* `buildOpts` inside `bootSequence`.
|
|
214
|
+
*/
|
|
215
|
+
export function resolveMountThemeMode(meta) {
|
|
216
|
+
return meta.themeMode ?? meta.theme?.mode ?? hostAnnouncedThemeMode();
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
|
|
220
|
+
*
|
|
221
|
+
* stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
|
|
222
|
+
* > undefined (renderer default ladder)
|
|
223
|
+
*
|
|
224
|
+
* The name leg makes a slice theme whose `name` matches a REGISTERED
|
|
225
|
+
* theme id select that theme as the BASE token ladder — the overlay's
|
|
226
|
+
* cssVariables still apply above it, so the base carries the full
|
|
227
|
+
* brand ramp coverage a sparse overlay cannot (the "only the mapped
|
|
228
|
+
* vars carry brand" store-frame class). An unregistered name is
|
|
229
|
+
* harmless by construction: `getScopedThemeCss` falls back to the
|
|
230
|
+
* default theme, which is byte-identical to the no-themeId path
|
|
231
|
+
* (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
|
|
232
|
+
* naming its theme "My Custom" renders exactly as before.
|
|
233
|
+
*
|
|
234
|
+
* @internal — exported for unit tests; production caller is
|
|
235
|
+
* `buildOpts` inside `bootSequence`.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveMountThemeId(meta) {
|
|
238
|
+
return meta.themeId ?? meta.theme?.name;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The host's announced palette, read from the connected App's
|
|
242
|
+
* pre-merged hostContext and translated onto `--ggui-*` tokens by the
|
|
243
|
+
* host-palette bridge. `undefined` when no App is connected, the host
|
|
244
|
+
* sent no `styles.variables`, or nothing in it maps/survives
|
|
245
|
+
* sanitization — callers spread-skip the option, exactly like
|
|
246
|
+
* {@link hostAnnouncedThemeMode}.
|
|
247
|
+
*
|
|
248
|
+
* This is the palette input leg of host-theme adaptation (ggui#572,
|
|
249
|
+
* the color analog of #551's mode leg above): the spec `--color-*`
|
|
250
|
+
* variables land as inline custom properties on `<html>` via the
|
|
251
|
+
* canonical ext-apps helper, but nothing ggui renders consumes that
|
|
252
|
+
* vocabulary, and the scoped token block shadows root inheritance
|
|
253
|
+
* anyway — so without this mapping a host palette repaints nothing.
|
|
254
|
+
* `buildOpts` threads it onto the mount options; the renderer merges
|
|
255
|
+
* it as the fallback layer BENEATH the slice's own theme (ggui#573
|
|
256
|
+
* ruling: slice wins, host fallback).
|
|
257
|
+
*
|
|
258
|
+
* @internal — exported for unit tests (`setCurrentApp` injects the
|
|
259
|
+
* App); production caller is `buildOpts` inside `bootSequence`.
|
|
260
|
+
*/
|
|
261
|
+
export function hostAnnouncedPalette() {
|
|
262
|
+
return mapHostPaletteToGguiVars(currentApp?.getHostContext()?.styles?.variables);
|
|
263
|
+
}
|
|
189
264
|
/**
|
|
190
265
|
* @internal — exported for unit tests to reset module state between
|
|
191
266
|
* scenarios.
|
|
@@ -314,6 +389,14 @@ export async function bootSequence(opts) {
|
|
|
314
389
|
const toolResultPromise = opts.preResolvedMeta !== undefined
|
|
315
390
|
? Promise.resolve(null)
|
|
316
391
|
: awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
|
|
392
|
+
// Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
|
|
393
|
+
// Tier-1/preResolved boot the one-shot listener above is skipped,
|
|
394
|
+
// which made the post-mount installation the FIRST toolresult
|
|
395
|
+
// registration — after the handshake, tripping the ext-apps timing
|
|
396
|
+
// notice and leaning on SDK replay for correctness. Registering here
|
|
397
|
+
// covers every boot path; arrivals before a renderer publishes
|
|
398
|
+
// `applyRender` no-op inside the listener.
|
|
399
|
+
installPersistentToolResultListener(app);
|
|
317
400
|
const initResult = await connectApp(app, transport);
|
|
318
401
|
if (!initResult.ok) {
|
|
319
402
|
setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
|
|
@@ -1259,17 +1342,19 @@ function awaitToolResultMeta(timeoutMs) {
|
|
|
1259
1342
|
});
|
|
1260
1343
|
}
|
|
1261
1344
|
/**
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1345
|
+
* Per-App guard for the persistent toolresult re-listener. Ensures
|
|
1346
|
+
* exactly one `app.addEventListener('toolresult', …)` registration per
|
|
1347
|
+
* App instance even across re-mounts (e.g. an agent fires a second
|
|
1348
|
+
* `ggui_render` and the listener re-applies through the published
|
|
1349
|
+
* `applyRender`). Keyed by App — not a module boolean — so a fresh App
|
|
1350
|
+
* (a re-booted document in tests; production is single-tenant per
|
|
1351
|
+
* iframe) always gets its own registration, never a stale latch.
|
|
1267
1352
|
*/
|
|
1268
|
-
|
|
1353
|
+
const persistentToolResultListenerApps = new WeakSet();
|
|
1269
1354
|
/**
|
|
1270
1355
|
* Module-level handle to the active renderer's `applyRender`, published
|
|
1271
1356
|
* by `bootProduction`'s `setup()` once the renderer is built. The
|
|
1272
|
-
* module-level {@link
|
|
1357
|
+
* module-level {@link installPersistentToolResultListener} resolves this to re-mount
|
|
1273
1358
|
* on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount,
|
|
1274
1359
|
* no second WS. This is the no-WS live-re-render channel for
|
|
1275
1360
|
* spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
|
|
@@ -1281,8 +1366,8 @@ let postMountListenerInstalled = false;
|
|
|
1281
1366
|
let activeApplyRender = null;
|
|
1282
1367
|
/**
|
|
1283
1368
|
* Module-level guard for {@link installAnchorClickInterceptor}. Same
|
|
1284
|
-
* rationale as {@link
|
|
1285
|
-
* triggered by `
|
|
1369
|
+
* rationale as {@link persistentToolResultListenerApps}: re-mounts are
|
|
1370
|
+
* triggered by `installPersistentToolResultListener`, and stacking capture-phase
|
|
1286
1371
|
* click listeners across re-mounts would multiply the audit envelopes
|
|
1287
1372
|
* that fire on a single click.
|
|
1288
1373
|
*/
|
|
@@ -2813,7 +2898,7 @@ export function requestDisplayModeInParent(args) {
|
|
|
2813
2898
|
* or honor an upstream `defaultPrevented` from a higher-priority
|
|
2814
2899
|
* handler.
|
|
2815
2900
|
*
|
|
2816
|
-
* Idempotent: re-mounts (via `
|
|
2901
|
+
* Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
|
|
2817
2902
|
* helper again; the {@link anchorClickInterceptInstalled} guard makes
|
|
2818
2903
|
* subsequent calls a no-op.
|
|
2819
2904
|
*/
|
|
@@ -2954,15 +3039,26 @@ export function __resetInterceptorsForTest() {
|
|
|
2954
3039
|
fullscreenInterceptInstalled = false;
|
|
2955
3040
|
}
|
|
2956
3041
|
/**
|
|
2957
|
-
* Install
|
|
2958
|
-
* that catches `ui/notifications/tool-result` notifications
|
|
2959
|
-
*
|
|
2960
|
-
* different bootstrap (different sessionId / codeUrl / kind)
|
|
2961
|
-
* a re-mount through the published `applyRender
|
|
3042
|
+
* Install the persistent `app.addEventListener('toolresult', …)`
|
|
3043
|
+
* listener that catches `ui/notifications/tool-result` notifications
|
|
3044
|
+
* arriving at ANY point in the document's life. Each tool-result that
|
|
3045
|
+
* carries a different bootstrap (different sessionId / codeUrl / kind)
|
|
3046
|
+
* triggers a re-mount through the published `applyRender`; arrivals
|
|
3047
|
+
* before a renderer publishes `applyRender` no-op safely (and do NOT
|
|
3048
|
+
* latch the dedupe key — see below). This closes the boot-only-
|
|
2962
3049
|
* listener gap that prevented live re-render when an agent issued
|
|
2963
3050
|
* a second `ggui_render` to the same render-resource.
|
|
2964
3051
|
*
|
|
2965
|
-
*
|
|
3052
|
+
* REGISTRATION TIMING (ggui#586): called by `bootSequence` BEFORE
|
|
3053
|
+
* `app.connect(transport)` — on a Tier-1/preResolved boot the one-shot
|
|
3054
|
+
* Tier-2 listener never registers, so this used to be the FIRST
|
|
3055
|
+
* `toolresult` registration and landed post-handshake (the ext-apps
|
|
3056
|
+
* `_assertHandlerTiming` notice a guuey production render surfaced).
|
|
3057
|
+
* The SDK's replay made that harmless; pre-registering removes the
|
|
3058
|
+
* dependence on replay entirely.
|
|
3059
|
+
*
|
|
3060
|
+
* Idempotent per App: repeat calls with the same App no-op via
|
|
3061
|
+
* {@link persistentToolResultListenerApps}.
|
|
2966
3062
|
*
|
|
2967
3063
|
* Why module-level vs scoped to a single mount: re-mounts re-apply
|
|
2968
3064
|
* through `applyRender` while the listener stays live, and the
|
|
@@ -2972,17 +3068,13 @@ export function __resetInterceptorsForTest() {
|
|
|
2972
3068
|
* Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
|
|
2973
3069
|
* `window.addEventListener('message', …)` is gone; App handles every
|
|
2974
3070
|
* inbound `ui/notifications/tool-result` envelope and dispatches via
|
|
2975
|
-
* its event system.
|
|
2976
|
-
*
|
|
2977
|
-
* path before this helper runs.
|
|
3071
|
+
* its event system. The App handle is passed by the boot path
|
|
3072
|
+
* directly (pre-connect, so `getCurrentApp()` is not yet set).
|
|
2978
3073
|
*/
|
|
2979
|
-
function
|
|
2980
|
-
if (
|
|
3074
|
+
function installPersistentToolResultListener(app) {
|
|
3075
|
+
if (persistentToolResultListenerApps.has(app))
|
|
2981
3076
|
return;
|
|
2982
|
-
|
|
2983
|
-
if (app === null)
|
|
2984
|
-
return; // pre-connect; caller bug, swallow safely
|
|
2985
|
-
postMountListenerInstalled = true;
|
|
3077
|
+
persistentToolResultListenerApps.add(app);
|
|
2986
3078
|
let lastMetaKey = null;
|
|
2987
3079
|
app.addEventListener('toolresult', (params) => {
|
|
2988
3080
|
const meta = extractMetaFromToolResult(params);
|
|
@@ -3020,15 +3112,21 @@ function installPostMountListener() {
|
|
|
3020
3112
|
].join('|');
|
|
3021
3113
|
if (key === lastMetaKey)
|
|
3022
3114
|
return;
|
|
3023
|
-
lastMetaKey = key;
|
|
3024
3115
|
// Re-mount through the EXISTING renderer's `applyRender` (published
|
|
3025
3116
|
// module-level by bootProduction's setup) — NOT a fresh boot. One
|
|
3026
3117
|
// mount surface, one WS; the prior stale-closure / second-WS race
|
|
3027
3118
|
// (the #290 root cause) is structurally impossible. Project the new
|
|
3028
3119
|
// tool-result meta into a seed and re-apply it.
|
|
3120
|
+
//
|
|
3121
|
+
// The apply-null check runs BEFORE the dedupe key latches (ggui#586
|
|
3122
|
+
// ordering): now that this listener registers pre-connect, the
|
|
3123
|
+
// BOOT-path tool-result also flows through here while no renderer
|
|
3124
|
+
// is published yet — latching its key then would silently dedupe a
|
|
3125
|
+
// legitimate post-mount re-broadcast of the same envelope.
|
|
3029
3126
|
const apply = activeApplyRender;
|
|
3030
3127
|
if (apply === null)
|
|
3031
3128
|
return; // no renderer published yet — no-op safely
|
|
3129
|
+
lastMetaKey = key;
|
|
3032
3130
|
void buildGguiSessionSeedInput(meta).then((seed) => {
|
|
3033
3131
|
if (seed === null)
|
|
3034
3132
|
return;
|
|
@@ -3461,13 +3559,21 @@ async function bootProduction(opts) {
|
|
|
3461
3559
|
!('createdAt' in render)
|
|
3462
3560
|
? { codeModuleUrl: meta.codeModuleUrl }
|
|
3463
3561
|
: {}),
|
|
3464
|
-
|
|
3465
|
-
//
|
|
3466
|
-
//
|
|
3467
|
-
//
|
|
3468
|
-
//
|
|
3562
|
+
// Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
|
|
3563
|
+
// stamped themeId > the slice theme object's NAME (a
|
|
3564
|
+
// registered name binds the full brand base; unregistered
|
|
3565
|
+
// names fall back to the default ladder, byte-identical to
|
|
3566
|
+
// the no-themeId path).
|
|
3469
3567
|
...(() => {
|
|
3470
|
-
const
|
|
3568
|
+
const themeId = resolveMountThemeId(meta);
|
|
3569
|
+
return themeId !== undefined ? { themeId } : {};
|
|
3570
|
+
})(),
|
|
3571
|
+
// Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
|
|
3572
|
+
// stamped `themeMode` > the slice theme OBJECT's `mode` >
|
|
3573
|
+
// host-announced theme. Absent everywhere means "no opinion",
|
|
3574
|
+
// not "light".
|
|
3575
|
+
...(() => {
|
|
3576
|
+
const themeMode = resolveMountThemeMode(meta);
|
|
3471
3577
|
return themeMode !== undefined ? { themeMode } : {};
|
|
3472
3578
|
})(),
|
|
3473
3579
|
// Per-app theme overlay (St3 M2.2). Threaded straight from the
|
|
@@ -3477,6 +3583,15 @@ async function bootProduction(opts) {
|
|
|
3477
3583
|
// `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
|
|
3478
3584
|
// cards theme via the SystemCardHost/ThemeProvider path.
|
|
3479
3585
|
...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
|
|
3586
|
+
// Host palette: always threaded when the host announced one —
|
|
3587
|
+
// unlike mode there is no either/or gate here, because the
|
|
3588
|
+
// precedence is CSS document order inside the scoped block
|
|
3589
|
+
// (base < hostPalette < appTheme, ggui#572/#573: slice wins,
|
|
3590
|
+
// host fallback), not a value-level ?? fallback.
|
|
3591
|
+
...(() => {
|
|
3592
|
+
const hostPalette = hostAnnouncedPalette();
|
|
3593
|
+
return hostPalette !== undefined ? { hostPalette } : {};
|
|
3594
|
+
})(),
|
|
3480
3595
|
...(wrapOuter !== undefined ? { wrapOuter } : {}),
|
|
3481
3596
|
};
|
|
3482
3597
|
};
|
|
@@ -3500,17 +3615,17 @@ async function bootProduction(opts) {
|
|
|
3500
3615
|
}
|
|
3501
3616
|
await renderHandle.update(buildOpts(render));
|
|
3502
3617
|
};
|
|
3503
|
-
// Publish `applyRender` module-level
|
|
3504
|
-
//
|
|
3505
|
-
// (
|
|
3506
|
-
//
|
|
3507
|
-
//
|
|
3508
|
-
//
|
|
3509
|
-
//
|
|
3510
|
-
//
|
|
3511
|
-
//
|
|
3618
|
+
// Publish `applyRender` module-level. The persistent tool-result
|
|
3619
|
+
// listener is ALREADY registered — `bootSequence` installs it
|
|
3620
|
+
// pre-connect (ggui#586) — and reads this slot dynamically, so
|
|
3621
|
+
// publishing here is what arms it. On a host-re-emitted
|
|
3622
|
+
// tool-result (claude.ai re-broadcast; the
|
|
3623
|
+
// Anthropic-SDK-strips-_meta refetch), the listener re-mounts
|
|
3624
|
+
// through THIS `applyRender` — no fresh boot, no second WS. The
|
|
3625
|
+
// no-WS live-re-render channel; for WS hosts the render-frame
|
|
3626
|
+
// handler already covers re-render, so it's a redundant-safe
|
|
3627
|
+
// fallback (guarded by the sessionId pin + liveTrio dedupe).
|
|
3512
3628
|
activeApplyRender = applyRender;
|
|
3513
|
-
installPostMountListener();
|
|
3514
3629
|
// Validator context — A2UI default for `_ggui:preview`; no
|
|
3515
3630
|
// bootstrap-supplied overrides today (the
|
|
3516
3631
|
// `extraReservedValidators` injection slot is reserved for a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/iframe-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The iframe-local ggui runtime: a self-contained ESM bundle that boots inside an MCP Apps host iframe, runs the protocol-version handshake, opens the live-channel WebSocket, and mounts the rendered UI. Bundles React, ReactDOM, and the ggui design system so generated component code has a single seam to read from.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ggui",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"react": "^19.0.0",
|
|
37
37
|
"react-dom": "^19.0.0",
|
|
38
38
|
"zod": "^4.3.6",
|
|
39
|
-
"@ggui-ai/
|
|
40
|
-
"@ggui-ai/
|
|
41
|
-
"@ggui-ai/
|
|
42
|
-
"@ggui-ai/
|
|
43
|
-
"@ggui-ai/
|
|
44
|
-
"@ggui-ai/wire": "0.
|
|
39
|
+
"@ggui-ai/preview-a2ui": "0.11.0",
|
|
40
|
+
"@ggui-ai/live-channel": "0.11.0",
|
|
41
|
+
"@ggui-ai/gadgets": "0.11.0",
|
|
42
|
+
"@ggui-ai/design": "0.11.0",
|
|
43
|
+
"@ggui-ai/protocol": "0.11.0",
|
|
44
|
+
"@ggui-ai/wire": "0.11.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.27.0"
|