@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/iframe-runtime.js +70 -69
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/observability.d.ts +16 -10
- package/dist/react-renderer.d.ts +2 -0
- package/dist/react-renderer.js +96 -3
- package/dist/registry-subscribe.d.ts +16 -1
- package/dist/registry-subscribe.js +29 -0
- package/dist/render-item.d.ts +7 -3
- package/dist/render-item.js +10 -2
- package/dist/runtime.d.ts +56 -17
- package/dist/runtime.js +194 -73
- package/dist/shims/wire.js +1 -1
- package/dist/wire-config.d.ts +9 -2
- package/dist/wire-config.js +4 -0
- package/package.json +7 -7
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
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(
|
|
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,
|
package/dist/shims/wire.js
CHANGED
|
@@ -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;
|
package/dist/wire-config.d.ts
CHANGED
|
@@ -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
|
|
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):
|
|
134
|
+
export declare function buildRootWireConfig(opts: BuildRootWireConfigOptions): BuiltWireConfig;
|
package/dist/wire-config.js
CHANGED
|
@@ -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.
|
|
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/
|
|
45
|
-
"@ggui-ai/
|
|
46
|
-
"@ggui-ai/
|
|
47
|
-
"@ggui-ai/
|
|
48
|
-
"@ggui-ai/
|
|
49
|
-
"@ggui-ai/wire": "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"
|