@ggui-ai/iframe-runtime 0.23.0 → 0.25.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.
@@ -296,14 +296,20 @@ export interface RelayDeadTapEvent {
296
296
  readonly appId?: string;
297
297
  }
298
298
  /**
299
- * The mount painted NOTHING (ggui#1103): the renderer held no component,
300
- * so the scope element carries only its stylesheet — a blank card, with no
301
- * error boundary to catch anything (the boundary wraps a null child). The
302
- * host is told which mount moment it was and why, because a card that
303
- * renders nothing is invisible on every other channel: `reason: 'no-code'`
304
- * is a payload that carried no component code, `'eval-failed'` a module
305
- * that threw while evaluating. Names the moment only — never the code, the
306
- * props or the error text, which stay in the operator-visible console line.
299
+ * The mount painted NOTHING. The host is told which mount moment it was and
300
+ * why, because a card that renders nothing is invisible on every other
301
+ * channel. Two reasons are a renderer that held no component (ggui#1103), so
302
+ * the scope carries only its stylesheet and no error boundary engages:
303
+ * `'no-code'` is a payload that carried no component code, `'eval-failed'` a
304
+ * module that threw while evaluating. Three are a component that WAS held
305
+ * and still painted nothing (ggui#1104): `'rendered-nothing'` — the tree
306
+ * committed and the scope holds no element; `'detached'` — the tree
307
+ * committed into a scope no longer in the document (another mount took the
308
+ * container); `'no-commit'` — the tree never committed within the renderer's
309
+ * deadline (a component that suspends with no Suspense boundary). Names the
310
+ * moment only — never the code, the props or the error text, which stay in
311
+ * the operator-visible console line. A host MUST treat an unknown reason as
312
+ * a blank of unknown cause.
307
313
  *
308
314
  * @public
309
315
  */
@@ -311,8 +317,8 @@ export interface ComponentEmptyEvent {
311
317
  readonly kind: 'component-empty';
312
318
  /** Which lifecycle moment painted nothing. */
313
319
  readonly where: 'mount' | 'update';
314
- /** Why the renderer held no component. */
315
- readonly reason: 'no-code' | 'eval-failed';
320
+ /** Why the moment painted nothing (see above). */
321
+ readonly reason: 'no-code' | 'eval-failed' | 'rendered-nothing' | 'detached' | 'no-commit';
316
322
  }
317
323
  /**
318
324
  * Catch-all branch for event kinds the host's typings don't recognize.
@@ -31,6 +31,8 @@
31
31
  */
32
32
  import React, { type ReactNode } from 'react';
33
33
  import type { AppTheme } from '@ggui-ai/protocol/wire';
