@ggui-ai/iframe-runtime 0.1.0-rc.1

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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. package/package.json +81 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Display-mode escalation policy.
3
+ *
4
+ * Pure functions that decide which `ui/request-display-mode` value the
5
+ * canvas should request based on:
6
+ * - the host's declared `availableDisplayModes` (from `McpUiHostContext`)
7
+ * - the canvas's content state (empty vs. has-content)
8
+ * - the host's current `displayMode`
9
+ *
10
+ * The rules implement this core matrix:
11
+ *
12
+ * ┌─────────────────────────────┬──────────────────────────┐
13
+ * │ canvas state │ requested target │
14
+ * ├─────────────────────────────┼──────────────────────────┤
15
+ * │ mounted, navStack empty │ pip (fallback inline) │
16
+ * │ navStack has ≥1 item │ fullscreen (then pip, │
17
+ * │ │ then inline) │
18
+ * └─────────────────────────────┴──────────────────────────┘
19
+ *
20
+ * - Never request `fullscreen` when the canvas is empty (taking over
21
+ * the whole screen to show a single pill is hostile UX).
22
+ * - For content state, prefer `fullscreen` > `pip` > `inline`.
23
+ * - `inline` is the silent fallback when neither `pip` nor
24
+ * `fullscreen` is available — the canvas renders inside whatever
25
+ * box the host gives it, no escalation request fires.
26
+ *
27
+ * Boundary discipline:
28
+ *
29
+ * - No DOM, no postMessage, no React. The reconciler returns a
30
+ * decision ('request X' or 'do nothing'); the caller fires the
31
+ * request via the iframe-runtime's existing
32
+ * `requestDisplayModeInParent()` seam.
33
+ * - No HTTP, no IO. Every input is a value; the function is
34
+ * referentially transparent so tests don't need mocks.
35
+ * - No subscriptions, no listeners. The reconciler is called by the
36
+ * canvas shell at navStack transitions + on
37
+ * `ui/notifications/host-context-changed` notifications.
38
+ *
39
+ * Why pure functions: the policy IS the contract between the host's
40
+ * capability surface and ggui's user-experience guarantees. Testing it
41
+ * exhaustively requires nothing but a value-in / value-out matrix.
42
+ */
43
+ import type { McpUiDisplayMode } from '@ggui-ai/protocol';
44
+ /**
45
+ * What the canvas should request when navStack is empty (just the
46
+ * animator pill visible). Never returns `'fullscreen'` — fullscreen
47
+ * with no content is hostile UX.
48
+ *
49
+ * - `['pip']` or supersets containing pip → `'pip'`
50
+ * - everything else → `'inline'`
51
+ *
52
+ * Treats absent / empty `available` as `['inline']`-only.
53
+ */
54
+ export declare function pickEmptyStateMode(available: readonly McpUiDisplayMode[] | undefined): McpUiDisplayMode;
55
+ /**
56
+ * What the canvas should request when navStack has ≥1 item (content
57
+ * is visible; pill compressed into navbar). Prefers `fullscreen` for
58
+ * the most prominent presentation; falls back to `pip` then `inline`.
59
+ *
60
+ * - `'fullscreen'` if available
61
+ * - else `'pip'` if available
62
+ * - else `'inline'`
63
+ *
64
+ * Treats absent / empty `available` as `['inline']`-only.
65
+ */
66
+ export declare function pickContentStateMode(available: readonly McpUiDisplayMode[] | undefined): McpUiDisplayMode;
67
+ /**
68
+ * Decision for the reconciler. The canvas calls
69
+ * {@link reconcileDisplayMode} on every state change; the result tells
70
+ * it whether to fire a `ui/request-display-mode` postMessage.
71
+ *
72
+ * - `{ kind: 'noop' }` — already at target; nothing to do. Skips
73
+ * postMessage + audit envelope traffic.
74
+ * - `{ kind: 'request', mode }` — fire the request.
75
+ */
76
+ export type DisplayModeDecision = {
77
+ readonly kind: 'noop';
78
+ } | {
79
+ readonly kind: 'request';
80
+ readonly mode: McpUiDisplayMode;
81
+ };
82
+ /**
83
+ * State the reconciler considers.
84
+ *
85
+ * - `available` — host's declared `availableDisplayModes`.
86
+ * - `current` — host's current `displayMode`.
87
+ * - `contentState` — whether the canvas's navStack has content.
88
+ */
89
+ export interface DisplayModePolicyInput {
90
+ readonly available: readonly McpUiDisplayMode[] | undefined;
91
+ readonly current: McpUiDisplayMode | undefined;
92
+ readonly contentState: 'empty' | 'has-content';
93
+ }
94
+ /**
95
+ * Compute whether a display-mode change should be requested. Returns
96
+ * `noop` when current already matches the policy target (saves
97
+ * postMessage round-trips + audit-envelope noise on no-ops).
98
+ *
99
+ * When `current` is undefined (host hasn't reported a mode yet), the
100
+ * reconciler treats it as `'inline'` and fires a request to the
101
+ * policy target — matches the worst-case assumption that the host
102
+ * started in inline.
103
+ */
104
+ export declare function reconcileDisplayMode(input: DisplayModePolicyInput): DisplayModeDecision;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Display-mode escalation policy.
3
+ *
4
+ * Pure functions that decide which `ui/request-display-mode` value the
5
+ * canvas should request based on:
6
+ * - the host's declared `availableDisplayModes` (from `McpUiHostContext`)
7
+ * - the canvas's content state (empty vs. has-content)
8
+ * - the host's current `displayMode`
9
+ *
10
+ * The rules implement this core matrix:
11
+ *
12
+ * ┌─────────────────────────────┬──────────────────────────┐
13
+ * │ canvas state │ requested target │
14
+ * ├─────────────────────────────┼──────────────────────────┤
15
+ * │ mounted, navStack empty │ pip (fallback inline) │
16
+ * │ navStack has ≥1 item │ fullscreen (then pip, │
17
+ * │ │ then inline) │
18
+ * └─────────────────────────────┴──────────────────────────┘
19
+ *
20
+ * - Never request `fullscreen` when the canvas is empty (taking over
21
+ * the whole screen to show a single pill is hostile UX).
22
+ * - For content state, prefer `fullscreen` > `pip` > `inline`.
23
+ * - `inline` is the silent fallback when neither `pip` nor
24
+ * `fullscreen` is available — the canvas renders inside whatever
25
+ * box the host gives it, no escalation request fires.
26
+ *
27
+ * Boundary discipline:
28
+ *
29
+ * - No DOM, no postMessage, no React. The reconciler returns a
30
+ * decision ('request X' or 'do nothing'); the caller fires the
31
+ * request via the iframe-runtime's existing
32
+ * `requestDisplayModeInParent()` seam.
33
+ * - No HTTP, no IO. Every input is a value; the function is
34
+ * referentially transparent so tests don't need mocks.
35
+ * - No subscriptions, no listeners. The reconciler is called by the
36
+ * canvas shell at navStack transitions + on
37
+ * `ui/notifications/host-context-changed` notifications.
38
+ *
39
+ * Why pure functions: the policy IS the contract between the host's
40
+ * capability surface and ggui's user-experience guarantees. Testing it
41
+ * exhaustively requires nothing but a value-in / value-out matrix.
42
+ */
43
+ /**
44
+ * What the canvas should request when navStack is empty (just the
45
+ * animator pill visible). Never returns `'fullscreen'` — fullscreen
46
+ * with no content is hostile UX.
47
+ *
48
+ * - `['pip']` or supersets containing pip → `'pip'`
49
+ * - everything else → `'inline'`
50
+ *
51
+ * Treats absent / empty `available` as `['inline']`-only.
52
+ */
53
+ export function pickEmptyStateMode(available) {
54
+ if (!available || available.length === 0)
55
+ return 'inline';
56
+ if (available.includes('pip'))
57
+ return 'pip';
58
+ return 'inline';
59
+ }
60
+ /**
61
+ * What the canvas should request when navStack has ≥1 item (content
62
+ * is visible; pill compressed into navbar). Prefers `fullscreen` for
63
+ * the most prominent presentation; falls back to `pip` then `inline`.
64
+ *
65
+ * - `'fullscreen'` if available
66
+ * - else `'pip'` if available
67
+ * - else `'inline'`
68
+ *
69
+ * Treats absent / empty `available` as `['inline']`-only.
70
+ */
71
+ export function pickContentStateMode(available) {
72
+ if (!available || available.length === 0)
73
+ return 'inline';
74
+ if (available.includes('fullscreen'))
75
+ return 'fullscreen';
76
+ if (available.includes('pip'))
77
+ return 'pip';
78
+ return 'inline';
79
+ }
80
+ /**
81
+ * Compute whether a display-mode change should be requested. Returns
82
+ * `noop` when current already matches the policy target (saves
83
+ * postMessage round-trips + audit-envelope noise on no-ops).
84
+ *
85
+ * When `current` is undefined (host hasn't reported a mode yet), the
86
+ * reconciler treats it as `'inline'` and fires a request to the
87
+ * policy target — matches the worst-case assumption that the host
88
+ * started in inline.
89
+ */
90
+ export function reconcileDisplayMode(input) {
91
+ const target = input.contentState === 'empty'
92
+ ? pickEmptyStateMode(input.available)
93
+ : pickContentStateMode(input.available);
94
+ const current = input.current ?? 'inline';
95
+ if (current === target)
96
+ return { kind: 'noop' };
97
+ return { kind: 'request', mode: target };
98
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Canvas mount — wires the React-tree CanvasShell into bootProduction.
3
+ *
4
+ * The canvas-mode iframe is session-scoped — a single React root lives
5
+ * for the lifetime of the session, with the active stack item rendered
6
+ * inside its content slot. This module bridges the React/DOM boundary
7
+ * for that mount:
8
+ *
9
+ * - Renders `<CanvasShell>` into a host-supplied DOM element.
10
+ * - Exposes a stable `contentEl` div via `renderActiveItem` that the
11
+ * existing stack renderer can write into (one slot, swapped per
12
+ * navStack-top transition).
13
+ * - Returns a `NavStackModel` so the caller (bootSequence) can keep
14
+ * it synced with the server's stack snapshots on every ack.
15
+ * - Returns a dispose handle that unmounts the React tree on
16
+ * teardown.
17
+ *
18
+ * Why this lives next to `canvas-shell.tsx` instead of `runtime.ts`:
19
+ * keeps the DOM/React seam co-located with the shell so anyone adding
20
+ * a new piece of canvas chrome sees the wiring point. `runtime.ts`
21
+ * already pulls React + design under a dynamic import — this file
22
+ * piggybacks on those modules being available at call time, taking
23
+ * them as constructor args rather than top-level imports.
24
+ */
25
+ import type { CanvasLifecyclePayload, McpUiDisplayMode, SessionStackEntry } from '@ggui-ai/protocol';
26
+ import type { AnimatorEventStream } from './animator/animator-host.js';
27
+ import type { AnimatorEvent } from './animator/state-machine.js';
28
+ import { NavStackModel } from './nav-stack.js';
29
+ /**
30
+ * Multi-subscriber AnimatorEventStream backed by a Set of listeners.
31
+ * Boot publishes via `publish(event)`; the shell's AnimatorHost
32
+ * subscribes through the {@link AnimatorEventStream} interface.
33
+ *
34
+ * Kept small and inline because the only call site is `mountCanvas`
35
+ * — extracting it to its own file would be ceremony.
36
+ */
37
+ export declare class AnimatorEventBus implements AnimatorEventStream {
38
+ private readonly listeners;
39
+ subscribe: (listener: (event: AnimatorEvent) => void) => (() => void);
40
+ publish(event: AnimatorEvent): void;
41
+ /**
42
+ * Translate a wire-shape {@link CanvasLifecyclePayload} from the
43
+ * `_ggui:lifecycle` channel into the matching {@link AnimatorEvent}
44
+ * variant and publish. The wire union is closed, so the switch is
45
+ * exhaustive.
46
+ */
47
+ publishLifecycle(payload: CanvasLifecyclePayload): void;
48
+ }
49
+ /**
50
+ * Minimal React/ReactDOM surface the mount needs. Constructor accepts
51
+ * pre-imported modules so it doesn't duplicate the dynamic-import
52
+ * graph runtime.ts already pays for. Type loose-typed because tests
53
+ * may pass stubs; production wires the real `react` + `react-dom/client`.
54
+ */
55
+ export interface CanvasMountReactDeps {
56
+ readonly react: typeof import('react');
57
+ readonly reactDomClient: typeof import('react-dom/client');
58
+ }
59
+ /**
60
+ * Side-effecting callbacks the canvas shell threads up to the boot
61
+ * layer. The boot layer owns the live-channel transport so it knows how
62
+ * to emit envelopes; this module knows nothing about WS.
63
+ */
64
+ export interface CanvasMountCallbacks {
65
+ /**
66
+ * Fire a `ui/request-display-mode` postMessage to the host. The
67
+ * shell calls this when the display-mode policy says to escalate
68
+ * (e.g. content state ⇒ fullscreen). Boot wires it to the existing
69
+ * `requestDisplayModeInParent` helper.
70
+ */
71
+ readonly onRequestDisplayMode?: (mode: McpUiDisplayMode) => void;
72
+ /**
73
+ * Fire when the user back-navigates. Boot wires this to the
74
+ * live-channel `canvas_navigated` envelope so the server can update
75
+ * its `activeStackItemId` + abort in-flight cold-gen for the popped
76
+ * item.
77
+ */
78
+ readonly onBackGesture?: (args: {
79
+ readonly previousActiveItemId: string;
80
+ readonly activeItemId: string | null;
81
+ }) => void;
82
+ }
83
+ export interface CanvasMountHandle {
84
+ /**
85
+ * The NavStackModel the shell renders from. Boot syncs this with
86
+ * the server's stack snapshot on every ack via `setStack()`.
87
+ */
88
+ readonly navStack: NavStackModel;
89
+ /**
90
+ * Bus for live-channel lifecycle envelopes. Boot calls
91
+ * `events.publish(event)` when an `_ggui:lifecycle` envelope arrives
92
+ * on the WS; AnimatorHost subscribes through the read-side
93
+ * {@link AnimatorEventStream} interface.
94
+ */
95
+ readonly events: AnimatorEventBus;
96
+ /**
97
+ * Stable DOM element the stack renderer writes into. Mirrors the
98
+ * shape of the legacy `refs.stack` element — bootSequence hands
99
+ * this to `triadWiring.setup({ renderInto })` instead of the raw
100
+ * `refs.stack`. The shell guarantees only the active stack item's
101
+ * subtree lives here at any moment.
102
+ */
103
+ readonly contentEl: HTMLElement;
104
+ /**
105
+ * Replace the entire nav stack. Used on the first ack (seed from
106
+ * snapshot) AND on every subsequent ack that delivers a fresh
107
+ * stack array.
108
+ */
109
+ setStack(stack: readonly SessionStackEntry[]): void;
110
+ /**
111
+ * Push a single item — used by the live-channel `push` envelope path
112
+ * where the server delivers one item at a time after the canvas is
113
+ * up. Idempotent in-place replace on duplicate id (matches server
114
+ * upsert semantics).
115
+ */
116
+ pushItem(item: SessionStackEntry): void;
117
+ /**
118
+ * Update the host's currently-reported display mode. Boot calls
119
+ * this when the host's `host-context-changed` notification arrives,
120
+ * so the policy reconciles against the latest mode.
121
+ */
122
+ setDisplayMode(mode: McpUiDisplayMode | undefined): void;
123
+ /**
124
+ * Update the host's declared `availableDisplayModes`. Same
125
+ * provenance as `setDisplayMode` — comes from
126
+ * `host-context-changed` payloads.
127
+ */
128
+ setAvailableDisplayModes(available: readonly McpUiDisplayMode[] | undefined): void;
129
+ /**
130
+ * Register a cleanup callback that fires when {@link dispose} runs.
131
+ * Used by boot to attach external teardown (e.g., the
132
+ * host-context-emitter local subscription's unsubscribe fn) to the
133
+ * canvas lifecycle so a re-mount doesn't leak the prior callback.
134
+ * Idempotent re-registration is fine; each fires exactly once on
135
+ * the first dispose.
136
+ */
137
+ registerCleanup(fn: () => void): void;
138
+ /**
139
+ * React unmount + cleanup. Called by `triadWiring.teardown` on
140
+ * boot-failure / explicit dispose paths. Also fires every
141
+ * registered cleanup callback in registration order.
142
+ */
143
+ dispose(): void;
144
+ }
145
+ /**
146
+ * Mount the canvas shell into `mountTarget`. The shell takes over
147
+ * `mountTarget`'s child layout — boot SHOULD pass an empty container
148
+ * (`refs.stack`), not the document root.
149
+ */
150
+ export declare function mountCanvas(mountTarget: HTMLElement, deps: CanvasMountReactDeps, callbacks?: CanvasMountCallbacks): CanvasMountHandle;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Canvas mount — wires the React-tree CanvasShell into bootProduction.
3
+ *
4
+ * The canvas-mode iframe is session-scoped — a single React root lives
5
+ * for the lifetime of the session, with the active stack item rendered
6
+ * inside its content slot. This module bridges the React/DOM boundary
7
+ * for that mount:
8
+ *
9
+ * - Renders `<CanvasShell>` into a host-supplied DOM element.
10
+ * - Exposes a stable `contentEl` div via `renderActiveItem` that the
11
+ * existing stack renderer can write into (one slot, swapped per
12
+ * navStack-top transition).
13
+ * - Returns a `NavStackModel` so the caller (bootSequence) can keep
14
+ * it synced with the server's stack snapshots on every ack.
15
+ * - Returns a dispose handle that unmounts the React tree on
16
+ * teardown.
17
+ *
18
+ * Why this lives next to `canvas-shell.tsx` instead of `runtime.ts`:
19
+ * keeps the DOM/React seam co-located with the shell so anyone adding
20
+ * a new piece of canvas chrome sees the wiring point. `runtime.ts`
21
+ * already pulls React + design under a dynamic import — this file
22
+ * piggybacks on those modules being available at call time, taking
23
+ * them as constructor args rather than top-level imports.
24
+ */
25
+ import { CanvasShell } from './canvas-shell.js';
26
+ import { NavStackModel } from './nav-stack.js';
27
+ /**
28
+ * Multi-subscriber AnimatorEventStream backed by a Set of listeners.
29
+ * Boot publishes via `publish(event)`; the shell's AnimatorHost
30
+ * subscribes through the {@link AnimatorEventStream} interface.
31
+ *
32
+ * Kept small and inline because the only call site is `mountCanvas`
33
+ * — extracting it to its own file would be ceremony.
34
+ */
35
+ export class AnimatorEventBus {
36
+ listeners = new Set();
37
+ subscribe = (listener) => {
38
+ this.listeners.add(listener);
39
+ return () => {
40
+ this.listeners.delete(listener);
41
+ };
42
+ };
43
+ publish(event) {
44
+ for (const fn of this.listeners)
45
+ fn(event);
46
+ }
47
+ /**
48
+ * Translate a wire-shape {@link CanvasLifecyclePayload} from the
49
+ * `_ggui:lifecycle` channel into the matching {@link AnimatorEvent}
50
+ * variant and publish. The wire union is closed, so the switch is
51
+ * exhaustive.
52
+ */
53
+ publishLifecycle(payload) {
54
+ switch (payload.kind) {
55
+ case 'handshake_started':
56
+ this.publish({ kind: 'handshake_started', payload });
57
+ return;
58
+ case 'handshake_completed':
59
+ this.publish({ kind: 'handshake_completed', payload });
60
+ return;
61
+ case 'push_started':
62
+ this.publish({ kind: 'push_started', payload });
63
+ return;
64
+ case 'consume_polling':
65
+ this.publish({ kind: 'consume_polling', payload });
66
+ return;
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Mount the canvas shell into `mountTarget`. The shell takes over
72
+ * `mountTarget`'s child layout — boot SHOULD pass an empty container
73
+ * (`refs.stack`), not the document root.
74
+ */
75
+ export function mountCanvas(mountTarget, deps, callbacks = {}) {
76
+ const navStack = new NavStackModel();
77
+ const events = new AnimatorEventBus();
78
+ const cleanups = [];
79
+ // The content element is stable across re-renders — we keep a
80
+ // single div and let the existing stack renderer mutate it. The
81
+ // shell receives `() => contentSlot` as `renderActiveItem`.
82
+ const contentEl = mountTarget.ownerDocument.createElement('div');
83
+ contentEl.setAttribute('data-ggui-canvas-content-slot', 'true');
84
+ contentEl.style.width = '100%';
85
+ contentEl.style.height = '100%';
86
+ // Mutable display-mode state lives in closure refs. The Wrapper
87
+ // captures a useState setter on first render; external setters
88
+ // route through it to push updates into React. Single consumer so
89
+ // a Set+broadcast pattern would be over-built — one nullable ref
90
+ // matches the architecture exactly.
91
+ let availableDisplayModes;
92
+ let currentDisplayMode;
93
+ let setWrapperState = null;
94
+ const bumpState = () => {
95
+ setWrapperState?.({
96
+ available: availableDisplayModes,
97
+ current: currentDisplayMode,
98
+ });
99
+ };
100
+ const { react, reactDomClient } = deps;
101
+ const root = reactDomClient.createRoot(mountTarget);
102
+ function Wrapper() {
103
+ const [state, setState] = react.useState({
104
+ available: availableDisplayModes,
105
+ current: currentDisplayMode,
106
+ });
107
+ react.useEffect(() => {
108
+ setWrapperState = setState;
109
+ return () => {
110
+ if (setWrapperState === setState)
111
+ setWrapperState = null;
112
+ };
113
+ }, []);
114
+ const props = {
115
+ navStack,
116
+ events,
117
+ ...(state.available !== undefined
118
+ ? { availableDisplayModes: state.available }
119
+ : {}),
120
+ ...(state.current !== undefined
121
+ ? { currentDisplayMode: state.current }
122
+ : {}),
123
+ onRequestDisplayMode: (mode) => {
124
+ callbacks.onRequestDisplayMode?.(mode);
125
+ },
126
+ onBackGesture: (args) => {
127
+ callbacks.onBackGesture?.(args);
128
+ },
129
+ renderActiveItem: () => {
130
+ // Render the content slot DIV via a portal-ish ref pattern —
131
+ // we wrap it in a no-op React element that mounts the
132
+ // existing DOM node. Avoids React owning the slot's children
133
+ // (the stack renderer writes them imperatively).
134
+ return react.createElement(SlotMount, { node: contentEl });
135
+ },
136
+ };
137
+ return react.createElement(CanvasShell, props);
138
+ }
139
+ function SlotMount({ node }) {
140
+ const hostRef = react.useRef(null);
141
+ react.useEffect(() => {
142
+ const host = hostRef.current;
143
+ if (host === null)
144
+ return undefined;
145
+ host.appendChild(node);
146
+ return () => {
147
+ if (node.parentNode === host)
148
+ host.removeChild(node);
149
+ };
150
+ }, [node]);
151
+ return react.createElement('div', {
152
+ ref: hostRef,
153
+ style: { width: '100%', height: '100%' },
154
+ });
155
+ }
156
+ // React 18+ createRoot.render. The `unstable_strictMode` etc. are
157
+ // host-supplied; we don't opt into them here.
158
+ root.render(react.createElement(Wrapper, null));
159
+ return {
160
+ navStack,
161
+ events,
162
+ contentEl,
163
+ setStack(stack) {
164
+ navStack.reset(stack);
165
+ },
166
+ pushItem(item) {
167
+ navStack.push(item);
168
+ },
169
+ setDisplayMode(mode) {
170
+ currentDisplayMode = mode;
171
+ bumpState();
172
+ },
173
+ setAvailableDisplayModes(available) {
174
+ availableDisplayModes = available;
175
+ bumpState();
176
+ },
177
+ registerCleanup(fn) {
178
+ cleanups.push(fn);
179
+ },
180
+ dispose() {
181
+ // Run external cleanups first so subscribers that touch the
182
+ // navStack / events bus get a chance to detach before React
183
+ // tears down its tree underneath them.
184
+ while (cleanups.length > 0) {
185
+ const fn = cleanups.shift();
186
+ try {
187
+ fn?.();
188
+ }
189
+ catch {
190
+ // Cleanup threw — keep going so one bad callback doesn't
191
+ // strand the rest.
192
+ }
193
+ }
194
+ try {
195
+ root.unmount();
196
+ }
197
+ catch {
198
+ // React already unmounted (e.g. host removed the iframe).
199
+ }
200
+ },
201
+ };
202
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * NavStackModel.
3
+ *
4
+ * Canvas-local navigation stack. **Distinct from the server's
5
+ * `StackModel`**:
6
+ *
7
+ * - Server's `StackModel` (`@ggui-ai/iframe-runtime/stack.ts`)
8
+ * tracks every stack item the agent has pushed in this session.
9
+ * The durable shape; mutated only by server-side push/pop/replace
10
+ * deliveries.
11
+ * - NavStackModel tracks the USER'S NAVIGATION HISTORY through
12
+ * those items. When the user back-navigates, the popped item
13
+ * leaves navStack but stays in server's StackModel.
14
+ *
15
+ * These can diverge:
16
+ *
17
+ * ```
18
+ * server StackModel: [a, b, c] ← all items the agent has pushed
19
+ * navStack: [a, b] ← user has back-navigated away from c
20
+ * activeItem: b ← top of navStack; the mounted screen
21
+ * ```
22
+ *
23
+ * The server-side `activeStackItemId` field (on AckPayload +
24
+ * SessionRecord) follows navStack-top via `canvas_navigated` outbound
25
+ * envelopes, so `ggui_consume` returns actions for the active item
26
+ * regardless of what the agent has pushed since.
27
+ *
28
+ * Pure model — no DOM, no React. Methods return the new state (or
29
+ * mutate in-place; v1 mutates for simplicity since the canvas shell
30
+ * holds the only reference). The renderer consumes the model via a
31
+ * version counter (bumped on every mutation) so React picks up
32
+ * changes without deep equality.
33
+ *
34
+ * Two listener seams (distinct semantics):
35
+ *
36
+ * - `onMutation` fires on EVERY state mutation including in-place
37
+ * replace (ggui_update patch). The canvas shell uses this to
38
+ * re-read the version counter and re-render — without it, a
39
+ * props-only update on the active item bumps the version but
40
+ * React never schedules a render and the screen shows stale props.
41
+ * - `onNavChange` fires only when the active item actually changes
42
+ * (new push, pop, reset that lands on a different top). The
43
+ * live-channel `canvas_navigated` envelope is emitted from here, so
44
+ * an in-place replace doesn't spuriously tell the server the
45
+ * user back-navigated.
46
+ */
47
+ import type { SessionStackEntry } from '@ggui-ai/protocol';
48
+ /**
49
+ * Listener for nav-changing mutations (push of new id, pop, reset to
50
+ * a different top). The canvas shell threads this into the live-channel
51
+ * `canvas_navigated` envelope so the server can update
52
+ * `activeStackItemId` and abort in-flight cold-gen for items the user
53
+ * has navigated away from.
54
+ */
55
+ export type NavStackChangeListener = (event: {
56
+ readonly direction: 'forward' | 'back';
57
+ readonly activeItemId: string | null;
58
+ readonly previousActiveItemId: string | null;
59
+ }) => void;
60
+ /**
61
+ * Listener for any state mutation. Distinct from
62
+ * {@link NavStackChangeListener} so React consumers can bridge to a
63
+ * version counter — in-place replaces (ggui_update) mutate state
64
+ * without changing the active item, and React still needs to re-render
65
+ * to pick up the new props.
66
+ */
67
+ export type NavStackMutationListener = () => void;
68
+ export declare class NavStackModel {
69
+ private items;
70
+ private mutationListener;
71
+ private navChangeListener;
72
+ private versionCounter;
73
+ /**
74
+ * Replace the entire nav stack. Used on subscribe ack to seed
75
+ * navStack from the server's `activeStackItemId` + the snapshot's
76
+ * stack array.
77
+ */
78
+ reset(items: readonly SessionStackEntry[]): void;
79
+ /**
80
+ * Append an item to the top. Called when a `stack_item_appended`
81
+ * envelope arrives (server pushed a new item).
82
+ *
83
+ * If an item with the same id already exists, the existing entry is
84
+ * REPLACED IN PLACE (preserves nav position) — matches the server-
85
+ * side StackModel's upsert semantics so `ggui_update` patches don't
86
+ * shift the user's nav state. The mutation listener still fires (so
87
+ * React re-renders with the new props) but the nav-change listener
88
+ * is suppressed (active item hasn't changed).
89
+ */
90
+ push(item: SessionStackEntry): void;
91
+ /**
92
+ * Pop the top item. Returns the new active item (or null if the
93
+ * stack is now empty). No-op if already empty.
94
+ */
95
+ pop(): SessionStackEntry | null;
96
+ /** Top of the nav stack (the currently-mounted item), or null when empty. */
97
+ peek(): SessionStackEntry | null;
98
+ /** Read-only snapshot of the full nav stack in order (root → top). */
99
+ snapshot(): readonly SessionStackEntry[];
100
+ /** Item count. */
101
+ size(): number;
102
+ /**
103
+ * Monotonic mutation counter — bumped on every push/pop/reset.
104
+ * React consumers can use this as the `useSyncExternalStore`
105
+ * snapshot value: comparing version numbers is O(1).
106
+ */
107
+ version(): number;
108
+ /**
109
+ * Install a listener that fires on every state mutation (push, pop,
110
+ * reset, in-place replace). React consumers bridge this to a
111
+ * version-counter `useState` so the component re-renders for every
112
+ * change — including in-place replaces from `ggui_update` patches
113
+ * where the active item is unchanged but its props have moved.
114
+ *
115
+ * Single-subscriber model — the canvas shell is the only caller.
116
+ * Returns an unsubscribe function.
117
+ */
118
+ onMutation(listener: NavStackMutationListener): () => void;
119
+ /**
120
+ * Install a listener that fires only on nav-changing mutations
121
+ * (push of new id, pop, reset). Used by the canvas shell to emit
122
+ * the live-channel `canvas_navigated` envelope so the server can
123
+ * update `activeStackItemId` and abort in-flight cold-gen for the
124
+ * previous active item.
125
+ *
126
+ * In-place replaces from `ggui_update` patches do NOT fire this
127
+ * listener — the active item hasn't moved. Use {@link onMutation}
128
+ * for React re-render signaling.
129
+ *
130
+ * Single-subscriber model. Returns an unsubscribe function.
131
+ */
132
+ onNavChange(listener: NavStackChangeListener): () => void;
133
+ }