@ggui-ai/iframe-runtime 0.17.0 → 0.19.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.
@@ -129,6 +129,15 @@ export interface ReactRootMount {
129
129
  unmount(): void;
130
130
  /** Current component code (useful for tests + audit). */
131
131
  readonly currentCode: string | null;
132
+ /**
133
+ * Whether this mount HOLDS a component — i.e. whether the tree it renders
134
+ * is anything but its own stylesheet (ggui#1103). `applyRender` succeeding
135
+ * is not the same fact: a payload with no code, or a module that threw,
136
+ * leaves the scope element carrying only its `<style>`, and a caller that
137
+ * treats "a render was applied" as "something painted" retires the host's
138
+ * waiting indicator over a blank card.
139
+ */
140
+ readonly painted: boolean;
132
141
  }
133
142
  /**
134
143
  * Mount a React root inside `container` and render `render`'s
@@ -443,6 +443,9 @@ export async function mountReactRoot(container, opts) {
443
443
  }
444
444
  await initialEvaluate();
445
445
  return {
446
+ get painted() {
447
+ return currentComponent !== null;
448
+ },
446
449
  get currentCode() {
447
450
  return currentCode;
448
451
  },
@@ -106,6 +106,14 @@ export interface RenderItemHandle {
106
106
  unmount(): void;
107
107
  /** Current render kind. Test-only — not load-bearing. */
108
108
  readonly kind: MountedKind;
109
+ /**
110
+ * Whether the active mount PAINTS something (ggui#1103). A provisional,
111
+ * system or MCP-app mount always does; a React mount does only while it
112
+ * holds a component. The runtime reads this to decide whether the served
113
+ * shell's waiting indicator has become a lie — "a render was applied" is
114
+ * not that fact.
115
+ */
116
+ readonly painted: boolean;
109
117
  }
110
118
  export declare function mountRender(container: HTMLElement, opts: RenderItemOptions): Promise<RenderItemHandle>;
111
119
  export {};