34
+ /** Test hook: shorten the no-commit deadline; `null` restores the default. */
35
+ export declare function __setNoCommitDeadlineForTest(ms: number | null): void;
34
36
  export interface ReactRootMountOptions {
35
37
  /**
36
38
  * GguiSession whose componentCode + props drive the mount. Only
@@ -29,7 +29,7 @@
29
29
  * `React.createElement` + `React.Fragment` directly so the renderer's
30
30
  * TypeScript config doesn't need a JSX runtime plumbing.
31
31
  */
32
- import React, { Component, createElement, Fragment, } from 'react';
32
+ import React, { Component, createElement, Fragment, useEffect, } from 'react';
33
33
  import { createRoot } from 'react-dom/client';
34
34
  import { postObservabilityToParent } from './observability.js';
35
35
  import { trackThemeFontFaces } from './host-fonts.js';
@@ -69,6 +69,39 @@ function makeScopeClass() {
69
69
  * re-sanitize here.
70
70
  */
71
71
  // =============================================================================
72
+ // Paint probe (ggui#1104) — the blanks a HELD component can still produce.
73
+ // =============================================================================
74
+ /**
75
+ * How long a render may go without committing before it is named a
76
+ * `no-commit` blank. A healthy commit lands within a frame or two of
77
+ * `root.render`; a component that suspends with no Suspense boundary never
78
+ * commits at all, and until now that blank said nothing on any channel.
79
+ */
80
+ const NO_COMMIT_DEADLINE_MS = 5_000;
81
+ let noCommitDeadlineMs = NO_COMMIT_DEADLINE_MS;
82
+ /** Test hook: shorten the no-commit deadline; `null` restores the default. */
83
+ export function __setNoCommitDeadlineForTest(ms) {
84
+ noCommitDeadlineMs = ms ?? NO_COMMIT_DEADLINE_MS;
85
+ }
86
+ /**
87
+ * Rendered inside the scope; its PASSIVE effect runs once the whole commit is
88
+ * done, refs included. (A layout effect would not do: layout effects run
89
+ * child-first, before the parent scope `<div>`'s ref is attached, so the
90
+ * probe would read no scope and misname a healthy mount `detached`.) Renders
91
+ * nothing itself.
92
+ */
93
+ function CommitProbe(props) {
94
+ useEffect(() => {
95
+ props.onCommit();
96
+ });
97
+ return null;
98
+ }
99
+ const HELD_BLANK_DETAIL = {
100
+ 'rendered-nothing': 'the component rendered no element',
101
+ detached: 'the tree committed into a scope that is no longer in the document (another mount took the container)',
102
+ 'no-commit': 'the tree did not commit (a component that suspends with no Suspense boundary never commits)',
103
+ };
104
+ // =============================================================================
72
105
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
73
106
  // =============================================================================
74
107
  const AUTO_RETRY_LIMIT = 1;
@@ -212,6 +245,52 @@ export async function mountReactRoot(container, opts) {
212
245
  let currentOpts = opts;
213
246
  let currentCode = null;
214
247
  let currentComponent = null;
248
+ // ggui#1104 — the paint probe's state. `heldAtRender` is whether the render
249
+ // being committed carried a component (when it did not, ggui#1103's
250
+ // `reportNoComponent` has already named the blank). A blank is reported
251
+ // once per episode: `blankReported` resets when a commit shows paint.
252
+ let scopeEl = null;
253
+ let committedOnce = false;
254
+ let heldAtRender = false;
255
+ let blankReported = false;
256
+ let commitTimer = null;
257
+ function reportHeldBlank(where, reason) {
258
+ if (blankReported)
259
+ return;
260
+ blankReported = true;
261
+ // eslint-disable-next-line no-console -- operator-visible failure signal
262
+ console.warn(`[ggui] mountReactRoot: the ${where} painted nothing (${reason}) — ${HELD_BLANK_DETAIL[reason]}`);
263
+ try {
264
+ postObservabilityToParent({ kind: 'component-empty', where, reason });
265
+ }
266
+ catch {
267
+ // Best-effort seam, as in reportNoComponent: never turn a blank into a throw.
268
+ }
269
+ }
270
+ const setScopeEl = (el) => {
271
+ scopeEl = el;
272
+ };
273
+ const onCommit = () => {
274
+ if (commitTimer !== null) {
275
+ clearTimeout(commitTimer);
276
+ commitTimer = null;
277
+ }
278
+ const where = committedOnce ? 'update' : 'mount';
279
+ committedOnce = true;
280
+ if (!heldAtRender)
281
+ return;
282
+ const scope = scopeEl;
283
+ if (scope === null || !scope.isConnected) {
284
+ reportHeldBlank(where, 'detached');
285
+ return;
286
+ }
287
+ const painted = Array.from(scope.children).some((child) => child.tagName !== 'STYLE');
288
+ if (painted) {
289
+ blankReported = false;
290
+ return;
291
+ }
292
+ reportHeldBlank(where, 'rendered-nothing');
293
+ };
215
294
  async function evaluate(code) {
216
295
  // 0. If code is a URL (S3 presigned), fetch the bytes first.
217
296
  // Matches RCR's first-step URL-fetch guard verbatim.
@@ -382,10 +461,21 @@ export async function mountReactRoot(container, opts) {
382
461
  // The scope `<div>` + inline `<style>` + error-boundary-wrapped
383
462
  // children match RCR L376–383. `key={currentCode?.length}` mirrors
384
463
  // RCR's "reset boundary when code changes" heuristic.
385
- root.render(createElement('div', { className: scopeClass }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
464
+ root.render(createElement('div', { className: scopeClass, ref: setScopeEl }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
386
465
  key: currentCode?.length ?? 0,
387
466
  ...(opts.onError ? { onError: opts.onError } : {}),
388
- }, createElement(Fragment, null, wrapped))));
467
+ }, createElement(Fragment, null, wrapped)), createElement(CommitProbe, { onCommit })));
468
+ // ggui#1104 — a held component must commit. Armed only when this render
469
+ // carries one; the probe's commit disarms it.
470
+ heldAtRender = currentComponent !== null;
471
+ if (commitTimer !== null)
472
+ clearTimeout(commitTimer);
473
+ commitTimer = heldAtRender
474
+ ? setTimeout(() => {
475
+ commitTimer = null;
476
+ reportHeldBlank(committedOnce ? 'update' : 'mount', 'no-commit');
477
+ }, noCommitDeadlineMs)
478
+ : null;
389
479
  }
