@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.
package/dist/runtime.js CHANGED
@@ -42,7 +42,7 @@ import { RelayIncapableError } from './relay-incapability.js';
42
42
  import { ChannelRegistry } from '@ggui-ai/live-channel';
43
43
  import { createTelemetrySink, } from './runtime-telemetry.js';
44
44
  import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
45
- import { connectViaRegistry, } from './registry-subscribe.js';
45
+ import { classifyChannelFrame, connectViaRegistry, } from './registry-subscribe.js';
46
46
  import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
47
47
  import { unwrapCallToolResult } from './call-tool-unwrap.js';
48
48
  import { ensureStatusDom, setStatus, setConnectedStatus, retireShellLoadingIndicator, } from './status-dom.js';
@@ -185,12 +185,10 @@ let activeThemeUnsubscribe = null;
185
185
  * the spec's `'light' | 'dark'` literals (dropped, never guessed —
186
186
  * the same tolerant posture as `projectHostContext`).
187
187
  *
188
- * This is the INPUT leg of host-theme adaptation
189
- * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
188
+ * This is the INPUT leg of host-theme adaptation (ggui#551):
190
189
  * `hostContext.theme` alone reaches only `data-theme` +
191
190
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
192
- * pipeline keys off it, so a dark host painted the light ladder
193
- * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
191
+ * pipeline keys off it, so a dark host painted the light ladder.
194
192
  * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
195
193
  * announces one, `buildOpts` paints it over every stamped opinion; the
196
194
  * stamped layers decide only when the host is silent. Read live from
@@ -1045,6 +1043,7 @@ function createPlaceholderRegistry(params) {
1045
1043
  : {}),
1046
1044
  },
1047
1045
  }),
1046
+ classifyFrame: classifyChannelFrame,
1048
1047
  });