@@ -222,6 +222,11 @@ export async function mountRender(container, opts) {
222
222
  get kind() {
223
223
  return currentKind;
224
224
  },
225
+ get painted() {
226
+ if (currentKind === 'react')
227
+ return reactMount?.painted === true;
228
+ return currentKind !== 'none';
229
+ },
225
230
  async update(next) {
226
231
  const prevKind = currentKind;
227
232
  currentOpts = next;
package/dist/runtime.d.ts CHANGED
@@ -421,6 +421,14 @@ export interface RendererHandle {
421
421
  * `propsSpec` / `streamSpec`.
422
422
  */
423
423
  getCurrentGguiSession(): GguiSession | GguiSessionSeedInput | null;
424
+ /**
425
+ * Whether the mounted render PAINTS something (ggui#1103) — false while no
426
+ * render has mounted, and false for a mount holding no component (a payload
427
+ * with no code, or a module that threw). Read by the boot to decide whether
428
+ * the served shell's waiting indicator has become a lie; "a render was
429
+ * applied" is a different fact and was the one being used.
430
+ */
431
+ painted(): boolean;
424
432
  readonly validatorCtx: RendererValidatorContext;
425
433
  /**
426
434
  * Send surface for outbound frames. Wired by `setup()` to the WS
package/dist/runtime.js CHANGED
@@ -32,6 +32,7 @@ import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpApp
32
32
  import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
33
33
  import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, reportStrippedThemeMembers, } from './meta-parse.js';
34
34
  import { projectHostContext } from '@ggui-ai/protocol/wire';
35
+ import { installDismissIntentListener } from './dismiss-intent.js';
35
36
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
36
37
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
37
38
  import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
@@ -373,6 +374,13 @@ export async function bootSequence(opts) {
373
374
  onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
374
375
  };
375
376
  const refs = ensureStatusDom(doc);
377
+ // ggui#1109: a key pressed inside the card's document never reaches the page
378
+ // around it, so a dismiss gesture died here — which is the moment a user who
379
+ // just clicked the card presses Escape. The card forwards the INTENT and
380
+ // never acts on it; the host owns what dismissal means, and ignoring is
381
+ // conformant. Installed before any mount, because the gesture can precede
382
+ // the first frame.
383
+ installDismissIntentListener(doc);
376
384
  // Live regions up BEFORE anything can announce through them
377
385
  // (ggui#447). A region created in the same tick as its first message
378
386
  // is a region no screen reader was watching yet, and that first
@@ -652,6 +660,7 @@ export async function bootSequence(opts) {
652
660
  mountedRender = target;
653
661
  if (renderer !== null) {
654
662
  await renderer.applyRender(target);
663
+ retireStandbyIfPainted();
655
664
  if (target.type !== 'mcpApps' && target.type !== 'system') {
656
665
  renderer.channelTransport.applyRender({
657
666
  sessionId: target.id,
@@ -671,11 +680,42 @@ export async function bootSequence(opts) {
671
680
  if (codeReadyEmitted)
672
681
  return;
673
682
  codeReadyEmitted = true;
674
- // First real paint — the served shell's working-state mark (#667)
675
- // becomes a lie the moment content lands.
676
- retireShellLoadingIndicator(doc);
683
+ // The served shell's working-state mark (#667) is retired by PAINT, not by
684
+ // this event (ggui#1103): `code-ready` fires after a render was APPLIED,
685
+ // and applying succeeds when the mount holds no component — so retiring
686
+ // here left a blank box with no indicator, indistinguishable from a card
687
+ // still negotiating. `code-ready` itself keeps firing exactly where it did:
688
+ // hosts pin selectors on it (E2E specs, accessibility scanners).
689
+ retireStandbyIfPainted();
677
690
  onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
678
691
  };
692
+ /**
693
+ * Retire the shell's standby ONLY when the mount paints something, and say
694
+ * so when a boot reaches its end painting nothing (ggui#1103). Three states
695
+ * that looked alike — negotiating, empty, painted — now read apart: the
696
+ * standby means "still coming", its absence with content means the card,
697
+ * its absence with a said status means "nothing to show".
698
+ */
699
+ let saidNothingToShow = false;
700
+ function retireStandbyIfPainted() {
701
+ if (renderer !== null && renderer.painted()) {
702
+ retireShellLoadingIndicator(doc);
703
+ // A frame that lands after we said there was nothing withdraws the
704
+ // sentence — the card is the answer now.
705
+ if (saidNothingToShow) {
706
+ saidNothingToShow = false;
707
+ setConnectedStatus(refs);
708
+ }
709
+ return;
710
+ }
711
+ if (!codeReadyEmitted)
712
+ return; // still coming — the standby is honest
713
+ retireShellLoadingIndicator(doc);
714
+ if (!saidNothingToShow) {
715
+ saidNothingToShow = true;
716
+ setStatus(refs, 'Nothing to show for this card.', 'idle');
717
+ }
718
+ }
679
719
  // Mode discriminators. The mount surface is DECOUPLED from the live
680
720
  // channel: static content (codeUrl/codeB64/kind) paints immediately
681
721
  // with no WS; the live trio (wsUrl+wsToken) is an OPTIONAL
@@ -745,6 +785,7 @@ export async function bootSequence(opts) {
745
785
  if (seed !== null) {
746
786
  await renderer.applyRender(seed);
747
787
  mountedRender = seed;
788
+ retireStandbyIfPainted();
748
789
  emitCodeReadyOnce();
749
790
  }
750
791
  }
@@ -4084,6 +4125,7 @@ async function bootProduction(opts) {
4084
4125
  getChannelTransport: () => channelTransport,
4085
4126
  }));
4086
4127
  return {
4128
+ painted: () => renderHandle?.painted === true,
4087
4129
  rootWireConfig: rootConfig,
4088
4130
  streamBus,
4089
4131
  applyRender,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -41,12 +41,12 @@
41
41
  "react": "^19.0.0",
42
42
  "react-dom": "^19.0.0",
43
43
  "zod": "^4.3.6",
44
- "@ggui-ai/design": "0.17.0",
45
- "@ggui-ai/gadgets": "0.17.0",
46
- "@ggui-ai/live-channel": "0.17.0",
47
- "@ggui-ai/preview-a2ui": "0.17.0",
48
- "@ggui-ai/protocol": "0.17.0",
49
- "@ggui-ai/wire": "0.17.0"
44
+ "@ggui-ai/design": "0.19.0",
45
+ "@ggui-ai/gadgets": "0.19.0",
46
+ "@ggui-ai/preview-a2ui": "0.19.0",
47
+ "@ggui-ai/protocol": "0.19.0",
48
+ "@ggui-ai/live-channel": "0.19.0",
49
+ "@ggui-ai/wire": "0.19.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.0"