390
480
  /**
391
481
  * The mount painted NOTHING (ggui#1103). `wrapped` is `null` whenever the
@@ -484,6 +574,9 @@ export async function mountReactRoot(container, opts) {
484
574
  }
485
575
  },
486
576
  unmount() {
577
+ if (commitTimer !== null)
578
+ clearTimeout(commitTimer);
579
+ commitTimer = null;
487
580
  root.unmount();
488
581
  container.replaceChildren();
489
582
  },
@@ -1,7 +1,7 @@
1
1
  import type { AckPayload } from '@ggui-ai/protocol/wire';
2
2
  import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
3
3
  import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
4
- import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
4
+ import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, FrameClassifier, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
5
5
  import { type ProtocolErrorEmitter } from './protocol-error.js';
6
6
  import type { ObservabilityEmitter } from './observability.js';
7
7
  /**
@@ -130,6 +130,21 @@ export interface ConnectViaRegistryOptions {
130
130
  */
131
131
  readonly logger?: ChannelLogger;
132
132
  }
133
+ /**
134
+ * The iframe's verdict on channel frames for the WebSocket retry budget
135
+ * (ggui#1496), handed to every `ChannelRegistry` the runtime builds.
136
+ *
137
+ * - An `ack` is an accepted subscription, so the budget resets. It no
138
+ * longer resets when a socket merely opens: a socket the server opens
139
+ * and then refuses at subscribe used to reset it on every attempt, so
140
+ * the transport never reached `'failed'` and the failover never moved.
141
+ * - An `error` whose code is a {@link PRE_ACK_AUTH_CODES} member is a
142
+ * refusal no retry of the same credential can fix (the subscribe is
143
+ * rebuilt from the same `wsToken` each attempt), so the transport
144
+ * fails at once and the ladder moves to its next rung. The same list
145
+ * classifies the handshake's typed error, so the two cannot drift.
146
+ */
147
+ export declare const classifyChannelFrame: FrameClassifier;
133
148
  /**
134
149
  * Open the WS via the registry, send subscribe (composed by the
135
150
  * registry's `subscribeFrameBuilder`), await the first `ack`. Resolves
@@ -110,6 +110,35 @@ const PRE_ACK_AUTH_CODES = new Set([
110
110
  function isAuthFailureCode(code) {
111
111
  return PRE_ACK_AUTH_CODES.has(code);
112
112
  }
113
+ /**
114
+ * The iframe's verdict on channel frames for the WebSocket retry budget
115
+ * (ggui#1496), handed to every `ChannelRegistry` the runtime builds.
116
+ *
117
+ * - An `ack` is an accepted subscription, so the budget resets. It no
118
+ * longer resets when a socket merely opens: a socket the server opens
119
+ * and then refuses at subscribe used to reset it on every attempt, so
120
+ * the transport never reached `'failed'` and the failover never moved.
121
+ * - An `error` whose code is a {@link PRE_ACK_AUTH_CODES} member is a
122
+ * refusal no retry of the same credential can fix (the subscribe is
123
+ * rebuilt from the same `wsToken` each attempt), so the transport
124
+ * fails at once and the ladder moves to its next rung. The same list
125
+ * classifies the handshake's typed error, so the two cannot drift.
126
+ */
127
+ export const classifyChannelFrame = (frame) => {
128
+ if (frame.type === 'ack')
129
+ return 'accepted';
130
+ if (frame.type !== 'error')
131
+ return undefined;
132
+ const payload = frame.payload;
133
+ if (typeof payload === 'object' &&
134
+ payload !== null &&
135
+ 'code' in payload &&
136
+ typeof payload.code === 'string' &&
137
+ isAuthFailureCode(payload.code)) {
138
+ return 'refused-terminal';
139
+ }
140
+ return undefined;
141
+ };
113
142
  /**
114
143
  * Map a pre-ack `error` frame onto a non-UPGRADE_REQUIRED ProtocolError.
115
144
  * {@link PRE_ACK_AUTH_CODES} → `'auth'`; every other code → `'protocol'`
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { AppTheme, GguiSession } from '@ggui-ai/protocol/wire';
3
3
  import type { GguiSessionSeedInput } from './types.js';
4
- import { type WireConfig } from '@ggui-ai/wire';
4
+ import { type BuiltWireConfig } from '@ggui-ai/wire';
5
5
  import type { StreamBus } from './wire-config.js';
6
6
  export interface RenderItemOptions {
7
7
  /** The render to mount. */