1049
1048
  registry.register(createRenderHandler({
1050
1049
  statusRefs: params.statusRefs,
@@ -1516,56 +1515,6 @@ function fnv1aHex(payload) {
1516
1515
  }
1517
1516
  return (hash >>> 0).toString(16).padStart(8, '0');
1518
1517
  }
1519
- /**
1520
- * Render a submit-action's `data` payload as a short inline string
1521
- * for embedding in a `ui/message` consent prompt. Goal: human-
1522
- * readable, not a JSON dump. Falls back to truncated JSON for
1523
- * nested values so the prompt doesn't drop information silently.
1524
- *
1525
- * Exported for unit-testing — an earlier implementation returned `''`
1526
- * for primitive payloads (strings/numbers/booleans), which silently
1527
- * vaporised the chip's actual text from the consent prompt and made
1528
- * the LLM think every dispatch was a contentless "Please proceed
1529
- * with **<intent>**" request.
1530
- */
1531
- export function formatSubmitActionDataInline(data) {
1532
- if (data === null || data === undefined)
1533
- return '';
1534
- // Bare primitives: render verbatim. Strings unquoted (most legible
1535
- // in a "Please proceed with X (foo)" sentence). Numbers / booleans
1536
- // stringified.
1537
- if (typeof data === 'string')
1538
- return data;
1539
- if (typeof data === 'number' || typeof data === 'boolean')
1540
- return String(data);
1541
- // Arrays: short JSON, truncated. Length cap mirrors the per-entry
1542
- // cap below so the consent line stays a single human-readable phrase.
1543
- if (Array.isArray(data)) {
1544
- const json = JSON.stringify(data);
1545
- return json.length > 60 ? `${json.slice(0, 57)}…` : json;
1546
- }
1547
- if (typeof data !== 'object')
1548
- return '';
1549
- const entries = Object.entries(data);
1550
- if (entries.length === 0)
1551
- return '';
1552
- const parts = entries.map(([k, v]) => {
1553
- if (v === null)
1554
- return `${k}: null`;
1555
- if (v === undefined)
1556
- return `${k}: undefined`;
1557
- if (typeof v === 'string')
1558
- return `${k}: ${v}`;
1559
- if (typeof v === 'number' || typeof v === 'boolean')
1560
- return `${k}: ${v}`;
1561
- // `JSON.stringify` returns `undefined` for unrepresentable values
1562
- // (e.g. a `function` field on a form payload object) — guard so
1563
- // the subsequent `.length` access doesn't crash the dispatch path.
1564
- const json = JSON.stringify(v) ?? String(v);
1565
- return `${k}: ${json.length > 40 ? `${json.slice(0, 37)}…` : json}`;
1566
- });
1567
- return parts.join(', ');
1568
- }
1569
1518
  /**
1570
1519
  * Post an arbitrary JSON-RPC envelope to the iframe's parent
1571
1520
  * window. Internal helper shared across {@link emitAudit} (the
@@ -2097,6 +2046,84 @@ function hideToast(el) {
2097
2046
  }
2098
2047
  /** The single action-toast element, reused for every message. */
2099
2048
  const ACTION_TOAST_ID = '__ggui-action-toast__';
2049
+ /**
2050
+ * Lightweight toast UX surface for dispatched actions. Renders a
2051
+ * fixed-position element at the bottom of the iframe so the user
2052
+ * gets immediate visual feedback that their gesture was registered
2053
+ * — "→ Sending action: archive" → "✓ Queued — agent will react"
2054
+ * (or "💬 Sent to chat" on ui/message fallback).
2055
+ *
2056
+ * Without this, the iframe is silent during dispatch. User clicks
2057
+ * and waits, with no way to distinguish "click was received but the
2058
+ * agent is busy" from "click was lost". Especially load-bearing in
2059
+ * the consume-pipe vs ui/message dual-path era — the toast tells
2060
+ * the user which path the gesture actually took.
2061
+ *
2062
+ * Direct DOM (not React-managed) because:
2063
+ * - Works during boot before React mounts.
2064
+ * - Doesn't interfere with the component tree the generator owns.
2065
+ * - Survives React mount transitions inside the iframe.
2066
+ *
2067
+ * Single global toast (per iframe). Auto-dismisses after 2.5s on
2068
+ * `success` / `fallback` outcomes. A successful dispatch draws nothing
2069
+ * here (ggui#1444): its "sending" is spoken only (see
2070
+ * {@link GESTURE_COPY}).
2071
+ *
2072
+ * The visible element is only half the surface (ggui#447). Every
2073
+ * message is also spoken through {@link ensureToastAnnouncer}'s live
2074
+ * regions, and the element itself is hidden from the accessibility
2075
+ * tree so the sentence is not read twice. The one exception is
2076
+ * `action_required`, the only toast the user must OPERATE: it re-enters
2077
+ * the tree as a named button with a tab stop, because a dismissal that
2078
+ * needs a mouse is a dismissal some users cannot perform.
2079
+ *
2080
+ * @internal — runtime-layer concern.
2081
+ */
2082
+ /**
2083
+ * The `label` an action declares on the render's `actionSpec`, trimmed, or
2084
+ * `undefined` when the render declares none (or is a system / MCP-Apps
2085
+ * render, which carries no actionSpec). By contract the label is the
2086
+ * control's own copy, so it is the one name for a tap a visitor may read
2087
+ * (see {@link GESTURE_COPY}).
2088
+ *
2089
+ * @internal — exported for unit tests.
2090
+ */
2091
+ export function declaredActionLabel(render, actionName) {
2092
+ if (render === null || render.type === 'mcpApps' || render.type === 'system')
2093
+ return undefined;
2094
+ const declared = render.actionSpec?.[actionName]?.label;
2095
+ return typeof declared === 'string' && declared.trim() !== '' ? declared.trim() : undefined;
2096
+ }
2097
+ /**
2098
+ * Everything the runtime says to a VISITOR about a gesture (ggui#1444).
2099
+ *
2100
+ * A card's action NAME and the data it sends are the author's and the
2101
+ * agent's vocabulary, never the visitor's: a staging rehearsal drew
2102
+ * "→ chooseReply (id: pricing)" over a card after a chip tap. So no text
2103
+ * here names the action or carries its data. When the action declares a
2104
+ * `label` (by contract the control's own copy, already on screen) the text
2105
+ * names the tap by it, so two failed taps on different controls read as two
2106
+ * different sentences to a screen reader (ggui#447); without one it says
2107
+ * the same thing unnamed. Diagnostics keep the name and data (the
2108
+ * `gesture.*` telemetry records); the screen and the live regions do not.
2109
+ *
2110
+ * `sending` and `sentToChat` are spoken, never drawn: a tap the relay
2111
+ * accepted, or a wake-up the host took, needs no chrome over the card — the
2112
+ * pending look belongs to the card's own control (`useActionPending`), and a
2113
+ * host that takes a wake-up shows the conversation continuing in its own UI.
2114
+ * The rest are drawn because each one tells the visitor something they have
2115
+ * to know or do.
2116
+ */
2117
+ const named = (label, rest, unnamed) => label === undefined ? unnamed : `${label} — ${rest}`;
2118
+ const GESTURE_COPY = {
2119
+ sending: (label) => named(label, 'sending…', 'Sending…'),
2120
+ notDelivered: (label) => `⚠ ${named(label, 'not delivered', 'Not delivered')}`,
2121
+ transportError: (label) => `⚠ ${named(label, 'could not send, try again', 'Could not send — try again')}`,
2122
+ unreachable: (label) => `⚠ ${named(label, 'could not reach the agent', 'Could not reach the agent')}`,
2123
+ sentToChat: (label) => named(label, 'sent to the chat', 'Sent to the chat'),
2124
+ sendToContinue: (label) => `💬 ${named(label, 'agent not listening. Send a message to continue.', 'Agent not listening. Send a message to continue.')}`,
2125
+ chatRefused: (label) => `💬 ${named(label, 'the chat did not take the message. Send a message to continue.', 'The chat did not take the message. Send a message to continue.')}`,
2126
+ };
2100
2127
  function showActionToast(text, kind, onDismiss) {
2101
2128
  if (typeof document === 'undefined')
2102
2129
  return;
@@ -2338,9 +2365,14 @@ function emitUserActionDoorbell(args) {
2338
2365
  // `ai.ggui/userAction` to match our other protocol extensions
2339
2366
  // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
2340
2367
  currentTelemetrySink?.record('doorbell.ring', args.sessionId);
2368
+ const doorbellId = Math.floor(Math.random() * 1e9);
2369
+ watchDoorbellReply(doorbellId, (code) => {
2370
+ currentTelemetrySink?.record('doorbell.refused', `${args.sessionId} code=${code}`);
2371
+ args.onRefused();
2372
+ });
2341
2373
  postToParent({
2342
2374
  jsonrpc: '2.0',
2343
- id: Math.floor(Math.random() * 1e9),
2375
+ id: doorbellId,
2344
2376
  method: 'ui/message',
2345
2377
  params: {
2346
2378
  role: 'user',
@@ -2356,6 +2388,54 @@ function emitUserActionDoorbell(args) {
2356
2388
  },
2357
2389
  });
2358
2390
  }
2391
+ /**
2392
+ * How long the view listens for the host's answer to a doorbell. A host
2393
+ * may ask the user before sending the message, so the answer can take a
2394
+ * while; past this the listener is removed and a late answer is ignored.
2395
+ */
2396
+ const DOORBELL_REPLY_WAIT_MS = 5 * 60_000;
2397
+ /**
2398
+ * Listen for the host's JSON-RPC reply to the doorbell `id` (ggui#1314).
2399
+ * The doorbell is posted raw, outside the `App`'s request table, so no
2400
+ * one else correlates it. Only a frame from `window.parent` carrying this
2401
+ * `id` and a `result` or `error` counts; the first such frame ends the
2402
+ * watch. An `error` is the host refusing the doorbell in-band (#1309:
2403
+ * a host delivers a view's `ui/message` or answers it with an error,
2404
+ * never a success), reported with its numeric code (`unknown` when the
2405
+ * host sent none).
2406
+ */
2407
+ function watchDoorbellReply(id, onRefused) {
2408
+ if (typeof window === 'undefined')
2409
+ return;
2410
+ const onMessage = (ev) => {
2411
+ if (ev.source !== window.parent)
2412
+ return;
2413
+ const frame = ev.data;
2414
+ if (frame === null || typeof frame !== 'object')
2415
+ return;
2416
+ if (!('jsonrpc' in frame) || frame.jsonrpc !== '2.0')
2417
+ return;
2418
+ if (!('id' in frame) || frame.id !== id)
2419
+ return;
2420
+ const refused = 'error' in frame;
2421
+ if (!refused && !('result' in frame))
2422
+ return;
2423
+ stop();
2424
+ if (!refused)
2425
+ return;
2426
+ const error = frame.error;
2427
+ const code = error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'number'
2428
+ ? String(error.code)
2429
+ : 'unknown';
2430
+ onRefused(code);
2431
+ };
2432
+ const stop = () => {
2433
+ window.removeEventListener('message', onMessage);
2434
+ clearTimeout(timer);
2435
+ };
2436
+ window.addEventListener('message', onMessage);
2437
+ const timer = setTimeout(stop, DOORBELL_REPLY_WAIT_MS);
2438
+ }
2359
2439
  /**
2360
2440
  * Latch for the relay-incapability notice (ggui#440). A host that
2361
2441
  * cannot relay fails EVERY gesture, so the explanation is stated once
@@ -2604,6 +2684,14 @@ function resetRelayCueThrottles() {
2604
2684
  cancelRelayCueRetraction();
2605
2685
  }
2606
2686
  /** @internal — exported for unit tests to reset module state. */
2687
+ /**
2688
+ * Test-only: install the telemetry sink the runtime records into (normally
2689
+ * set once at boot), so a test can observe the events a code path posts.
2690
+ * `null` restores the no-sink state.
2691
+ */
2692
+ export function __setTelemetrySinkForTest(sink) {
2693
+ currentTelemetrySink = sink;
2694
+ }
2607
2695
  export function __resetRelayNoticeForTest() {
2608
2696
  relayIncapabilityAnnounced = false;
2609
2697
  runtimeConnectionWriter().set(true);
@@ -2687,7 +2775,7 @@ function resolveRelayCueTarget() {
2687
2775
  * the rest of the session is the same stale-announcement defect the
2688
2776
  * toast half avoids by clearing on hide.
2689
2777
  */
2690
- function announceRelayCue(intent) {
2778
+ function announceRelayCue(intent, label) {
2691
2779
  const now = Date.now();
2692
2780
  if (intent === lastRelayCueAnnouncedIntent &&
2693
2781
  now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
@@ -2697,7 +2785,7 @@ function announceRelayCue(intent) {
2697
2785
  lastRelayCueAnnouncedIntent = intent;
2698
2786
  // `announceToast` cancels any retraction still pending, so the timer
2699
2787
  // armed here is always the only one in flight.
2700
- announceToast(`⚠ ${intent} — not delivered`, 'error');
2788
+ announceToast(GESTURE_COPY.notDelivered(label), 'error');
2701
2789
  relayCueAnnounceTimer = window.setTimeout(() => {
2702
2790
  relayCueAnnounceTimer = undefined;
2703
2791
  retractRelayCueAnnouncement();
@@ -2738,7 +2826,7 @@ function retractRelayCueAnnouncement() {
2738
2826
  * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
2739
2827
  * the toast primitive's own announcement.
2740
2828
  */
2741
- function showDeadZoneCue(intent) {
2829
+ function showDeadZoneCue(intent, label) {
2742
2830
  if (typeof document === 'undefined')
2743
2831
  return;
2744
2832
  const target = resolveRelayCueTarget();
@@ -2751,7 +2839,7 @@ function showDeadZoneCue(intent) {
2751
2839
  return;
2752
2840
  ensureRelayCueStyle();
2753
2841
  target.classList.add(RELAY_CUE_CLASS);
2754
- announceRelayCue(intent);
2842
+ announceRelayCue(intent, label);
2755
2843
  // Both cleanup routes cancel the other. Without the `clearTimeout`,
2756
2844
  // a pulse ended early by `animationend` leaves its timer armed, and
2757
2845
  // that timer later strips whatever class is on the element THEN —
@@ -2780,7 +2868,7 @@ function showDeadZoneCue(intent) {
2780
2868
  // a second explanation, so it must clear itself. Throttled because a
2781
2869
  // per-click toast on a host that fails every click is the #426
2782
2870
  // failure mode the latch exists to prevent.
2783
- showActionToast(`⚠ ${intent} — not delivered`, 'error');
2871
+ showActionToast(GESTURE_COPY.notDelivered(label), 'error');
2784
2872
  }
2785
2873
  /**
2786
2874
  * Dispatch a submit-action via the empirically-validated bridge
@@ -2805,7 +2893,7 @@ export function dispatchSubmitAction(args) {
2805
2893
  currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
2806
2894
  return;
2807
2895
  }
2808
- const { toolName, intent, data, sessionId, appId } = args;
2896
+ const { toolName, intent, data, sessionId, appId, label } = args;
2809
2897
  // Gesture-path telemetry — the click's own autopsy trail. #471
2810
2898
  // round 12 hit a frame whose channels + beacons were fully healthy
2811
2899
  // while clicks produced NOTHING observable; without a record at the
@@ -2814,8 +2902,6 @@ export function dispatchSubmitAction(args) {
2814
2902
  currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
2815
2903
  const firedAt = new Date().toISOString();
2816
2904
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2817
- const inlineData = formatSubmitActionDataInline(data);
2818
- const dataPart = inlineData === '' ? '' : ` (${inlineData})`;
2819
2905
  const uiContext = readLocalUiContext();
2820
2906
  // (1) Silent context update — fires FIRST and ALWAYS. Primes the
2821
2907
  // LLM's widget-context surface regardless of which downstream path
@@ -2857,10 +2943,13 @@ export function dispatchSubmitAction(args) {
2857
2943
  // the user has focused NOW; by the time the relay response settles,
2858
2944
  // focus may have moved on.
2859
2945
  if (!relayIncapabilityAnnounced) {
2860
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
2946
+ // No pill over the card (ggui#1444): the previous gesture's notice goes,
2947
+ // and "sending" is spoken for assistive tech only.
2948
+ dismissActionToast();
2949
+ announceToast(GESTURE_COPY.sending(label), 'pending');
2861
2950
  }
2862
2951
  else if (!isRelayNoticeVisible()) {
2863
- showDeadZoneCue(intent);
2952
+ showDeadZoneCue(intent, label);
2864
2953
  }
2865
2954
  // (2) Try submit_action via host relay. Spec-compliant hosts
2866
2955
  // forward the tools/call to the MCP server's submit_action handler;
@@ -2897,7 +2986,7 @@ export function dispatchSubmitAction(args) {
2897
2986
  });
2898
2987
  }
2899
2988
  catch {
2900
- showActionToast(`⚠ ${intent} — transport error`, 'error');
2989
+ showActionToast(GESTURE_COPY.transportError(label), 'error');
2901
2990
  resp = null;
2902
2991
  }
2903
2992
  // Final hop of the gesture autopsy trail — what the relay
@@ -2940,7 +3029,7 @@ export function dispatchSubmitAction(args) {
2940
3029
  // through to the terminal "could not reach the agent" toast a
2941
3030
  // few lines down, which already replaces the stale notice.
2942
3031
  if (classifySubmitActionResponse(resp) === 'success') {
2943
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
3032
+ dismissActionToast();
2944
3033
  }
2945
3034
  }
2946
3035
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
@@ -2964,14 +3053,29 @@ export function dispatchSubmitAction(args) {
2964
3053
  // `ggui_consume`; servers that CAN answer always send an
2965
3054
  // explicit `true` (the factory wires the registry
2966
3055
  // unconditionally), so confirmed-consumer hosts stay quiet.
2967
- showActionToast(hostCanReceiveMessages()
2968
- ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2969
- : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
3056
+ // A host that takes the wake-up carries it in its own UI (the
3057
+ // conversation visibly continues, or a prepared message lands in
3058
+ // its input), so nothing is drawn over the card (ggui#1444): the tap
3059
+ // is spoken to assistive tech only. The card draws only when the
3060
+ // visitor must act — this host cannot receive a message at all, or
3061
+ // it refuses this one (`onRefused` below).
3062
+ if (hostCanReceiveMessages()) {
3063
+ announceToast(GESTURE_COPY.sentToChat(label), 'pending');
3064
+ }
3065
+ else {
3066
+ showActionToast(GESTURE_COPY.sendToContinue(label), 'action_required');
3067
+ }
2970
3068
  emitUserActionDoorbell({
2971
3069
  intent,
2972
3070
  sessionId,
2973
3071
  actionId,
2974
3072
  submittedAt: firedAt,
3073
+ // The host refused the wake-up in-band (ggui#1314), so the
3074
+ // visitor has to act: say what. The gesture itself is safe on
3075
+ // the pipe; only the wake-up failed.
3076
+ onRefused: () => {
3077
+ showActionToast(GESTURE_COPY.chatRefused(label), 'action_required');
3078
+ },
2975
3079
  });
2976
3080
  return;
2977
3081
  }
@@ -3053,7 +3157,7 @@ export function dispatchSubmitAction(args) {
3053
3157
  announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
3054
3158
  return;
3055
3159
  }
3056
- showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
3160
+ showActionToast(GESTURE_COPY.unreachable(label), 'error');
3057
3161
  })();
3058
3162
  }
3059
3163
  /**
@@ -3170,13 +3274,14 @@ export function resolveDispatchToolName() {
3170
3274
  * @internal — exported for unit tests + production reuse.
3171
3275
  */
3172
3276
  export function routeDispatch(args) {
3173
- const { actionName, data, meta, dispatchToolName } = args;
3277
+ const { actionName, data, meta, dispatchToolName, label } = args;
3174
3278
  dispatchSubmitAction({
3175
3279
  toolName: dispatchToolName,
3176
3280
  intent: actionName,
3177
3281
  data,
3178
3282
  sessionId: meta.sessionId,
3179
3283
  appId: meta.appId,
3284
+ ...(label !== undefined ? { label } : {}),
3180
3285
  });
3181
3286
  }
3182
3287
  /**
@@ -3794,6 +3899,15 @@ async function bootProduction(opts) {
3794
3899
  // through `currentRender`-returning thunks so they always see the
3795
3900
  // latest snapshot without holding stale refs.
3796
3901
  let currentRender = null;
3902
+ // ggui#1223 — who to tell when `currentRender` is replaced, so the card's
3903
+ // spent state (read through `getCurrentGguiSession`) is re-read at once.
3904
+ const renderListeners = new Set();
3905
+ const onRenderChange = (listener) => {
3906
+ renderListeners.add(listener);
3907
+ return () => {
3908
+ renderListeners.delete(listener);
3909
+ };
3910
+ };
3797
3911
  let renderHandle = null;
3798
3912
  const dispatchToolName = resolveDispatchToolName();
3799
3913
  // Native-idiom interceptors — the replacement for the retired
@@ -3841,6 +3955,7 @@ async function bootProduction(opts) {
3841
3955
  sessionId: meta.sessionId,
3842
3956
  appId: meta.appId,
3843
3957
  getCurrentGguiSession: () => currentRender,
3958
+ renderChanges: onRenderChange,
3844
3959
  manager,
3845
3960
  streamBus,
3846
3961
  onDispatchEnvelope: (envelope) => {
@@ -3856,6 +3971,8 @@ async function bootProduction(opts) {
3856
3971
  || payload.action.length === 0) {
3857
3972
  return;
3858
3973
  }
3974
+ // The declared label names the tap in visitor copy (ggui#1444).
3975
+ const label = declaredActionLabel(currentRender, payload.action);
3859
3976
  routeDispatch({
3860
3977
  actionName: payload.action,
3861
3978
  data: payload.data,
@@ -3864,6 +3981,7 @@ async function bootProduction(opts) {
3864
3981
  appId: meta.appId,
3865
3982
  },
3866
3983
  dispatchToolName,
3984
+ ...(label !== undefined ? { label } : {}),
3867
3985
  });
3868
3986
  },
3869
3987
  });
@@ -3998,6 +4116,8 @@ async function bootProduction(opts) {
3998
4116
  };
3999
4117
  const applyRender = async (render) => {
4000
4118
  currentRender = render;
4119
+ for (const listener of [...renderListeners])
4120
+ listener();
4001
4121
  if (renderHandle === null) {
4002
4122
  renderHandle = await mountRender(renderInto, buildOpts(render));
4003
4123
  return;
@@ -4099,6 +4219,7 @@ async function bootProduction(opts) {
4099
4219
  : {}),
4100
4220
  },
4101
4221
  }),
4222
+ classifyFrame: classifyChannelFrame,
4102
4223
  });
4103
4224
  channelRegistry.register(createRenderHandler({
4104
4225
  statusRefs,
@@ -1 +1 @@
1
- const w = globalThis.__ggui__.wire; export const useAction = w.useAction; export const useStream = w.useStream; export const useAuth = w.useAuth; export const useApp = w.useApp; export const useRender = w.useRender; export const useGguiContext = w.useGguiContext; export const useContract = w.useContract; export const useWireContext = w.useWireContext; export const GguiWireProvider = w.GguiWireProvider; export default w;
1
+ const w = globalThis.__ggui__.wire; export const useAction = w.useAction; export const useStream = w.useStream; export const useAuth = w.useAuth; export const useApp = w.useApp; export const useRender = w.useRender; export const useGguiContext = w.useGguiContext; export const useContract = w.useContract; export const useWireContext = w.useWireContext; export const useActionSpent = w.useActionSpent; export const useActionPending = w.useActionPending; export const GguiWireProvider = w.GguiWireProvider; export default w;
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { ActionEnvelope, GguiSession } from '@ggui-ai/protocol/wire';
20
20
  import type { GguiSessionSeedInput } from './types.js';
21
- import { ClientContractViolationError, StreamBus, type WireConfig } from '@ggui-ai/wire';
21
+ import { ClientContractViolationError, StreamBus, type BuiltWireConfig } from '@ggui-ai/wire';
22
22
  import type { WebSocketMessage } from '@ggui-ai/protocol/transport/websocket';
23
23
  /**
24
24
  * Outbound send surface — the minimal shape wire-config calls on the
@@ -48,6 +48,13 @@ export interface BuildRootWireConfigOptions {
48
48
  * WireConfig.
49
49
  */
50
50
  readonly getCurrentGguiSession: () => GguiSession | GguiSessionSeedInput | null;
51
+ /**
52
+ * The runtime's notice that the current render was replaced (a props
53
+ * update, a later live frame), so the card's spent state is re-read at
54
+ * once (ggui#1223), and the card's in-flight dispatches clear (ggui#1398).
55
+ * Passed through as wire's `spentInputsChanged` and `pendingInputsChanged`.
56
+ */
57
+ readonly renderChanges?: (listener: () => void) => () => void;
51
58
  /** Handle to the renderer's WS manager; used for outbound `action` frames. */
52
59
  readonly manager: RendererSendSurface;
53
60
  /** Shared bus for inbound stream deliveries. */
@@ -124,4 +131,4 @@ export interface BuildRootWireConfigOptions {
124
131
  * `RootWireConfigBundle` / `scopeWireConfig` / `buildScopedConfig`
125
132
  * indirection collapsed to a single `WireConfig`.
126
133
  */
127
- export declare function buildRootWireConfig(opts: BuildRootWireConfigOptions): WireConfig;
134
+ export declare function buildRootWireConfig(opts: BuildRootWireConfigOptions): BuiltWireConfig;
@@ -87,6 +87,10 @@ export function buildRootWireConfig(opts) {
87
87
  : undefined;
88
88
  },
89
89
  onActionSpecAbsent: nameUnenforceableOnce,
90
+ ...(opts.renderChanges !== undefined ? { spentInputsChanged: opts.renderChanges } : {}),
91
+ // ggui#1398 — a replaced render is the session's next frame: the agent's
92
+ // answer, so every in-flight dispatch stops reading pending.
93
+ ...(opts.renderChanges !== undefined ? { pendingInputsChanged: opts.renderChanges } : {}),
90
94
  // ggui#1223 — the card's persisted spent `oneShot` names. A re-served card
91
95
  // boots a new iframe, so the guard's in-memory set starts empty; the
92
96
  // render's record says what this card already spent. It counts only when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.23.0",
3
+ "version": "0.25.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/live-channel": "0.23.0",
45
- "@ggui-ai/preview-a2ui": "0.23.0",
46
- "@ggui-ai/protocol": "0.23.0",
47
- "@ggui-ai/gadgets": "0.23.0",
48
- "@ggui-ai/design": "0.23.0",
49
- "@ggui-ai/wire": "0.23.0"
44
+ "@ggui-ai/gadgets": "0.25.0",
45
+ "@ggui-ai/design": "0.25.0",
46
+ "@ggui-ai/live-channel": "0.25.0",
47
+ "@ggui-ai/preview-a2ui": "0.25.0",
48
+ "@ggui-ai/protocol": "0.25.0",
49
+ "@ggui-ai/wire": "0.25.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.0"