@ggui-ai/iframe-runtime 0.7.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 +74 -62
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.d.ts +3 -3
- package/dist/meta-parse.js +26 -10
- 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 +110 -30
- 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.d.ts +45 -5
- package/dist/runtime.js +412 -93
- 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
|
|
@@ -461,16 +471,37 @@ export async function bootSequence(opts) {
|
|
|
461
471
|
onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
|
|
462
472
|
};
|
|
463
473
|
// Mode discriminators. The mount surface is DECOUPLED from the live
|
|
464
|
-
// channel: static content (codeUrl/kind) paints immediately
|
|
465
|
-
// the live trio (wsUrl+wsToken) is an OPTIONAL
|
|
466
|
-
// delivers props_update / data / re-render frames.
|
|
467
|
-
// NEITHER has nothing to show and nowhere to
|
|
468
|
-
|
|
469
|
-
|
|
474
|
+
// channel: static content (codeUrl/codeB64/kind) paints immediately
|
|
475
|
+
// with no WS; the live trio (wsUrl+wsToken) is an OPTIONAL
|
|
476
|
+
// enhancement that delivers props_update / data / re-render frames.
|
|
477
|
+
// A bootstrap with NEITHER has nothing to show and nowhere to
|
|
478
|
+
// subscribe.
|
|
479
|
+
const hasStaticContent = hasStaticContentMeta(meta);
|
|
470
480
|
const hasLiveTrio = typeof meta.wsUrl === 'string' &&
|
|
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
|
|
@@ -801,16 +911,22 @@ function parseSeedProps(propsJson) {
|
|
|
801
911
|
* no WS at all for spec-compliant MCP-Apps hosts that expose no ggui
|
|
802
912
|
* live channel (claude.ai / ChatGPT / Claude Desktop).
|
|
803
913
|
*
|
|
804
|
-
*
|
|
914
|
+
* Three static-content shapes (the autostart discriminator), in
|
|
915
|
+
* priority order:
|
|
805
916
|
* - `kind` → a system-card seed (`type:'system'`); no fetch.
|
|
917
|
+
* - `codeB64` → a compiled-component seed carried inline; DECODED,
|
|
918
|
+
* never fetched. Preferred over `codeUrl` when both are present —
|
|
919
|
+
* the inline bytes exist precisely for hosts whose iframe CSP
|
|
920
|
+
* blocks the fetch.
|
|
806
921
|
* - `codeUrl` → a compiled-component seed; fetches the
|
|
807
922
|
* content-addressable component bytes.
|
|
808
923
|
*
|
|
809
|
-
* Returns `null` when the meta carries
|
|
924
|
+
* Returns `null` when the meta carries NONE (a live-only meta has
|
|
810
925
|
* nothing to seed — the first WS ack mounts it). Throws when a `codeUrl`
|
|
811
|
-
* fetch fails
|
|
812
|
-
*
|
|
813
|
-
* ack reconciles the seed to a
|
|
926
|
+
* fetch fails or `codeB64` is not valid base64 so the caller can
|
|
927
|
+
* surface a typed boot failure. The four server-assigned ledger fields
|
|
928
|
+
* are intentionally absent — the first ack reconciles the seed to a
|
|
929
|
+
* full `GguiSession` (no fabrication).
|
|
814
930
|
*/
|
|
815
931
|
export async function buildGguiSessionSeedInput(meta) {
|
|
816
932
|
const props = parseSeedProps(meta.propsJson);
|
|
@@ -824,6 +940,15 @@ export async function buildGguiSessionSeedInput(meta) {
|
|
|
824
940
|
...(props !== undefined ? { props } : {}),
|
|
825
941
|
};
|
|
826
942
|
}
|
|
943
|
+
// Inline compiled-component mode — decode, no network.
|
|
944
|
+
if (meta.codeB64 !== undefined) {
|
|
945
|
+
return {
|
|
946
|
+
id: meta.sessionId,
|
|
947
|
+
appId: meta.appId,
|
|
948
|
+
componentCode: decodeCodeB64(meta.codeB64),
|
|
949
|
+
...(props !== undefined ? { props } : {}),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
827
952
|
// Compiled-component mode — fetch the content-addressable bytes.
|
|
828
953
|
if (meta.codeUrl === undefined)
|
|
829
954
|
return null;
|
|
@@ -839,6 +964,45 @@ export async function buildGguiSessionSeedInput(meta) {
|
|
|
839
964
|
...(props !== undefined ? { props } : {}),
|
|
840
965
|
};
|
|
841
966
|
}
|
|
967
|
+
/**
|
|
968
|
+
* Does this slice carry STATIC content the mount surface can paint
|
|
969
|
+
* without any network round-trip authorization — `codeUrl` (fetched),
|
|
970
|
+
* `codeB64` (decoded inline), or `kind` (built-in registry)?
|
|
971
|
+
*
|
|
972
|
+
* The ONE discriminator `bootSequence` consults when deciding whether
|
|
973
|
+
* the static seed-mount path runs. `codeB64` counting here is
|
|
974
|
+
* load-bearing for fetch-blocked hosts: their meta carries codeB64
|
|
975
|
+
* PLUS a live trio the iframe can never connect, and missing the
|
|
976
|
+
* inline arm would skip the only paint path and die on the dead WS.
|
|
977
|
+
* Mirrors `validateMeta`'s static-content arm (meta-parse.ts) and the
|
|
978
|
+
* protocol host-helper's `hasMountModeDiscriminator` — drift among
|
|
979
|
+
* the three IS the historical bug class, hence the named export.
|
|
980
|
+
*/
|
|
981
|
+
export function hasStaticContentMeta(meta) {
|
|
982
|
+
return ((typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
|
|
983
|
+
(typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
|
|
984
|
+
(typeof meta.kind === 'string' && meta.kind.length > 0));
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Decode a `codeB64` slice field to UTF-8 component source. `atob`
|
|
988
|
+
* yields a byte string; routing through `TextDecoder` restores
|
|
989
|
+
* multi-byte characters (component source is UTF-8 — string literals
|
|
990
|
+
* carry emoji, quotes, non-Latin text). Throws a labeled error on
|
|
991
|
+
* malformed base64 so the boot-failure surface names the field.
|
|
992
|
+
*/
|
|
993
|
+
function decodeCodeB64(codeB64) {
|
|
994
|
+
try {
|
|
995
|
+
const byteString = atob(codeB64);
|
|
996
|
+
const bytes = new Uint8Array(byteString.length);
|
|
997
|
+
for (let i = 0; i < byteString.length; i++) {
|
|
998
|
+
bytes[i] = byteString.charCodeAt(i);
|
|
999
|
+
}
|
|
1000
|
+
return new TextDecoder().decode(bytes);
|
|
1001
|
+
}
|
|
1002
|
+
catch (cause) {
|
|
1003
|
+
throw new Error('buildGguiSessionSeedInput: codeB64 is not valid base64', { cause });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
842
1006
|
/**
|
|
843
1007
|
* Extract a {@link McpAppAiGguiRenderMeta} from a
|
|
844
1008
|
* `ui/notifications/tool-result` JSON-RPC params payload — the
|
|
@@ -860,23 +1024,29 @@ export function extractMetaFromToolResult(params) {
|
|
|
860
1024
|
/**
|
|
861
1025
|
* Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
|
|
862
1026
|
* minimal shell populates while messages arrive between
|
|
863
|
-
* shell-load and runtime-load. Returns the
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
|
|
872
|
-
|
|
1027
|
+
* shell-load and runtime-load. Returns the NEWEST valid slice meta
|
|
1028
|
+
* (each new tool-result supersedes the previous — every emission
|
|
1029
|
+
* carries complete state, so last-wins loses nothing). Newest-first
|
|
1030
|
+
* matters on the inline-runtime shell: its preflight completes the
|
|
1031
|
+
* handshake BEFORE the large bundle parses, so a render AND a
|
|
1032
|
+
* follow-up update can both land in the buffer during parse — booting
|
|
1033
|
+
* the oldest would paint stale props and drop the update forever
|
|
1034
|
+
* (buffered entries never reach the post-mount listener; it hears
|
|
1035
|
+
* only post-App arrivals).
|
|
1036
|
+
*
|
|
1037
|
+
* The shell-side buffer contract: an array whose ELEMENTS are the raw
|
|
1038
|
+
* JSON-RPC `params` values of `ui/notifications/tool-result`
|
|
1039
|
+
* notifications, in arrival order (shells cap it newest-biased). The
|
|
1040
|
+
* runtime reads it once at autostart.
|
|
1041
|
+
*/
|
|
1042
|
+
export function readPendingToolResults() {
|
|
873
1043
|
if (typeof window === 'undefined')
|
|
874
1044
|
return null;
|
|
875
1045
|
const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
|
|
876
1046
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
877
1047
|
return null;
|
|
878
|
-
for (
|
|
879
|
-
const meta = extractMetaFromToolResult(
|
|
1048
|
+
for (let i = raw.length - 1; i >= 0; i--) {
|
|
1049
|
+
const meta = extractMetaFromToolResult(raw[i]);
|
|
880
1050
|
if (meta !== null)
|
|
881
1051
|
return meta;
|
|
882
1052
|
}
|
|
@@ -1247,6 +1417,23 @@ export function emitAudit(args) {
|
|
|
1247
1417
|
* iframe-runtime then falls through to `ui/message` so the gesture
|
|
1248
1418
|
* still reaches the agent on its next turn.
|
|
1249
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
|
+
}
|
|
1250
1437
|
function classifySubmitActionResponse(resp) {
|
|
1251
1438
|
if (resp === null || typeof resp !== 'object')
|
|
1252
1439
|
return 'fallback';
|
|
@@ -1255,15 +1442,9 @@ function classifySubmitActionResponse(resp) {
|
|
|
1255
1442
|
resp.error !== null) {
|
|
1256
1443
|
return 'fallback';
|
|
1257
1444
|
}
|
|
1258
|
-
const
|
|
1259
|
-
if (
|
|
1445
|
+
const inner = submitActionPayload(resp);
|
|
1446
|
+
if (inner === null)
|
|
1260
1447
|
return 'fallback';
|
|
1261
|
-
const r = result;
|
|
1262
|
-
// Spec-canonical hosts wrap tool output under `structuredContent`;
|
|
1263
|
-
// looser hosts may surface fields directly on `result`.
|
|
1264
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1265
|
-
? r.structuredContent
|
|
1266
|
-
: r;
|
|
1267
1448
|
return inner.ok === true ? 'success' : 'fallback';
|
|
1268
1449
|
}
|
|
1269
1450
|
/**
|
|
@@ -1295,25 +1476,65 @@ function isRelayShapedFailure(resp) {
|
|
|
1295
1476
|
/**
|
|
1296
1477
|
* Extract `consumerPresent` from a successful submit_action response.
|
|
1297
1478
|
* Returns the boolean if present + well-typed; `undefined` otherwise
|
|
1298
|
-
* (
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
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.
|
|
1303
1484
|
*/
|
|
1304
1485
|
function extractConsumerPresent(resp) {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
const result = resp.result;
|
|
1308
|
-
if (result === null || typeof result !== 'object')
|
|
1486
|
+
const inner = submitActionPayload(resp);
|
|
1487
|
+
if (inner === null)
|
|
1309
1488
|
return undefined;
|
|
1310
|
-
const r = result;
|
|
1311
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1312
|
-
? r.structuredContent
|
|
1313
|
-
: r;
|
|
1314
1489
|
const flag = inner.consumerPresent;
|
|
1315
1490
|
return typeof flag === 'boolean' ? flag : undefined;
|
|
1316
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
|
+
}
|
|
1317
1538
|
/** Container id for the announcer holding both live regions. */
|
|
1318
1539
|
const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
|
|
1319
1540
|
/**
|
|
@@ -1650,7 +1871,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1650
1871
|
` <next_args>${nextArgs}</next_args>`,
|
|
1651
1872
|
`</ggui_directive>`,
|
|
1652
1873
|
'',
|
|
1653
|
-
`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).`,
|
|
1654
1875
|
].join('\n');
|
|
1655
1876
|
// Structured mirror of the directive for ggui-aware programmatic
|
|
1656
1877
|
// consumers. OPTIONAL — nothing in the loop depends on a server-side
|
|
@@ -1694,6 +1915,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1694
1915
|
// record — the proper extension point). Namespaced under
|
|
1695
1916
|
// `ai.ggui/userAction` to match our other protocol extensions
|
|
1696
1917
|
// (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
|
|
1918
|
+
currentTelemetrySink?.record('doorbell.ring', args.sessionId);
|
|
1697
1919
|
postToParent({
|
|
1698
1920
|
jsonrpc: '2.0',
|
|
1699
1921
|
id: Math.floor(Math.random() * 1e9),
|
|
@@ -1725,6 +1947,23 @@ function emitUserActionDoorbell(args) {
|
|
|
1725
1947
|
* nothing else about the latch is observable to the host.
|
|
1726
1948
|
*/
|
|
1727
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;
|
|
1728
1967
|
/**
|
|
1729
1968
|
* Whether the user has manually dismissed the CURRENTLY-STANDING relay
|
|
1730
1969
|
* notice (ggui#442).
|
|
@@ -2006,7 +2245,20 @@ function showPostDismissalCue(intent) {
|
|
|
2006
2245
|
export function dispatchSubmitAction(args) {
|
|
2007
2246
|
if (typeof window === 'undefined')
|
|
2008
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
|
+
}
|
|
2009
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 }));
|
|
2010
2262
|
const firedAt = new Date().toISOString();
|
|
2011
2263
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
2012
2264
|
const inlineData = formatSubmitActionDataInline(data);
|
|
@@ -2091,6 +2343,15 @@ export function dispatchSubmitAction(args) {
|
|
|
2091
2343
|
showActionToast(`⚠ ${intent} — transport error`, 'error');
|
|
2092
2344
|
resp = null;
|
|
2093
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
|
+
}));
|
|
2094
2355
|
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
2095
2356
|
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
2096
2357
|
// (an otherwise-healthy relay, expired pipe) proves it exactly as
|
|
@@ -2132,16 +2393,25 @@ export function dispatchSubmitAction(args) {
|
|
|
2132
2393
|
}
|
|
2133
2394
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
2134
2395
|
const consumerPresent = extractConsumerPresent(resp);
|
|
2135
|
-
if (consumerPresent
|
|
2136
|
-
// No `ggui_consume` long-poll is
|
|
2137
|
-
// so the gesture needs a fresh
|
|
2138
|
-
// ONLY view→host method that
|
|
2139
|
-
//
|
|
2140
|
-
// 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).
|
|
2141
2403
|
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
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.
|
|
2145
2415
|
showActionToast(hostCanReceiveMessages()
|
|
2146
2416
|
? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
|
|
2147
2417
|
: `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
|
|
@@ -2153,9 +2423,9 @@ export function dispatchSubmitAction(args) {
|
|
|
2153
2423
|
});
|
|
2154
2424
|
return;
|
|
2155
2425
|
}
|
|
2156
|
-
// consumerPresent
|
|
2157
|
-
// the
|
|
2158
|
-
// 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.
|
|
2159
2429
|
return;
|
|
2160
2430
|
}
|
|
2161
2431
|
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
@@ -2609,6 +2879,10 @@ function installPostMountListener() {
|
|
|
2609
2879
|
meta.sessionId,
|
|
2610
2880
|
meta.kind ?? '-',
|
|
2611
2881
|
meta.codeUrl ?? '-',
|
|
2882
|
+
// Inline code participates like codeUrl: an update that ships new
|
|
2883
|
+
// component bytes (re-generation) must re-mount even when
|
|
2884
|
+
// sessionId + props are unchanged.
|
|
2885
|
+
meta.codeB64 ?? '-',
|
|
2612
2886
|
meta.propsJson ?? '-',
|
|
2613
2887
|
liveTrio,
|
|
2614
2888
|
].join('|');
|
|
@@ -2918,6 +3192,28 @@ async function bootProduction(opts) {
|
|
|
2918
3192
|
// these lived only in the retired self-contained boot path; the
|
|
2919
3193
|
// WS-driven path installed neither, so anchor clicks + fullscreen
|
|
2920
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
|
+
}
|
|
2921
3217
|
installAnchorClickInterceptor({
|
|
2922
3218
|
dispatchToolName,
|
|
2923
3219
|
sessionId: meta.sessionId,
|
|
@@ -2935,6 +3231,10 @@ async function bootProduction(opts) {
|
|
|
2935
3231
|
manager,
|
|
2936
3232
|
streamBus,
|
|
2937
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 }));
|
|
2938
3238
|
if (envelope.type !== 'data:submit')
|
|
2939
3239
|
return;
|
|
2940
3240
|
const payload = envelope.payload;
|
|
@@ -3121,9 +3421,28 @@ async function bootProduction(opts) {
|
|
|
3121
3421
|
streamBus,
|
|
3122
3422
|
validatorCtx,
|
|
3123
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
|
+
};
|
|
3124
3440
|
channelRegistry.register(createPropsUpdateHandler({
|
|
3125
3441
|
getCurrentGguiSession: () => currentRender,
|
|
3126
3442
|
applyRender,
|
|
3443
|
+
getSelfEpoch: () => selfEpoch,
|
|
3444
|
+
onSuperseded: supersede,
|
|
3445
|
+
isSuperseded: () => superseded,
|
|
3127
3446
|
}));
|
|
3128
3447
|
channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
3129
3448
|
channelRegistry.register(createChannelPayloadHandler({
|