@@ -10,8 +10,12 @@ export interface RenderItemOptions {
10
10
  * `buildRootWireConfig(...)` (see `wire-config.ts`). `null` ⇒ the
11
11
  * component mounts without a wire provider (standalone — matches
12
12
  * today's DynamicComponent fallback when no GguiRender parent is
13
- * present). */
14
- readonly scopedWireConfig: WireConfig | null;
13
+ * present). The config carries the card's `actionSpent` source
14
+ * (ggui#1223), and the component mounts inside `ActionSpentContext` too,
15
+ * so `useActionSpent` reads the one-shot guard's own spent set; likewise
16
+ * `actionPending` inside `ActionPendingContext` (ggui#1398), so
17
+ * `useActionPending` reads the card's in-flight dispatches. */
18
+ readonly scopedWireConfig: BuiltWireConfig | null;
15
19
  /** Shared stream bus — provisional renderer subscribes to
16
20
  * `_ggui:preview` for this render. */
17
21
  readonly streamBus: StreamBus;
@@ -39,6 +39,7 @@
39
39
  */
40
40
  import { createElement } from 'react';
41
41
  import { GguiWireProvider } from '@ggui-ai/wire';
42
+ import { ActionPendingContext, ActionSpentContext } from '@ggui-ai/wire/internal';
42
43
  import { mountReactRoot } from './react-renderer.js';
43
44
  import { mountProvisional, } from './provisional-renderer.js';
44
45
  import { mountMcpAppIframe, } from './mcp-app-iframe-host.js';
@@ -208,9 +209,16 @@ export async function mountRender(container, opts) {
208
209
  * mount. When absent, the wire provider is the only wrapper.
209
210
  */
210
211
  function wrapInScopedProvider(mountedComponent) {
211
- const wireWrapped = currentOpts.scopedWireConfig === null
212
+ const config = currentOpts.scopedWireConfig;
213
+ const wireWrapped = config === null
212
214
  ? mountedComponent
213
- : createElement(GguiWireProvider, { config: currentOpts.scopedWireConfig, children: mountedComponent });
215
+ : createElement(GguiWireProvider, {
216
+ config,
217
+ children: createElement(ActionSpentContext.Provider, {
218
+ value: config.actionSpent,
219
+ children: createElement(ActionPendingContext.Provider, { value: config.actionPending, children: mountedComponent }),
220
+ }),
221
+ });
214
222
  return currentOpts.wrapOuter !== undefined
215
223
  ? currentOpts.wrapOuter(wireWrapped)
216
224
  : wireWrapped;
package/dist/runtime.d.ts CHANGED
@@ -37,6 +37,7 @@ import type { GadgetPackageRegistry } from './globals.js';
37
37
  import { StreamBus } from './wire-config.js';
38
38
  import { type ChannelTransportRouter } from './channel-transport.js';
39
39
  import { ChannelRegistry } from '@ggui-ai/live-channel';
40
+ import { type TelemetrySink } from './runtime-telemetry.js';
40
41
  import { type ConnectFn } from './registry-subscribe.js';
41
42
  import { type StatusRefs } from './status-dom.js';
42
43
  import { type RendererValidatorContext } from './validation.js';
@@ -92,12 +93,10 @@ export declare function setCurrentApp(app: App): void;
92
93
  * the spec's `'light' | 'dark'` literals (dropped, never guessed —
93
94
  * the same tolerant posture as `projectHostContext`).
94
95
  *
95
- * This is the INPUT leg of host-theme adaptation
96
- * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
96
+ * This is the INPUT leg of host-theme adaptation (ggui#551):
97
97
  * `hostContext.theme` alone reaches only `data-theme` +
98
98
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
99
- * pipeline keys off it, so a dark host painted the light ladder
100
- * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
99
+ * pipeline keys off it, so a dark host painted the light ladder.
101
100
  * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
102
101
  * announces one, `buildOpts` paints it over every stamped opinion; the
103
102
  * stamped layers decide only when the host is silent. Read live from
@@ -609,19 +608,6 @@ export declare function readPendingToolResultLocator(): string | null;
609
608
  * gone, no envelope) — the caller's other tiers still decide.
610
609
  */
611
610
  export declare function resolveMetaViaReadDoor(app: App, locator: string): Promise<McpAppAiGguiRenderMeta | null>;
612
- /**
613
- * Render a submit-action's `data` payload as a short inline string
614
- * for embedding in a `ui/message` consent prompt. Goal: human-
615
- * readable, not a JSON dump. Falls back to truncated JSON for
616
- * nested values so the prompt doesn't drop information silently.
617
- *
618
- * Exported for unit-testing — an earlier implementation returned `''`
619
- * for primitive payloads (strings/numbers/booleans), which silently
620
- * vaporised the chip's actual text from the consent prompt and made
621
- * the LLM think every dispatch was a contentless "Please proceed
622
- * with **<intent>**" request.
623
- */
624
- export declare function formatSubmitActionDataInline(data: unknown): string;
625
611
  /**
626
612
  * Fire a single canonical action envelope (`tools/call
627
613
  * ggui_runtime_submit_action`) carrying the typed `{kind, payload, …}` shape
@@ -683,6 +669,49 @@ interface ToastLiveRegions {
683
669
  * @internal — exported for unit tests.
684
670
  */
685
671
  export declare function ensureToastAnnouncer(doc: Document): ToastLiveRegions | null;
672
+ /**
673
+ * Lightweight toast UX surface for dispatched actions. Renders a
674
+ * fixed-position element at the bottom of the iframe so the user
675
+ * gets immediate visual feedback that their gesture was registered
676
+ * — "→ Sending action: archive" → "✓ Queued — agent will react"
677
+ * (or "💬 Sent to chat" on ui/message fallback).
678
+ *
679
+ * Without this, the iframe is silent during dispatch. User clicks
680
+ * and waits, with no way to distinguish "click was received but the
681
+ * agent is busy" from "click was lost". Especially load-bearing in
682
+ * the consume-pipe vs ui/message dual-path era — the toast tells
683
+ * the user which path the gesture actually took.
684
+ *
685
+ * Direct DOM (not React-managed) because:
686
+ * - Works during boot before React mounts.
687
+ * - Doesn't interfere with the component tree the generator owns.
688
+ * - Survives React mount transitions inside the iframe.
689
+ *
690
+ * Single global toast (per iframe). Auto-dismisses after 2.5s on
691
+ * `success` / `fallback` outcomes. A successful dispatch draws nothing
692
+ * here (ggui#1444): its "sending" is spoken only (see
693
+ * {@link GESTURE_COPY}).
694
+ *
695
+ * The visible element is only half the surface (ggui#447). Every
696
+ * message is also spoken through {@link ensureToastAnnouncer}'s live
697
+ * regions, and the element itself is hidden from the accessibility
698
+ * tree so the sentence is not read twice. The one exception is
699
+ * `action_required`, the only toast the user must OPERATE: it re-enters
700
+ * the tree as a named button with a tab stop, because a dismissal that
701
+ * needs a mouse is a dismissal some users cannot perform.
702
+ *
703
+ * @internal — runtime-layer concern.
704
+ */
705
+ /**
706
+ * The `label` an action declares on the render's `actionSpec`, trimmed, or
707
+ * `undefined` when the render declares none (or is a system / MCP-Apps
708
+ * render, which carries no actionSpec). By contract the label is the
709
+ * control's own copy, so it is the one name for a tap a visitor may read
710
+ * (see {@link GESTURE_COPY}).
711
+ *
712
+ * @internal — exported for unit tests.
713
+ */
714
+ export declare function declaredActionLabel(render: GguiSession | GguiSessionSeedInput | null, actionName: string): string | undefined;
686
715
  /**
687
716
  * Boot-time reset of the relay latch (ggui#670 Phase 3). A latch
688
717
  * standing from a prior mount in a re-booted document is closed through
@@ -694,6 +723,12 @@ export declare function ensureToastAnnouncer(doc: Document): ToastLiveRegions |
694
723
  */
695
724
  export declare function resetRelayLatchForBoot(): void;
696
725
  /** @internal — exported for unit tests to reset module state. */
726
+ /**
727
+ * Test-only: install the telemetry sink the runtime records into (normally
728
+ * set once at boot), so a test can observe the events a code path posts.
729
+ * `null` restores the no-sink state.
730
+ */
731
+ export declare function __setTelemetrySinkForTest(sink: TelemetrySink | null): void;
697
732
  export declare function __resetRelayNoticeForTest(): void;
698
733
  /**
699
734
  * Dispatch a submit-action via the empirically-validated bridge
@@ -714,6 +749,8 @@ export declare function dispatchSubmitAction(args: {
714
749
  readonly data: unknown;
715
750
  readonly sessionId: string;
716
751
  readonly appId: string;
752
+ /** The action's declared `label`, when it has one — see {@link GESTURE_COPY}. */
753
+ readonly label?: string;
717
754
  }): void;
718
755
  /**
719
756
  * Channel-transport router's `tools/call` invoker (ggui#440) —
@@ -809,6 +846,8 @@ export declare function routeDispatch(args: {
809
846
  readonly appId: string;
810
847
  };
811
848
  readonly dispatchToolName: string;
849
+ /** The action's declared `label` (visitor copy by contract), when it has one. */
850
+ readonly label?: string;
812
851
  }): void;
813
852
  /**
814
853
  * `ui/open-link` direct dispatch helper — used by the anchor-click