@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.
- package/LICENSE +201 -0
- package/README.md +33 -0
- package/dist/bootstrap.d.ts +72 -0
- package/dist/bootstrap.js +500 -0
- package/dist/canvas/animator/animator-host.d.ts +63 -0
- package/dist/canvas/animator/animator-host.js +105 -0
- package/dist/canvas/animator/animator-pill.d.ts +42 -0
- package/dist/canvas/animator/animator-pill.js +119 -0
- package/dist/canvas/animator/state-machine.d.ts +106 -0
- package/dist/canvas/animator/state-machine.js +150 -0
- package/dist/canvas/canvas-shell.d.ts +77 -0
- package/dist/canvas/canvas-shell.js +117 -0
- package/dist/canvas/display-mode-policy.d.ts +104 -0
- package/dist/canvas/display-mode-policy.js +98 -0
- package/dist/canvas/mount.d.ts +150 -0
- package/dist/canvas/mount.js +202 -0
- package/dist/canvas/nav-stack.d.ts +133 -0
- package/dist/canvas/nav-stack.js +172 -0
- package/dist/channel-transport.d.ts +182 -0
- package/dist/channel-transport.js +344 -0
- package/dist/channels/channel-payload.d.ts +20 -0
- package/dist/channels/channel-payload.js +34 -0
- package/dist/channels/data.d.ts +48 -0
- package/dist/channels/data.js +103 -0
- package/dist/channels/drain-ack.d.ts +13 -0
- package/dist/channels/drain-ack.js +15 -0
- package/dist/channels/feedback.d.ts +14 -0
- package/dist/channels/feedback.js +19 -0
- package/dist/channels/index.d.ts +7 -0
- package/dist/channels/index.js +7 -0
- package/dist/channels/props-update.d.ts +53 -0
- package/dist/channels/props-update.js +130 -0
- package/dist/channels/push.d.ts +47 -0
- package/dist/channels/push.js +54 -0
- package/dist/channels/system.d.ts +26 -0
- package/dist/channels/system.js +44 -0
- package/dist/compiled-validators.d.ts +40 -0
- package/dist/compiled-validators.js +74 -0
- package/dist/context-observer.d.ts +205 -0
- package/dist/context-observer.js +312 -0
- package/dist/gadget-loader.d.ts +118 -0
- package/dist/gadget-loader.js +136 -0
- package/dist/globals.d.ts +234 -0
- package/dist/globals.js +101 -0
- package/dist/host-context-emitter.d.ts +91 -0
- package/dist/host-context-emitter.js +213 -0
- package/dist/iframe-runtime.js +261 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +66 -0
- package/dist/lifecycle.js +49 -0
- package/dist/mcp-app-iframe-host.d.ts +69 -0
- package/dist/mcp-app-iframe-host.js +214 -0
- package/dist/observability.d.ts +225 -0
- package/dist/observability.js +28 -0
- package/dist/protocol-error.d.ts +199 -0
- package/dist/protocol-error.js +116 -0
- package/dist/provisional-renderer.d.ts +62 -0
- package/dist/provisional-renderer.js +323 -0
- package/dist/react-renderer.d.ts +95 -0
- package/dist/react-renderer.js +343 -0
- package/dist/registry-subscribe.d.ts +102 -0
- package/dist/registry-subscribe.js +319 -0
- package/dist/runtime.d.ts +531 -0
- package/dist/runtime.js +2623 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +72 -0
- package/dist/stack-item-renderer.d.ts +103 -0
- package/dist/stack-item-renderer.js +270 -0
- package/dist/stack.d.ts +90 -0
- package/dist/stack.js +118 -0
- package/dist/status-dom.d.ts +72 -0
- package/dist/status-dom.js +51 -0
- package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
- package/dist/system-cards/NoCredentialsCard.js +189 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
- package/dist/system-cards/ProtocolProbeCard.js +463 -0
- package/dist/system-cards/QrCode.d.ts +46 -0
- package/dist/system-cards/QrCode.js +55 -0
- package/dist/system-cards/host-detect.d.ts +31 -0
- package/dist/system-cards/host-detect.js +108 -0
- package/dist/system-cards/host-intents.d.ts +85 -0
- package/dist/system-cards/host-intents.js +170 -0
- package/dist/system-cards/index.d.ts +70 -0
- package/dist/system-cards/index.js +134 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +83 -0
- package/dist/validation.js +106 -0
- package/dist/wire-config.d.ts +214 -0
- package/dist/wire-config.js +290 -0
- 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
|
+
}
|