@ggui-ai/iframe-runtime 0.8.0 → 0.9.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/call-tool-unwrap.d.ts +29 -0
- package/dist/call-tool-unwrap.js +56 -0
- package/dist/channels/props-update.d.ts +19 -0
- package/dist/channels/props-update.js +14 -1
- package/dist/events-polling.d.ts +123 -1
- package/dist/events-polling.js +181 -53
- package/dist/iframe-runtime.js +63 -63
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.js +13 -0
- package/dist/observability.d.ts +1 -1
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +1 -1
- package/dist/react-renderer.js +40 -12
- package/dist/registry-subscribe.d.ts +42 -3
- package/dist/registry-subscribe.js +33 -10
- package/dist/render-item.js +1 -1
- package/dist/runtime-telemetry.d.ts +53 -0
- package/dist/runtime-telemetry.js +74 -0
- package/dist/runtime.js +325 -70
- package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
- package/dist/system-cards/UiFeedbackCard.js +1 -1
- package/dist/ui-feedback-chrome.js +7 -4
- package/package.json +7 -7
package/dist/runtime.js
CHANGED
|
@@ -7,9 +7,11 @@ import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
|
7
7
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
8
8
|
import { RelayIncapableError } from './relay-incapability.js';
|
|
9
9
|
import { ChannelRegistry } from '@ggui-ai/live-channel';
|
|
10
|
+
import { createTelemetrySink, } from './runtime-telemetry.js';
|
|
10
11
|
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
|
|
11
12
|
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
12
|
-
import { buildEventsPolling } from './events-polling.js';
|
|
13
|
+
import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
|
|
14
|
+
import { unwrapCallToolResult } from './call-tool-unwrap.js';
|
|
13
15
|
import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
|
|
14
16
|
import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
|
|
15
17
|
import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
|
|
@@ -386,16 +388,24 @@ export async function bootSequence(opts) {
|
|
|
386
388
|
// In-iframe UI-feedback affordance (ggui#244) — mounted adjacent to
|
|
387
389
|
// the session root, gated inside the helper on `window.parent !==
|
|
388
390
|
// window` (top-level `/r/<shortCode>` tabs have no `ggui:observe`
|
|
389
|
-
// egress, so they get NO affordance)
|
|
390
|
-
// `
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
391
|
+
// egress, so they get NO affordance) AND here on a LIVE SINK: an
|
|
392
|
+
// injected `onObserve` emitter, or a first-party embed host — the
|
|
393
|
+
// only parent that consumes the `ggui:observe` envelope
|
|
394
|
+
// (mcp-app-iframe-host answers `ui/initialize` with exactly this
|
|
395
|
+
// hostInfo name). Third-party MCP-Apps hosts (claude.ai, ChatGPT,
|
|
396
|
+
// Claude Desktop) drop the envelope on the floor, so rendering the
|
|
397
|
+
// affordance there is a dead control — the first #471 claude.ai
|
|
398
|
+
// retest surfaced it as unstyled clutter that could never deliver
|
|
399
|
+
// feedback anywhere. Fire-and-forget: chrome never blocks or fails
|
|
400
|
+
// the boot.
|
|
401
|
+
const feedbackSinkLive = onObserve !== undefined ||
|
|
402
|
+
app.getHostVersion()?.name === 'ggui-iframe-runtime-embed-host';
|
|
403
|
+
if (feedbackSinkLive) {
|
|
404
|
+
void mountUiFeedbackChrome(doc, {
|
|
405
|
+
emit: onObserve ?? postObservabilityToParent,
|
|
406
|
+
sessionId: meta.sessionId,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
399
409
|
setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
|
|
400
410
|
// Boot-without-renderer path: we still need a ChannelRegistry to
|
|
401
411
|
// receive frames, because the registry is the only dispatch
|
|
@@ -471,6 +481,27 @@ export async function bootSequence(opts) {
|
|
|
471
481
|
meta.wsUrl.length > 0 &&
|
|
472
482
|
typeof meta.wsToken === 'string' &&
|
|
473
483
|
meta.wsToken.length > 0;
|
|
484
|
+
// Transport-telemetry sink — the iframe's self-report channel (see
|
|
485
|
+
// runtime-telemetry.ts). Created for EVERY boot with an App bridge;
|
|
486
|
+
// the first batch carries the boot-path decision so operators can
|
|
487
|
+
// see which ladder (if any) this mount composed, even on hosts
|
|
488
|
+
// whose console and network are unreachable.
|
|
489
|
+
const telemetryApp = getCurrentApp();
|
|
490
|
+
const telemetry = telemetryApp !== null
|
|
491
|
+
? createTelemetrySink({
|
|
492
|
+
sessionId: meta.sessionId,
|
|
493
|
+
callTool: (args) => telemetryApp.callServerTool(args),
|
|
494
|
+
})
|
|
495
|
+
: null;
|
|
496
|
+
currentTelemetrySink = telemetry;
|
|
497
|
+
// Fresh boot ⇒ not superseded (matters only for test re-runs that
|
|
498
|
+
// reuse the module; a real iframe boots once).
|
|
499
|
+
mountSuperseded = false;
|
|
500
|
+
telemetry?.record('boot.path', JSON.stringify({
|
|
501
|
+
hasStaticContent,
|
|
502
|
+
hasLiveTrio,
|
|
503
|
+
bridgeCapable: telemetryApp !== null,
|
|
504
|
+
}));
|
|
474
505
|
// ── Static seed mount — zero-round-trip paint, no WS required. ──────
|
|
475
506
|
// The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
|
|
476
507
|
// ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
|
|
@@ -511,17 +542,28 @@ export async function bootSequence(opts) {
|
|
|
511
542
|
emitCodeReadyOnce();
|
|
512
543
|
}
|
|
513
544
|
}
|
|
514
|
-
// ── No live channel → static-only host.
|
|
545
|
+
// ── No live channel → static-only host (unless bridge-capable). ─────
|
|
515
546
|
// The static seed already painted (or there was nothing to paint).
|
|
516
547
|
// A bootstrap with neither static content nor a live trio is a
|
|
517
548
|
// misconfiguration — surface a typed boot failure.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
549
|
+
//
|
|
550
|
+
// #471 round-6 live finding: when the host exposes an App bridge
|
|
551
|
+
// (tools/call postMessage — claude.ai, every MCP Apps host), a
|
|
552
|
+
// trio-less static mount must NOT stop here — the bridge-pull
|
|
553
|
+
// terminal rung is exactly its subscription. Fall through to the
|
|
554
|
+
// subscribe below, which binds trio-less (the registry starts the
|
|
555
|
+
// ladder at its tail). Gating the terminal rung behind the WS
|
|
556
|
+
// trio's presence disabled it on precisely the host class it was
|
|
557
|
+
// built for.
|
|
558
|
+
const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
|
|
559
|
+
if (!hasLiveTrio && mountedRender === null) {
|
|
560
|
+
const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
|
|
561
|
+
setStatus(refs, message, 'error');
|
|
562
|
+
emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
|
|
563
|
+
return { ok: false, mountedRender };
|
|
564
|
+
}
|
|
565
|
+
if (staticOnlyNoBridge) {
|
|
566
|
+
telemetry?.record('boot.static_only_no_bridge');
|
|
525
567
|
// Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
|
|
526
568
|
// / Claude Desktop). The INITIAL host theme/style/fonts were already
|
|
527
569
|
// applied at boot (applyHostContextStyling, above); attach the
|
|
@@ -543,12 +585,42 @@ export async function bootSequence(opts) {
|
|
|
543
585
|
return { ok: true, mountedRender };
|
|
544
586
|
}
|
|
545
587
|
// ── Live-channel subscribe (conditional enhancement). ──────────────
|
|
588
|
+
// Failover-ladder rung URLs (WS → SSE → polling → bridge-pull),
|
|
589
|
+
// hoisted so the spreads below stay exact-optional-clean without
|
|
590
|
+
// non-null assertions. `sseUrl` is the server-stamped wsToken-gated
|
|
591
|
+
// `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
|
|
592
|
+
// SAME cursor model as the WS `sinceSequence` replay and polling
|
|
593
|
+
// ticks — switching rungs does not lose events.
|
|
594
|
+
const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
|
|
595
|
+
? meta.sseUrl
|
|
596
|
+
: undefined;
|
|
597
|
+
const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
|
|
598
|
+
? meta.pollingUrl
|
|
599
|
+
: undefined;
|
|
600
|
+
// One shared cursor for the whole ladder — SSE deliveries advance it
|
|
601
|
+
// (via onSequence), the polling + bridge descriptors read it per
|
|
602
|
+
// tick, so a demotion down the ladder resumes from the last
|
|
603
|
+
// delivered event instead of re-replaying from the boot snapshot.
|
|
604
|
+
// Created when ANY fallback rung exists — including the bridge-pull
|
|
605
|
+
// rung, whose only precondition is a connected App handle (the
|
|
606
|
+
// universal floor: a CSP-jailed host stamps no sseUrl/pollingUrl,
|
|
607
|
+
// yet its `tools/call` postMessage bridge still reaches the ledger).
|
|
608
|
+
const bridgeApp = getCurrentApp();
|
|
609
|
+
const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
|
|
610
|
+
? createSequenceCursor(meta.lastSequence ?? 0)
|
|
611
|
+
: undefined;
|
|
546
612
|
let handle;
|
|
547
613
|
try {
|
|
548
614
|
handle = await connectFn({
|
|
549
615
|
meta,
|
|
550
616
|
registry: activeRegistry,
|
|
617
|
+
// Live-channel diagnostics tap — every channel_* event the
|
|
618
|
+
// transports emit (failover swaps, polling budget exhaustion,
|
|
619
|
+
// SSE lifecycle) lands in the telemetry buffer. Without this the
|
|
620
|
+
// ladder's story on sandboxed hosts is unobservable.
|
|
621
|
+
...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
|
|
551
622
|
onStatusChange: (status) => {
|
|
623
|
+
telemetry?.record(`status.${status}`);
|
|
552
624
|
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
553
625
|
// Propagate WS status to the per-channel transport router so
|
|
554
626
|
// it can flip WS-bound channels into polling
|
|
@@ -573,21 +645,48 @@ export async function bootSequence(opts) {
|
|
|
573
645
|
onResubscribeAck: (ack) => {
|
|
574
646
|
void applyAck(ack);
|
|
575
647
|
},
|
|
576
|
-
//
|
|
577
|
-
// bind time from
|
|
578
|
-
// gated
|
|
579
|
-
// (cursor seed). FailoverHandle
|
|
580
|
-
// 'failed';
|
|
648
|
+
// Failover-ladder fallback rungs (WS → SSE → polling →
|
|
649
|
+
// bridge-pull), composed once at bind time from the
|
|
650
|
+
// server-stamped wsToken-gated URLs + `meta.lastSequence`
|
|
651
|
+
// (shared cursor seed). FailoverHandle descends a rung on each
|
|
652
|
+
// 'failed'; the bridge rung is terminal (never emits 'failed').
|
|
581
653
|
//
|
|
582
654
|
// Same cursor model as the WS subscribe `sinceSequence` replay
|
|
583
655
|
// path — switching transports does not lose events.
|
|
584
|
-
...(
|
|
656
|
+
...(sseUrl !== undefined && ladderCursor !== undefined
|
|
657
|
+
? {
|
|
658
|
+
sse: {
|
|
659
|
+
url: sseUrl,
|
|
660
|
+
initialSinceSequence: ladderCursor.get(),
|
|
661
|
+
onSequence: (seq) => {
|
|
662
|
+
ladderCursor.advance(seq);
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
: {}),
|
|
667
|
+
...(pollingBaseUrl !== undefined && ladderCursor !== undefined
|
|
585
668
|
? {
|
|
586
669
|
polling: buildEventsPolling({
|
|
587
|
-
baseUrl:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
670
|
+
baseUrl: pollingBaseUrl,
|
|
671
|
+
cursor: ladderCursor,
|
|
672
|
+
}),
|
|
673
|
+
}
|
|
674
|
+
: {}),
|
|
675
|
+
// Bridge-pull terminal rung — composed whenever the App handle
|
|
676
|
+
// is bound (it always is by this point in the boot: connectApp
|
|
677
|
+
// succeeded and setCurrentApp ran above), INDEPENDENT of
|
|
678
|
+
// sseUrl/pollingUrl presence. This is the universal floor for
|
|
679
|
+
// CSP-jailed hosts (claude.ai) where the iframe has no network
|
|
680
|
+
// path at all: the ledger is pulled via `ggui_runtime_pull`
|
|
681
|
+
// `tools/call`s over the host's postMessage bridge. Shares the
|
|
682
|
+
// SAME ladder cursor — a demotion into the bridge resumes from
|
|
683
|
+
// whatever the rungs above already delivered.
|
|
684
|
+
...(bridgeApp !== null && ladderCursor !== undefined
|
|
685
|
+
? {
|
|
686
|
+
bridge: buildBridgePolling({
|
|
687
|
+
callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
|
|
688
|
+
sessionId: meta.sessionId,
|
|
689
|
+
cursor: ladderCursor,
|
|
591
690
|
}),
|
|
592
691
|
}
|
|
593
692
|
: {}),
|
|
@@ -626,12 +725,19 @@ export async function bootSequence(opts) {
|
|
|
626
725
|
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
627
726
|
return { ok: false, mountedRender };
|
|
628
727
|
}
|
|
728
|
+
telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
|
|
629
729
|
// Attach the live transport handle to the renderer — flushes any
|
|
630
|
-
// buffered outbound `action` frames that were queued
|
|
631
|
-
//
|
|
632
|
-
//
|
|
730
|
+
// buffered outbound `action` frames that were queued while the
|
|
731
|
+
// subscribe handshake completed. `send` narrows to the ws surface:
|
|
732
|
+
// a trio-less bridge-only bind has NO outbound channel (gestures
|
|
733
|
+
// ride app.callServerTool, not the transport), so its sends drop —
|
|
734
|
+
// the same posture a post-swap FailoverHandle already has.
|
|
735
|
+
const transportSend = (msg) => {
|
|
736
|
+
if (handle.handle.kind === 'ws')
|
|
737
|
+
handle.handle.send(msg);
|
|
738
|
+
};
|
|
633
739
|
if (renderer !== null && rendererHooks?.attachManager !== undefined) {
|
|
634
|
-
rendererHooks.attachManager(renderer, { send:
|
|
740
|
+
rendererHooks.attachManager(renderer, { send: transportSend });
|
|
635
741
|
}
|
|
636
742
|
// seed the host-context emitter with the projection captured from
|
|
637
743
|
// `app.getHostContext()` (the App class's spec-canonical
|
|
@@ -642,7 +748,7 @@ export async function bootSequence(opts) {
|
|
|
642
748
|
if (parsed.hostContext !== undefined) {
|
|
643
749
|
seedHostContext({
|
|
644
750
|
sessionId: meta.sessionId,
|
|
645
|
-
send:
|
|
751
|
+
send: transportSend,
|
|
646
752
|
initial: parsed.hostContext,
|
|
647
753
|
});
|
|
648
754
|
// Bind via App's spec-canonical `hostcontextchanged` event surface.
|
|
@@ -655,8 +761,12 @@ export async function bootSequence(opts) {
|
|
|
655
761
|
// `pinnedSessionId`) and mount it. Reuses the same `applyAck` helper
|
|
656
762
|
// the reconnect-rebootstrap path uses — so a server-restart-driven
|
|
657
763
|
// snapshot replay and the first-boot snapshot apply flow through
|
|
658
|
-
// one implementation.
|
|
659
|
-
|
|
764
|
+
// one implementation. Trio-less bridge-only binds have no handshake
|
|
765
|
+
// and therefore no ack — the static seed already painted, and the
|
|
766
|
+
// bridge delivers deltas from the boot cursor.
|
|
767
|
+
if (handle.ack !== undefined) {
|
|
768
|
+
await applyAck(handle.ack);
|
|
769
|
+
}
|
|
660
770
|
setConnectedStatus(refs);
|
|
661
771
|
// Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
|
|
662
772
|
// (a static+live meta already fired it from the seed mount, so this is
|
|
@@ -1307,6 +1417,23 @@ export function emitAudit(args) {
|
|
|
1307
1417
|
* iframe-runtime then falls through to `ui/message` so the gesture
|
|
1308
1418
|
* still reaches the agent on its next turn.
|
|
1309
1419
|
*/
|
|
1420
|
+
/**
|
|
1421
|
+
* Unwrap the payload record of a relayed submit-action tool result.
|
|
1422
|
+
* Envelope-level shim over the shared 3-tier unwrap
|
|
1423
|
+
* ({@link unwrapCallToolResult}: `structuredContent` →
|
|
1424
|
+
* `content[0].text` parsed as JSON → bare result) — this wrapper only
|
|
1425
|
+
* peels the JSON-RPC `result` field first. The tiers themselves are
|
|
1426
|
+
* shared with the bridge-pull rung's `fetchBody` carrier
|
|
1427
|
+
* (`events-polling.ts`) so both reads agree on which envelope tier
|
|
1428
|
+
* carries the payload for a given host shape.
|
|
1429
|
+
* Returns `null` when no tier yields an object — the caller treats
|
|
1430
|
+
* that as fallback/unknown.
|
|
1431
|
+
*/
|
|
1432
|
+
function submitActionPayload(resp) {
|
|
1433
|
+
if (resp === null || typeof resp !== 'object')
|
|
1434
|
+
return null;
|
|
1435
|
+
return unwrapCallToolResult(resp.result);
|
|
1436
|
+
}
|
|
1310
1437
|
function classifySubmitActionResponse(resp) {
|
|
1311
1438
|
if (resp === null || typeof resp !== 'object')
|
|
1312
1439
|
return 'fallback';
|
|
@@ -1315,15 +1442,9 @@ function classifySubmitActionResponse(resp) {
|
|
|
1315
1442
|
resp.error !== null) {
|
|
1316
1443
|
return 'fallback';
|
|
1317
1444
|
}
|
|
1318
|
-
const
|
|
1319
|
-
if (
|
|
1445
|
+
const inner = submitActionPayload(resp);
|
|
1446
|
+
if (inner === null)
|
|
1320
1447
|
return 'fallback';
|
|
1321
|
-
const r = result;
|
|
1322
|
-
// Spec-canonical hosts wrap tool output under `structuredContent`;
|
|
1323
|
-
// looser hosts may surface fields directly on `result`.
|
|
1324
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1325
|
-
? r.structuredContent
|
|
1326
|
-
: r;
|
|
1327
1448
|
return inner.ok === true ? 'success' : 'fallback';
|
|
1328
1449
|
}
|
|
1329
1450
|
/**
|
|
@@ -1355,25 +1476,65 @@ function isRelayShapedFailure(resp) {
|
|
|
1355
1476
|
/**
|
|
1356
1477
|
* Extract `consumerPresent` from a successful submit_action response.
|
|
1357
1478
|
* Returns the boolean if present + well-typed; `undefined` otherwise
|
|
1358
|
-
* (
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1479
|
+
* (agnostic host stripped the field, or any non-`ok:true` shape).
|
|
1480
|
+
* `undefined` is treated by the dispatch gate as "no confirmed
|
|
1481
|
+
* consumer" — the doorbell rings. Shares {@link submitActionPayload}
|
|
1482
|
+
* with {@link classifySubmitActionResponse} so both reads agree on
|
|
1483
|
+
* which envelope tier carries the payload.
|
|
1363
1484
|
*/
|
|
1364
1485
|
function extractConsumerPresent(resp) {
|
|
1365
|
-
|
|
1486
|
+
const inner = submitActionPayload(resp);
|
|
1487
|
+
if (inner === null)
|
|
1366
1488
|
return undefined;
|
|
1367
|
-
const result = resp.result;
|
|
1368
|
-
if (result === null || typeof result !== 'object')
|
|
1369
|
-
return undefined;
|
|
1370
|
-
const r = result;
|
|
1371
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1372
|
-
? r.structuredContent
|
|
1373
|
-
: r;
|
|
1374
1489
|
const flag = inner.consumerPresent;
|
|
1375
1490
|
return typeof flag === 'boolean' ? flag : undefined;
|
|
1376
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Freeze-cue overlay (#483). A superseded (history) card gets a
|
|
1494
|
+
* subtle, theme-aware "superseded" veil + a labelled pill, and its
|
|
1495
|
+
* content is made non-interactive so a stale-view click can't even
|
|
1496
|
+
* reach a control. Idempotent — the overlay id guards a second call.
|
|
1497
|
+
* A polite live-region announcement (reusing the #447 announcer)
|
|
1498
|
+
* tells a screen-reader user the card is now history.
|
|
1499
|
+
*/
|
|
1500
|
+
const FREEZE_CUE_ID = '__ggui-superseded-cue__';
|
|
1501
|
+
function applyFreezeCue(root) {
|
|
1502
|
+
if (typeof document === 'undefined')
|
|
1503
|
+
return;
|
|
1504
|
+
if (root.querySelector(`#${FREEZE_CUE_ID}`) !== null)
|
|
1505
|
+
return;
|
|
1506
|
+
// Non-interactive + visually receded — inherits the mount's own
|
|
1507
|
+
// colors, so it reads correctly in any theme.
|
|
1508
|
+
root.style.pointerEvents = 'none';
|
|
1509
|
+
root.style.opacity = '0.55';
|
|
1510
|
+
root.style.filter = 'grayscale(0.4)';
|
|
1511
|
+
root.style.transition = 'opacity 160ms ease, filter 160ms ease';
|
|
1512
|
+
if (getComputedStyle(root).position === 'static') {
|
|
1513
|
+
root.style.position = 'relative';
|
|
1514
|
+
}
|
|
1515
|
+
const pill = document.createElement('div');
|
|
1516
|
+
pill.id = FREEZE_CUE_ID;
|
|
1517
|
+
pill.setAttribute('role', 'note');
|
|
1518
|
+
pill.textContent = 'Superseded — a newer version continues below';
|
|
1519
|
+
Object.assign(pill.style, {
|
|
1520
|
+
position: 'absolute',
|
|
1521
|
+
top: '8px',
|
|
1522
|
+
right: '8px',
|
|
1523
|
+
padding: '4px 10px',
|
|
1524
|
+
borderRadius: '9999px',
|
|
1525
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
1526
|
+
fontSize: '11px',
|
|
1527
|
+
fontWeight: '600',
|
|
1528
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
1529
|
+
background: 'rgba(128,128,128,0.16)',
|
|
1530
|
+
border: '1px solid currentColor',
|
|
1531
|
+
opacity: '0.85',
|
|
1532
|
+
pointerEvents: 'none',
|
|
1533
|
+
zIndex: '2',
|
|
1534
|
+
});
|
|
1535
|
+
root.appendChild(pill);
|
|
1536
|
+
announceToast('This card was superseded by a newer version below', 'fallback');
|
|
1537
|
+
}
|
|
1377
1538
|
/** Container id for the announcer holding both live regions. */
|
|
1378
1539
|
const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
|
|
1379
1540
|
/**
|
|
@@ -1710,7 +1871,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1710
1871
|
` <next_args>${nextArgs}</next_args>`,
|
|
1711
1872
|
`</ggui_directive>`,
|
|
1712
1873
|
'',
|
|
1713
|
-
`The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call
|
|
1874
|
+
`The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_amend on the SAME sessionId (${args.sessionId}) — the card the user is looking at repaints in place. Use ggui_update instead only if this moment deserves a NEW card in the conversation (it advances the history number).`,
|
|
1714
1875
|
].join('\n');
|
|
1715
1876
|
// Structured mirror of the directive for ggui-aware programmatic
|
|
1716
1877
|
// consumers. OPTIONAL — nothing in the loop depends on a server-side
|
|
@@ -1754,6 +1915,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1754
1915
|
// record — the proper extension point). Namespaced under
|
|
1755
1916
|
// `ai.ggui/userAction` to match our other protocol extensions
|
|
1756
1917
|
// (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
|
|
1918
|
+
currentTelemetrySink?.record('doorbell.ring', args.sessionId);
|
|
1757
1919
|
postToParent({
|
|
1758
1920
|
jsonrpc: '2.0',
|
|
1759
1921
|
id: Math.floor(Math.random() * 1e9),
|
|
@@ -1785,6 +1947,23 @@ function emitUserActionDoorbell(args) {
|
|
|
1785
1947
|
* nothing else about the latch is observable to the host.
|
|
1786
1948
|
*/
|
|
1787
1949
|
let relayIncapabilityAnnounced = false;
|
|
1950
|
+
/**
|
|
1951
|
+
* Module-level handle to the CURRENT boot's telemetry sink so
|
|
1952
|
+
* module-scope paths (the doorbell emitter below) can report without
|
|
1953
|
+
* threading the sink through every call chain. Per-iframe
|
|
1954
|
+
* single-tenancy makes a single slot correct — same justification as
|
|
1955
|
+
* `getCurrentApp()`.
|
|
1956
|
+
*/
|
|
1957
|
+
let currentTelemetrySink = null;
|
|
1958
|
+
/**
|
|
1959
|
+
* Freeze latch (#483): set true once a higher-epoch `ggui_update`
|
|
1960
|
+
* supersedes this mount. A frozen mount is HISTORY — its gestures no
|
|
1961
|
+
* longer target the live session, so `dispatchSubmitAction` drops them
|
|
1962
|
+
* (a stale-view submit against the live head is a prevented bug class).
|
|
1963
|
+
* Module-level for the same single-mount-per-iframe reason as
|
|
1964
|
+
* `currentTelemetrySink`.
|
|
1965
|
+
*/
|
|
1966
|
+
let mountSuperseded = false;
|
|
1788
1967
|
/**
|
|
1789
1968
|
* Whether the user has manually dismissed the CURRENTLY-STANDING relay
|
|
1790
1969
|
* notice (ggui#442).
|
|
@@ -2066,7 +2245,20 @@ function showPostDismissalCue(intent) {
|
|
|
2066
2245
|
export function dispatchSubmitAction(args) {
|
|
2067
2246
|
if (typeof window === 'undefined')
|
|
2068
2247
|
return;
|
|
2248
|
+
// Freeze latch (#483): a superseded (history) mount does not drive
|
|
2249
|
+
// the live session — drop the gesture rather than submit a stale
|
|
2250
|
+
// view's action against the current head.
|
|
2251
|
+
if (mountSuperseded) {
|
|
2252
|
+
currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2069
2255
|
const { toolName, intent, data, sessionId, appId } = args;
|
|
2256
|
+
// Gesture-path telemetry — the click's own autopsy trail. #471
|
|
2257
|
+
// round 12 hit a frame whose channels + beacons were fully healthy
|
|
2258
|
+
// while clicks produced NOTHING observable; without a record at the
|
|
2259
|
+
// dispatch entry there is no way to tell "handler never fired" from
|
|
2260
|
+
// "dispatch swallowed it" on a console-less host.
|
|
2261
|
+
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
|
|
2070
2262
|
const firedAt = new Date().toISOString();
|
|
2071
2263
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
2072
2264
|
const inlineData = formatSubmitActionDataInline(data);
|
|
@@ -2151,6 +2343,15 @@ export function dispatchSubmitAction(args) {
|
|
|
2151
2343
|
showActionToast(`⚠ ${intent} — transport error`, 'error');
|
|
2152
2344
|
resp = null;
|
|
2153
2345
|
}
|
|
2346
|
+
// Final hop of the gesture autopsy trail — what the relay
|
|
2347
|
+
// answered (or that it didn't).
|
|
2348
|
+
currentTelemetrySink?.record('gesture.result', JSON.stringify({
|
|
2349
|
+
intent,
|
|
2350
|
+
ok: resp !== null && resp.error === undefined,
|
|
2351
|
+
...(resp?.error?.message !== undefined
|
|
2352
|
+
? { error: resp.error.message.slice(0, 120) }
|
|
2353
|
+
: {}),
|
|
2354
|
+
}));
|
|
2154
2355
|
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
2155
2356
|
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
2156
2357
|
// (an otherwise-healthy relay, expired pipe) proves it exactly as
|
|
@@ -2192,16 +2393,25 @@ export function dispatchSubmitAction(args) {
|
|
|
2192
2393
|
}
|
|
2193
2394
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
2194
2395
|
const consumerPresent = extractConsumerPresent(resp);
|
|
2195
|
-
if (consumerPresent
|
|
2196
|
-
// No `ggui_consume` long-poll is
|
|
2197
|
-
// so the gesture needs a fresh
|
|
2198
|
-
// ONLY view→host method that
|
|
2199
|
-
//
|
|
2200
|
-
// to send a message
|
|
2396
|
+
if (consumerPresent !== true) {
|
|
2397
|
+
// No `ggui_consume` long-poll is POSITIVELY confirmed to be
|
|
2398
|
+
// draining this render's pipe, so the gesture needs a fresh
|
|
2399
|
+
// agent turn. `ui/message` is the ONLY view→host method that
|
|
2400
|
+
// can start one — a host that does not accept it cannot be
|
|
2401
|
+
// woken by us at all, and the user has to send a message
|
|
2402
|
+
// themselves (ggui#440).
|
|
2201
2403
|
//
|
|
2202
|
-
//
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
2404
|
+
// `!== true` (not `=== false`) is load-bearing: a relay host
|
|
2405
|
+
// that normalizes the tool result and strips the
|
|
2406
|
+
// `consumerPresent` field (claude.ai's live behavior — the
|
|
2407
|
+
// first #471 retest click died exactly here) must ring the
|
|
2408
|
+
// doorbell, not wait forever for a drain_ack that cannot
|
|
2409
|
+
// arrive without a live channel. The doorbell is pointer-only
|
|
2410
|
+
// and the pipe pop is exactly-once, so a redundant ring on a
|
|
2411
|
+
// field-stripping host with a live consumer costs one empty
|
|
2412
|
+
// `ggui_consume`; servers that CAN answer always send an
|
|
2413
|
+
// explicit `true` (the factory wires the registry
|
|
2414
|
+
// unconditionally), so confirmed-consumer hosts stay quiet.
|
|
2205
2415
|
showActionToast(hostCanReceiveMessages()
|
|
2206
2416
|
? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
|
|
2207
2417
|
: `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
|
|
@@ -2213,9 +2423,9 @@ export function dispatchSubmitAction(args) {
|
|
|
2213
2423
|
});
|
|
2214
2424
|
return;
|
|
2215
2425
|
}
|
|
2216
|
-
// consumerPresent
|
|
2217
|
-
// the
|
|
2218
|
-
// it when ggui_consume drains the event.
|
|
2426
|
+
// consumerPresent === true — a live long-poll is confirmed
|
|
2427
|
+
// draining the pipe. Toast stays `pending`; the drain_ack
|
|
2428
|
+
// listener dismisses it when ggui_consume drains the event.
|
|
2219
2429
|
return;
|
|
2220
2430
|
}
|
|
2221
2431
|
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
@@ -2982,6 +3192,28 @@ async function bootProduction(opts) {
|
|
|
2982
3192
|
// these lived only in the retired self-contained boot path; the
|
|
2983
3193
|
// WS-driven path installed neither, so anchor clicks + fullscreen
|
|
2984
3194
|
// requests in a live-rendered component silently no-op'd.
|
|
3195
|
+
// Root of the gesture autopsy trail: a capture-phase listener
|
|
3196
|
+
// proving pointer events reach this document AT ALL. #471
|
|
3197
|
+
// round 12: a healthy frame (beacons + pulls flowing) whose
|
|
3198
|
+
// clicks produced nothing observable — without this record,
|
|
3199
|
+
// "host swallows pointer events" and "component handler never
|
|
3200
|
+
// attached" are indistinguishable from outside a console-less
|
|
3201
|
+
// host. Bounded: only the first few clicks record (the sink
|
|
3202
|
+
// throttles + caps flushes regardless).
|
|
3203
|
+
if (typeof document !== 'undefined') {
|
|
3204
|
+
let recordedClicks = 0;
|
|
3205
|
+
document.addEventListener('click', (ev) => {
|
|
3206
|
+
if (recordedClicks >= 5)
|
|
3207
|
+
return;
|
|
3208
|
+
recordedClicks += 1;
|
|
3209
|
+
const target = ev.target instanceof Element ? ev.target : null;
|
|
3210
|
+
currentTelemetrySink?.record('gesture.dom_click', JSON.stringify({
|
|
3211
|
+
tag: target?.tagName ?? 'unknown',
|
|
3212
|
+
role: target?.getAttribute('role') ?? undefined,
|
|
3213
|
+
trusted: ev.isTrusted,
|
|
3214
|
+
}));
|
|
3215
|
+
}, { capture: true });
|
|
3216
|
+
}
|
|
2985
3217
|
installAnchorClickInterceptor({
|
|
2986
3218
|
dispatchToolName,
|
|
2987
3219
|
sessionId: meta.sessionId,
|
|
@@ -2999,6 +3231,10 @@ async function bootProduction(opts) {
|
|
|
2999
3231
|
manager,
|
|
3000
3232
|
streamBus,
|
|
3001
3233
|
onDispatchEnvelope: (envelope) => {
|
|
3234
|
+
// First hop of the gesture autopsy trail (see
|
|
3235
|
+
// `dispatchSubmitAction`'s sibling record): proves the wire
|
|
3236
|
+
// layer received the component's action envelope at all.
|
|
3237
|
+
currentTelemetrySink?.record('gesture.envelope', JSON.stringify({ type: envelope.type }));
|
|
3002
3238
|
if (envelope.type !== 'data:submit')
|
|
3003
3239
|
return;
|
|
3004
3240
|
const payload = envelope.payload;
|
|
@@ -3185,9 +3421,28 @@ async function bootProduction(opts) {
|
|
|
3185
3421
|
streamBus,
|
|
3186
3422
|
validatorCtx,
|
|
3187
3423
|
}));
|
|
3424
|
+
// Freeze latch (#483). This mount's own history epoch, read once
|
|
3425
|
+
// from its boot meta: render → 0, an update-minted card → its N,
|
|
3426
|
+
// a pinned `#N` read → N. When a `props_update` frame carries a
|
|
3427
|
+
// higher epoch, a newer card superseded this one.
|
|
3428
|
+
const selfEpoch = typeof meta.epoch === 'number' ? meta.epoch : 0;
|
|
3429
|
+
let superseded = false;
|
|
3430
|
+
const supersede = () => {
|
|
3431
|
+
if (superseded)
|
|
3432
|
+
return;
|
|
3433
|
+
superseded = true;
|
|
3434
|
+
// Module-level dispatch guard: a history card's gestures no
|
|
3435
|
+
// longer target the live session.
|
|
3436
|
+
mountSuperseded = true;
|
|
3437
|
+
currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
|
|
3438
|
+
applyFreezeCue(renderInto);
|
|
3439
|
+
};
|
|
3188
3440
|
channelRegistry.register(createPropsUpdateHandler({
|
|
3189
3441
|
getCurrentGguiSession: () => currentRender,
|
|
3190
3442
|
applyRender,
|
|
3443
|
+
getSelfEpoch: () => selfEpoch,
|
|
3444
|
+
onSuperseded: supersede,
|
|
3445
|
+
isSuperseded: () => superseded,
|
|
3191
3446
|
}));
|
|
3192
3447
|
channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
3193
3448
|
channelRegistry.register(createChannelPayloadHandler({
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* ("did this generated UI work for you?").
|
|
4
4
|
*
|
|
5
5
|
* The in-iframe twin of the host-chrome `UiFeedback` component in
|
|
6
|
-
* `@ggui-ai/react`: same three verdicts — Love / Dislike / Other
|
|
6
|
+
* `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
|
|
7
7
|
* (free-text comment) — same acknowledgement after submit, same
|
|
8
8
|
* dismiss control, same payload semantics (trimmed comment only on
|
|
9
9
|
* `verdict: 'other'`, context stamps present exactly when known).
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
* ("did this generated UI work for you?").
|
|
5
5
|
*
|
|
6
6
|
* The in-iframe twin of the host-chrome `UiFeedback` component in
|
|
7
|
-
* `@ggui-ai/react`: same three verdicts — Love / Dislike / Other
|
|
7
|
+
* `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
|
|
8
8
|
* (free-text comment) — same acknowledgement after submit, same
|
|
9
9
|
* dismiss control, same payload semantics (trimmed comment only on
|
|
10
10
|
* `verdict: 'other'`, context stamps present exactly when known).
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Sink-reachability gate — never render a dead affordance:
|
|
15
15
|
*
|
|
16
|
-
* - `window.parent !== window` → a parent window exists
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* - `window.parent !== window` → a parent window exists → this
|
|
17
|
+
* module's own precondition holds. The CALLER (bootSequence)
|
|
18
|
+
* additionally gates on a live sink: an injected `onObserve`
|
|
19
|
+
* emitter, or a first-party embed host (the only parent that
|
|
20
|
+
* consumes `ggui:observe`). Third-party MCP-Apps hosts drop the
|
|
21
|
+
* envelope, so the affordance is NOT mounted there — a feedback
|
|
22
|
+
* control that can never deliver feedback is worse than none.
|
|
20
23
|
* - `window.parent === window` → top-level tab (the `/r/<shortCode>`
|
|
21
24
|
* share-link viewer) → NO mount. There is no parent to receive
|
|
22
25
|
* the envelope, so a rendered affordance would drop every click
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/iframe-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"qrcode-generator": "^2.0.4",
|
|
36
36
|
"react": "^19.0.0",
|
|
37
37
|
"react-dom": "^19.0.0",
|
|
38
|
-
"@ggui-ai/live-channel": "0.
|
|
39
|
-
"@ggui-ai/gadgets": "0.
|
|
40
|
-
"@ggui-ai/design": "0.
|
|
41
|
-
"@ggui-ai/
|
|
42
|
-
"@ggui-ai/
|
|
43
|
-
"@ggui-ai/wire": "0.
|
|
38
|
+
"@ggui-ai/live-channel": "0.9.0",
|
|
39
|
+
"@ggui-ai/gadgets": "0.9.0",
|
|
40
|
+
"@ggui-ai/design": "0.9.0",
|
|
41
|
+
"@ggui-ai/preview-a2ui": "0.9.0",
|
|
42
|
+
"@ggui-ai/protocol": "0.9.0",
|
|
43
|
+
"@ggui-ai/wire": "0.9.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.27.0"
|