@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
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute filesystem path to the built iframe-runtime bundle
|
|
3
|
+
* (`dist/iframe-runtime.js`).
|
|
4
|
+
*
|
|
5
|
+
* - Contents: single self-contained ESM module — React + ReactDOM +
|
|
6
|
+
* `@ggui-ai/wire` + `@ggui-ai/design` + `@ggui-ai/preview-a2ui` +
|
|
7
|
+
* protocol zod transitives all bundled in.
|
|
8
|
+
* - Written by `esbuild.config.mjs`
|
|
9
|
+
* (`pnpm --filter @ggui-ai/iframe-runtime build`).
|
|
10
|
+
* - Served by `@ggui-ai/mcp-server` at `/_ggui/iframe-runtime.js`
|
|
11
|
+
* under the `runtime: true` config branch.
|
|
12
|
+
*
|
|
13
|
+
* If the file does not exist at runtime (operator forgot to run
|
|
14
|
+
* `pnpm build`), the mcp-server's static route should surface a
|
|
15
|
+
* clear "run `pnpm build`" 503 rather than silently 404ing — that
|
|
16
|
+
* decision lives in mcp-server, not here.
|
|
17
|
+
*/
|
|
18
|
+
export declare const RUNTIME_BUNDLE_FILE: string;
|
|
19
|
+
/**
|
|
20
|
+
* The same path exposed as a directory for
|
|
21
|
+
* `express.static(RUNTIME_DIST_DIR)` — when the mcp-server mount
|
|
22
|
+
* prefers the static-middleware shape over explicit `sendFile`.
|
|
23
|
+
* Either approach works; both point at the same bundle file.
|
|
24
|
+
*/
|
|
25
|
+
export declare const RUNTIME_DIST_DIR: string;
|
|
26
|
+
/**
|
|
27
|
+
* The default same-origin URL under which the mcp-server publishes
|
|
28
|
+
* the bundle. Canonical string so producers (server config) and
|
|
29
|
+
* consumers (shell HTML) agree on one spelling.
|
|
30
|
+
*
|
|
31
|
+
* Operators who serve the bundle from a different origin (CDN,
|
|
32
|
+
* proxy) override this by setting the MCP server's `runtime.path`
|
|
33
|
+
* config — the shell always reads the URL from
|
|
34
|
+
* `bootstrap.runtimeUrl`, never hardcodes it.
|
|
35
|
+
*/
|
|
36
|
+
export declare const RUNTIME_BUNDLE_URL_PATH: "/_ggui/iframe-runtime.js";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side export for `@ggui-ai/iframe-runtime`.
|
|
3
|
+
*
|
|
4
|
+
* This module is imported by `@ggui-ai/mcp-server` to locate the built
|
|
5
|
+
* iframe-runtime bundle on disk — exposed via an `express.static`-style
|
|
6
|
+
* file handler at `/_ggui/iframe-runtime.js`. It contains **no browser
|
|
7
|
+
* code** — never import React, DOM types, or anything from
|
|
8
|
+
* `src/runtime.ts` here.
|
|
9
|
+
*
|
|
10
|
+
* Rationale for the `./server` export split mirrors `@ggui-ai/console`:
|
|
11
|
+
*
|
|
12
|
+
* - The iframe runtime bundle lives in `dist/iframe-runtime.js`
|
|
13
|
+
* (esbuild output: single self-contained ESM module).
|
|
14
|
+
* - The server helper ships from `dist/server.js` (tsc output).
|
|
15
|
+
* - mcp-server only needs a path string
|
|
16
|
+
* ({@link RUNTIME_BUNDLE_FILE}) plus a type. Keeping those
|
|
17
|
+
* apart from the iframe runtime lets consumers import
|
|
18
|
+
* `@ggui-ai/iframe-runtime/server` without pulling React / ReactDOM /
|
|
19
|
+
* the full browser graph through their module graph.
|
|
20
|
+
*
|
|
21
|
+
* If you add more server-side helpers (e.g. CSP directives, SRI hash
|
|
22
|
+
* pre-computation), land them here — NOT in `src/runtime.ts` or
|
|
23
|
+
* `src/index.ts`.
|
|
24
|
+
*/
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the directory where this compiled module ends up at runtime.
|
|
29
|
+
* `dist/server.js` is the canonical location (tsc's `outDir`); the
|
|
30
|
+
* bundle (`dist/iframe-runtime.js`) is a sibling written by esbuild.
|
|
31
|
+
*
|
|
32
|
+
* Computed via `import.meta.url` so the resolver is independent of
|
|
33
|
+
* CWD — consumers mount this server inside CLIs, Lambdas, and tests
|
|
34
|
+
* with arbitrary working directories.
|
|
35
|
+
*/
|
|
36
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
/**
|
|
38
|
+
* Absolute filesystem path to the built iframe-runtime bundle
|
|
39
|
+
* (`dist/iframe-runtime.js`).
|
|
40
|
+
*
|
|
41
|
+
* - Contents: single self-contained ESM module — React + ReactDOM +
|
|
42
|
+
* `@ggui-ai/wire` + `@ggui-ai/design` + `@ggui-ai/preview-a2ui` +
|
|
43
|
+
* protocol zod transitives all bundled in.
|
|
44
|
+
* - Written by `esbuild.config.mjs`
|
|
45
|
+
* (`pnpm --filter @ggui-ai/iframe-runtime build`).
|
|
46
|
+
* - Served by `@ggui-ai/mcp-server` at `/_ggui/iframe-runtime.js`
|
|
47
|
+
* under the `runtime: true` config branch.
|
|
48
|
+
*
|
|
49
|
+
* If the file does not exist at runtime (operator forgot to run
|
|
50
|
+
* `pnpm build`), the mcp-server's static route should surface a
|
|
51
|
+
* clear "run `pnpm build`" 503 rather than silently 404ing — that
|
|
52
|
+
* decision lives in mcp-server, not here.
|
|
53
|
+
*/
|
|
54
|
+
export const RUNTIME_BUNDLE_FILE = path.resolve(here, 'iframe-runtime.js');
|
|
55
|
+
/**
|
|
56
|
+
* The same path exposed as a directory for
|
|
57
|
+
* `express.static(RUNTIME_DIST_DIR)` — when the mcp-server mount
|
|
58
|
+
* prefers the static-middleware shape over explicit `sendFile`.
|
|
59
|
+
* Either approach works; both point at the same bundle file.
|
|
60
|
+
*/
|
|
61
|
+
export const RUNTIME_DIST_DIR = path.dirname(RUNTIME_BUNDLE_FILE);
|
|
62
|
+
/**
|
|
63
|
+
* The default same-origin URL under which the mcp-server publishes
|
|
64
|
+
* the bundle. Canonical string so producers (server config) and
|
|
65
|
+
* consumers (shell HTML) agree on one spelling.
|
|
66
|
+
*
|
|
67
|
+
* Operators who serve the bundle from a different origin (CDN,
|
|
68
|
+
* proxy) override this by setting the MCP server's `runtime.path`
|
|
69
|
+
* config — the shell always reads the URL from
|
|
70
|
+
* `bootstrap.runtimeUrl`, never hardcodes it.
|
|
71
|
+
*/
|
|
72
|
+
export const RUNTIME_BUNDLE_URL_PATH = '/_ggui/iframe-runtime.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SessionStackEntry } from '@ggui-ai/protocol';
|
|
3
|
+
import { type WireConfig } from '@ggui-ai/wire';
|
|
4
|
+
import type { StreamBus } from './wire-config.js';
|
|
5
|
+
export interface StackItemRendererOptions {
|
|
6
|
+
/** The stack item to render. */
|
|
7
|
+
readonly stackItem: SessionStackEntry;
|
|
8
|
+
/** Wire config for this item — pre-scoped via the renderer's
|
|
9
|
+
* `buildScopedConfig(item)` (see
|
|
10
|
+
* `wire-config.ts::RootWireConfigBundle`). */
|
|
11
|
+
readonly scopedWireConfig: WireConfig | null;
|
|
12
|
+
/** Shared stream bus — provisional renderer subscribes to
|
|
13
|
+
* `_ggui:preview` for this item. */
|
|
14
|
+
readonly streamBus: StreamBus;
|
|
15
|
+
/** Session id — used by the mcp-apps iframe host for proxy URLs. */
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
/** ggui-server base URL (for mcp-apps proxy). Empty = same-origin. */
|
|
18
|
+
readonly mcpAppsServerBaseUrl?: string;
|
|
19
|
+
/** Theme id passed to ReactComponentRenderer for scoped CSS. */
|
|
20
|
+
readonly themeId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Theme color mode. Resolves the dark variant of `themeId` when set
|
|
23
|
+
* to `'dark'`. Falls through to `'light'` if absent — matches the
|
|
24
|
+
* design registry's `getTheme(id, mode?)` default.
|
|
25
|
+
*/
|
|
26
|
+
readonly themeMode?: 'light' | 'dark';
|
|
27
|
+
readonly cssOverrides?: string;
|
|
28
|
+
readonly onError?: (err: Error) => void;
|
|
29
|
+
readonly onRequestRepair?: (err: Error) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Optional outer wrapper applied AROUND the GguiWireProvider — used
|
|
32
|
+
* by `bootProduction` to install the `<ContextStateHost>` provider
|
|
33
|
+
* tree. Composition order:
|
|
34
|
+
*
|
|
35
|
+
* wrapOuter(<GguiWireProvider><userComponent/></GguiWireProvider>)
|
|
36
|
+
*
|
|
37
|
+
* `wrapOuter` returns the wrapped node; passing `undefined` is
|
|
38
|
+
* equivalent to the identity function (no extra wrapping). The
|
|
39
|
+
* self-contained boot path inlines its own ContextStateHost via the
|
|
40
|
+
* top-level `renderWrapper`; this seam exists for the WS-driven
|
|
41
|
+
* triad path where the per-item React mount is the only mount
|
|
42
|
+
* surface.
|
|
43
|
+
*/
|
|
44
|
+
readonly wrapOuter?: (mountedTree: ReactNode) => ReactNode;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Discriminator for WHICH renderer is currently mounted under this
|
|
48
|
+
* stack-item handle. Transitions: 'provisional' → 'react' when
|
|
49
|
+
* componentCode lands; 'react' → 'react' on code/prop updates;
|
|
50
|
+
* 'mcpApps' is terminal for its lifetime (mcp-apps items never
|
|
51
|
+
* become components + vice versa).
|
|
52
|
+
*/
|
|
53
|
+
type MountedKind = 'provisional' | 'react' | 'mcpApps' | 'none';
|
|
54
|
+
export interface StackItemHandle {
|
|
55
|
+
/**
|
|
56
|
+
* Apply an update to the stack-item handle. Kind changes (e.g.
|
|
57
|
+
* provisional → react when componentCode lands) tear down the old
|
|
58
|
+
* mount and stand up the replacement; same-kind updates route to
|
|
59
|
+
* the inner renderer's `update()`.
|
|
60
|
+
*/
|
|
61
|
+
update(next: StackItemRendererOptions): Promise<void>;
|
|
62
|
+
/** Tear down the active mount + unsubscribe from the StreamBus. */
|
|
63
|
+
unmount(): void;
|
|
64
|
+
/** Current render kind. Test-only — not load-bearing. */
|
|
65
|
+
readonly kind: MountedKind;
|
|
66
|
+
}
|
|
67
|
+
export declare function renderStackItem(container: HTMLElement, opts: StackItemRendererOptions): Promise<StackItemHandle>;
|
|
68
|
+
export interface StackRenderContext {
|
|
69
|
+
readonly containerFor: (stackItemId: string) => HTMLElement;
|
|
70
|
+
readonly getScopedWireConfig: (item: SessionStackEntry) => WireConfig | null;
|
|
71
|
+
readonly streamBus: StreamBus;
|
|
72
|
+
readonly sessionId: string;
|
|
73
|
+
readonly mcpAppsServerBaseUrl?: string;
|
|
74
|
+
readonly themeId?: string;
|
|
75
|
+
/** Theme color mode — see {@link StackItemRendererOptions.themeMode}. */
|
|
76
|
+
readonly themeMode?: 'light' | 'dark';
|
|
77
|
+
readonly onError?: (err: Error) => void;
|
|
78
|
+
readonly onRequestRepair?: (err: Error) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Optional outer wrapper for each stack-item React mount — see
|
|
81
|
+
* {@link StackItemRendererOptions.wrapOuter}. `bootProduction` uses
|
|
82
|
+
* this to inject `<ContextStateHost>` around every per-item React
|
|
83
|
+
* root, mirroring the wrap `bootSelfContained` performs at its
|
|
84
|
+
* single React root. Returning the input unchanged is equivalent
|
|
85
|
+
* to passing `undefined`.
|
|
86
|
+
*/
|
|
87
|
+
readonly getOuterWrapper?: (item: SessionStackEntry) => ((mountedTree: ReactNode) => ReactNode) | undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Minimal stack-level orchestrator — keyed by stackItem.id, mounts
|
|
91
|
+
* new items, updates existing ones, unmounts removed ones.
|
|
92
|
+
*
|
|
93
|
+
* The production renderer tree uses this; tests can drive the
|
|
94
|
+
* individual `renderStackItem` entry point directly.
|
|
95
|
+
*/
|
|
96
|
+
export declare class StackRenderer {
|
|
97
|
+
private readonly ctx;
|
|
98
|
+
private handles;
|
|
99
|
+
constructor(ctx: StackRenderContext);
|
|
100
|
+
applyStack(stack: readonly SessionStackEntry[]): Promise<void>;
|
|
101
|
+
unmountAll(): void;
|
|
102
|
+
}
|
|
103
|
+
export {};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-item dispatcher — routes each SessionStackEntry to the
|
|
3
|
+
* appropriate renderer based on its discriminator.
|
|
4
|
+
*
|
|
5
|
+
* The dispatch tree mirrors `@ggui-ai/react::DynamicComponent` +
|
|
6
|
+
* `McpAppsStackItemRenderer`:
|
|
7
|
+
*
|
|
8
|
+
* - `type === 'mcpApps'` → `mountMcpAppIframe` (vanilla iframe
|
|
9
|
+
* host; the recursive foreign-MCP-App path).
|
|
10
|
+
* - `componentCode` empty OR absent → `mountProvisional` (A2UI
|
|
11
|
+
* preview fed by the `_ggui:preview` reserved channel).
|
|
12
|
+
* - otherwise → `mountReactRoot` with a per-item `WireConfig`
|
|
13
|
+
* built via the renderer's `buildScopedConfig(item)` closure.
|
|
14
|
+
* The mounted component is wrapped in
|
|
15
|
+
* `<GguiWireProvider config={scopedConfig}>` through the
|
|
16
|
+
* `renderWrapper` seam.
|
|
17
|
+
*
|
|
18
|
+
* Per-item lifecycle:
|
|
19
|
+
*
|
|
20
|
+
* Each stack entry owns a SINGLE `StackItemHandle` for its visible
|
|
21
|
+
* lifetime. When the entry gets replaced in-place (push matched by
|
|
22
|
+
* id), the dispatcher transitions the existing handle rather than
|
|
23
|
+
* unmount + remount — props-only changes skip the React eval; kind
|
|
24
|
+
* changes unmount the current and mount the replacement.
|
|
25
|
+
*
|
|
26
|
+
* StreamBus integration:
|
|
27
|
+
*
|
|
28
|
+
* The dispatcher subscribes the mounted provisional renderer to
|
|
29
|
+
* `_ggui:preview` envelopes via the caller-provided StreamBus. On
|
|
30
|
+
* ReactComponentRenderer mount (componentCode lands), the
|
|
31
|
+
* provisional surface is `suspend()`-ed so the authoritative render
|
|
32
|
+
* takes over — matches DynamicComponent's componentCode-present
|
|
33
|
+
* gate.
|
|
34
|
+
*/
|
|
35
|
+
import { createElement } from 'react';
|
|
36
|
+
import { GguiWireProvider } from '@ggui-ai/wire';
|
|
37
|
+
import { mountReactRoot } from './react-renderer.js';
|
|
38
|
+
import { mountProvisional, } from './provisional-renderer.js';
|
|
39
|
+
import { mountMcpAppIframe, } from './mcp-app-iframe-host.js';
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Kind detection
|
|
42
|
+
// =============================================================================
|
|
43
|
+
function detectKind(stackItem) {
|
|
44
|
+
if (stackItem.type === 'mcpApps')
|
|
45
|
+
return 'mcpApps';
|
|
46
|
+
// System items don't route through this stack-item dispatcher — the
|
|
47
|
+
// self-contained boot path renders them via `SystemCardHost` directly.
|
|
48
|
+
// If one accidentally lands here, fall through to provisional so the
|
|
49
|
+
// user at least sees the A2UI placeholder rather than a crash.
|
|
50
|
+
if (stackItem.type === 'system')
|
|
51
|
+
return 'provisional';
|
|
52
|
+
const code = stackItem.componentCode;
|
|
53
|
+
if (typeof code === 'string' && code.trim().length > 0)
|
|
54
|
+
return 'react';
|
|
55
|
+
return 'provisional';
|
|
56
|
+
}
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Mount
|
|
59
|
+
// =============================================================================
|
|
60
|
+
export async function renderStackItem(container, opts) {
|
|
61
|
+
let currentOpts = opts;
|
|
62
|
+
let currentKind = 'none';
|
|
63
|
+
let reactMount = null;
|
|
64
|
+
let provisionalMount = null;
|
|
65
|
+
let mcpMount = null;
|
|
66
|
+
let previewUnsubscribe = null;
|
|
67
|
+
function teardown() {
|
|
68
|
+
if (reactMount !== null) {
|
|
69
|
+
reactMount.unmount();
|
|
70
|
+
reactMount = null;
|
|
71
|
+
}
|
|
72
|
+
if (provisionalMount !== null) {
|
|
73
|
+
provisionalMount.unmount();
|
|
74
|
+
provisionalMount = null;
|
|
75
|
+
}
|
|
76
|
+
if (mcpMount !== null) {
|
|
77
|
+
mcpMount.unmount();
|
|
78
|
+
mcpMount = null;
|
|
79
|
+
}
|
|
80
|
+
if (previewUnsubscribe !== null) {
|
|
81
|
+
previewUnsubscribe();
|
|
82
|
+
previewUnsubscribe = null;
|
|
83
|
+
}
|
|
84
|
+
currentKind = 'none';
|
|
85
|
+
}
|
|
86
|
+
async function mountByKind(kind) {
|
|
87
|
+
if (kind === 'mcpApps') {
|
|
88
|
+
// SessionStackEntry narrows to McpAppsStackItem when type='mcpApps'.
|
|
89
|
+
const item = currentOpts.stackItem;
|
|
90
|
+
mcpMount = mountMcpAppIframe(container, {
|
|
91
|
+
stackItem: item,
|
|
92
|
+
sessionId: currentOpts.sessionId,
|
|
93
|
+
...(currentOpts.mcpAppsServerBaseUrl !== undefined
|
|
94
|
+
? { serverBaseUrl: currentOpts.mcpAppsServerBaseUrl }
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
currentKind = 'mcpApps';
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'provisional') {
|
|
101
|
+
provisionalMount = mountProvisional(container);
|
|
102
|
+
previewUnsubscribe = currentOpts.streamBus.subscribe('_ggui:preview', (env) => {
|
|
103
|
+
provisionalMount?.pushEnvelope(env);
|
|
104
|
+
});
|
|
105
|
+
currentKind = 'provisional';
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// kind === 'react' — detectKind guarantees a generated component
|
|
109
|
+
// variant (no mcpApps, no system) by this point.
|
|
110
|
+
const item = currentOpts.stackItem;
|
|
111
|
+
if (item.type === 'mcpApps' || item.type === 'system')
|
|
112
|
+
return;
|
|
113
|
+
// GG.8.2 — gadget package names from the item's descriptor sidecar
|
|
114
|
+
// so the rewriter resolves each direct gadget import to its
|
|
115
|
+
// per-package shim. STDLIB `@ggui-ai/gadgets` is always rewritten.
|
|
116
|
+
const gadgetPackages = 'gadgetDescriptors' in item && item.gadgetDescriptors !== undefined
|
|
117
|
+
? item.gadgetDescriptors.map((d) => d.package)
|
|
118
|
+
: undefined;
|
|
119
|
+
reactMount = await mountReactRoot(container, {
|
|
120
|
+
stackItem: {
|
|
121
|
+
...(item.id !== undefined ? { id: item.id } : {}),
|
|
122
|
+
componentCode: item.componentCode ?? '',
|
|
123
|
+
...(item.props !== undefined
|
|
124
|
+
? { props: item.props }
|
|
125
|
+
: {}),
|
|
126
|
+
},
|
|
127
|
+
...(gadgetPackages !== undefined ? { gadgetPackages } : {}),
|
|
128
|
+
...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
|
|
129
|
+
...(currentOpts.themeMode !== undefined ? { themeMode: currentOpts.themeMode } : {}),
|
|
130
|
+
...(currentOpts.cssOverrides !== undefined
|
|
131
|
+
? { cssOverrides: currentOpts.cssOverrides }
|
|
132
|
+
: {}),
|
|
133
|
+
...(currentOpts.onError !== undefined ? { onError: currentOpts.onError } : {}),
|
|
134
|
+
...(currentOpts.onRequestRepair !== undefined
|
|
135
|
+
? { onRequestRepair: currentOpts.onRequestRepair }
|
|
136
|
+
: {}),
|
|
137
|
+
renderWrapper: wrapInScopedProvider,
|
|
138
|
+
});
|
|
139
|
+
currentKind = 'react';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Wrap the mounted component in `<GguiWireProvider
|
|
143
|
+
* config={scopedConfig}>` when the caller supplied one. Without a
|
|
144
|
+
* scoped config, the component renders standalone — matches
|
|
145
|
+
* today's DynamicComponent fallback when no GguiSession parent is
|
|
146
|
+
* present.
|
|
147
|
+
*
|
|
148
|
+
* Composition order (outer → inner):
|
|
149
|
+
*
|
|
150
|
+
* wrapOuter(<GguiWireProvider><userComponent/></GguiWireProvider>)
|
|
151
|
+
*
|
|
152
|
+
* `wrapOuter` is supplied by `bootProduction` to install the
|
|
153
|
+
* `<ContextStateHost>` provider tree around the per-item React
|
|
154
|
+
* mount. When absent, the wire provider is the only wrapper.
|
|
155
|
+
*/
|
|
156
|
+
function wrapInScopedProvider(mountedComponent) {
|
|
157
|
+
const wireWrapped = currentOpts.scopedWireConfig === null
|
|
158
|
+
? mountedComponent
|
|
159
|
+
: createElement(GguiWireProvider, { config: currentOpts.scopedWireConfig, children: mountedComponent });
|
|
160
|
+
return currentOpts.wrapOuter !== undefined
|
|
161
|
+
? currentOpts.wrapOuter(wireWrapped)
|
|
162
|
+
: wireWrapped;
|
|
163
|
+
}
|
|
164
|
+
// Initial mount.
|
|
165
|
+
const initialKind = detectKind(opts.stackItem);
|
|
166
|
+
await mountByKind(initialKind);
|
|
167
|
+
return {
|
|
168
|
+
get kind() {
|
|
169
|
+
return currentKind;
|
|
170
|
+
},
|
|
171
|
+
async update(next) {
|
|
172
|
+
const prevKind = currentKind;
|
|
173
|
+
currentOpts = next;
|
|
174
|
+
const nextKind = detectKind(next.stackItem);
|
|
175
|
+
if (nextKind !== prevKind) {
|
|
176
|
+
// Kind changed — tear down old + mount new.
|
|
177
|
+
teardown();
|
|
178
|
+
await mountByKind(nextKind);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Same-kind update — route to the inner renderer's update.
|
|
182
|
+
if (nextKind === 'react' && reactMount !== null) {
|
|
183
|
+
const item = next.stackItem;
|
|
184
|
+
if (item.type === 'mcpApps' || item.type === 'system')
|
|
185
|
+
return;
|
|
186
|
+
await reactMount.update({
|
|
187
|
+
stackItem: {
|
|
188
|
+
...(item.id !== undefined ? { id: item.id } : {}),
|
|
189
|
+
componentCode: item.componentCode ?? '',
|
|
190
|
+
...(item.props !== undefined
|
|
191
|
+
? { props: item.props }
|
|
192
|
+
: {}),
|
|
193
|
+
},
|
|
194
|
+
...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
|
|
195
|
+
...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
|
|
196
|
+
...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
|
|
197
|
+
...(next.onError !== undefined ? { onError: next.onError } : {}),
|
|
198
|
+
...(next.onRequestRepair !== undefined
|
|
199
|
+
? { onRequestRepair: next.onRequestRepair }
|
|
200
|
+
: {}),
|
|
201
|
+
renderWrapper: wrapInScopedProvider,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// provisional / mcpApps same-kind: no in-place update surface.
|
|
205
|
+
// Provisional keeps accumulating envelopes; mcpApps iframe
|
|
206
|
+
// doesn't rebind its src during its lifetime.
|
|
207
|
+
},
|
|
208
|
+
unmount: teardown,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Minimal stack-level orchestrator — keyed by stackItem.id, mounts
|
|
213
|
+
* new items, updates existing ones, unmounts removed ones.
|
|
214
|
+
*
|
|
215
|
+
* The production renderer tree uses this; tests can drive the
|
|
216
|
+
* individual `renderStackItem` entry point directly.
|
|
217
|
+
*/
|
|
218
|
+
export class StackRenderer {
|
|
219
|
+
ctx;
|
|
220
|
+
handles = new Map();
|
|
221
|
+
constructor(ctx) {
|
|
222
|
+
this.ctx = ctx;
|
|
223
|
+
}
|
|
224
|
+
async applyStack(stack) {
|
|
225
|
+
const nextIds = new Set(stack.map((s) => s.id));
|
|
226
|
+
// Unmount removed items.
|
|
227
|
+
for (const [id, handle] of this.handles) {
|
|
228
|
+
if (!nextIds.has(id)) {
|
|
229
|
+
handle.unmount();
|
|
230
|
+
this.handles.delete(id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Mount or update each item in stack order.
|
|
234
|
+
for (const item of stack) {
|
|
235
|
+
const container = this.ctx.containerFor(item.id);
|
|
236
|
+
const scopedWireConfig = this.ctx.getScopedWireConfig(item);
|
|
237
|
+
const wrapOuter = this.ctx.getOuterWrapper?.(item);
|
|
238
|
+
const opts = {
|
|
239
|
+
stackItem: item,
|
|
240
|
+
scopedWireConfig,
|
|
241
|
+
streamBus: this.ctx.streamBus,
|
|
242
|
+
sessionId: this.ctx.sessionId,
|
|
243
|
+
...(this.ctx.mcpAppsServerBaseUrl !== undefined
|
|
244
|
+
? { mcpAppsServerBaseUrl: this.ctx.mcpAppsServerBaseUrl }
|
|
245
|
+
: {}),
|
|
246
|
+
...(this.ctx.themeId !== undefined ? { themeId: this.ctx.themeId } : {}),
|
|
247
|
+
...(this.ctx.themeMode !== undefined ? { themeMode: this.ctx.themeMode } : {}),
|
|
248
|
+
...(this.ctx.onError !== undefined ? { onError: this.ctx.onError } : {}),
|
|
249
|
+
...(this.ctx.onRequestRepair !== undefined
|
|
250
|
+
? { onRequestRepair: this.ctx.onRequestRepair }
|
|
251
|
+
: {}),
|
|
252
|
+
...(wrapOuter !== undefined ? { wrapOuter } : {}),
|
|
253
|
+
};
|
|
254
|
+
const existing = this.handles.get(item.id);
|
|
255
|
+
if (existing === undefined) {
|
|
256
|
+
const handle = await renderStackItem(container, opts);
|
|
257
|
+
this.handles.set(item.id, handle);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
await existing.update(opts);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
unmountAll() {
|
|
265
|
+
for (const handle of this.handles.values()) {
|
|
266
|
+
handle.unmount();
|
|
267
|
+
}
|
|
268
|
+
this.handles.clear();
|
|
269
|
+
}
|
|
270
|
+
}
|
package/dist/stack.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack state + structural-placeholder DOM rendering.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the placeholder render path: each stack item is
|
|
5
|
+
* drawn as a structural placeholder showing id + description/prompt.
|
|
6
|
+
* The React mount path that executes `componentCode` lives elsewhere
|
|
7
|
+
* and swaps in over the same DOM seam.
|
|
8
|
+
*
|
|
9
|
+
* The model + the DOM render are split so:
|
|
10
|
+
* - `StackModel` is testable in isolation (no DOM needed for unit
|
|
11
|
+
* tests around upsert / replace / clear semantics);
|
|
12
|
+
* - `renderStackInto(el, model)` is the only place that touches DOM,
|
|
13
|
+
* making it the seam the React mount path swaps out without
|
|
14
|
+
* disturbing the WS message handler.
|
|
15
|
+
*
|
|
16
|
+
* Stack ordering follows the wire shape: the ack's initial `stack` is
|
|
17
|
+
* displayed in array order; each `push` either replaces an item with
|
|
18
|
+
* the same id (preserving its position) or appends at the tail.
|
|
19
|
+
*/
|
|
20
|
+
import type { SessionStackEntry } from '@ggui-ai/protocol';
|
|
21
|
+
/**
|
|
22
|
+
* Construction options for {@link StackModel}.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export interface StackModelOptions {
|
|
27
|
+
/**
|
|
28
|
+
* When present, the model renders only the single stack entry with
|
|
29
|
+
* this `id` — the rest of the session stack is filtered out on
|
|
30
|
+
* `setAll` and `upsert` ignores every entry whose id ≠ this value.
|
|
31
|
+
* Enables single-item renderer mode, driven by the
|
|
32
|
+
* `bootstrap.stackItemId` field set on a per-item session
|
|
33
|
+
* resource.
|
|
34
|
+
*
|
|
35
|
+
* Absent → multi-item mode: the model renders the entire stack as
|
|
36
|
+
* the server delivers it.
|
|
37
|
+
*
|
|
38
|
+
* The filter is immutable for a model's lifetime (no setter).
|
|
39
|
+
* Consumers that need a different filter MUST construct a new
|
|
40
|
+
* model — matches the "model is the truth" doctrine below and
|
|
41
|
+
* makes filter-change intent explicit in code review.
|
|
42
|
+
*/
|
|
43
|
+
readonly filterToItemId?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* In-memory model of the visible stack. Owned by the runtime; mutated
|
|
47
|
+
* via the methods (no setter — keeps the "model is the truth" doctrine
|
|
48
|
+
* obvious in code reviews).
|
|
49
|
+
*/
|
|
50
|
+
export declare class StackModel {
|
|
51
|
+
private items;
|
|
52
|
+
private readonly filterToItemId;
|
|
53
|
+
constructor(options?: StackModelOptions);
|
|
54
|
+
/**
|
|
55
|
+
* Replace the entire stack — used after the subscribe ack lands.
|
|
56
|
+
*
|
|
57
|
+
* Under `filterToItemId` (single-item mode), the incoming list is
|
|
58
|
+
* filtered to just the matching entry. If no entry matches, the
|
|
59
|
+
* model ends up empty — the server no longer includes the pinned
|
|
60
|
+
* item and the renderer's empty-state is the correct surface.
|
|
61
|
+
*/
|
|
62
|
+
setAll(items: readonly SessionStackEntry[]): void;
|
|
63
|
+
/**
|
|
64
|
+
* Upsert a stack item. If an entry with the same id already exists
|
|
65
|
+
* its slot is replaced in place (preserves ordering); otherwise the
|
|
66
|
+
* item is appended at the tail. Mirrors `GguiSession.tsx`'s push
|
|
67
|
+
* reducer.
|
|
68
|
+
*
|
|
69
|
+
* Under `filterToItemId` (single-item mode): entries whose id does
|
|
70
|
+
* not match the filter are ignored. The renderer's live-channel
|
|
71
|
+
* subscription still delivers every push for the session (subscribe
|
|
72
|
+
* remains session-scoped); this filter is the client-side pin that
|
|
73
|
+
* keeps the per-card iframe bound to its one stack entry.
|
|
74
|
+
*/
|
|
75
|
+
upsert(item: SessionStackEntry): void;
|
|
76
|
+
/** Read-only view — callers MUST NOT mutate. */
|
|
77
|
+
snapshot(): readonly SessionStackEntry[];
|
|
78
|
+
/** Item count — convenience for status-line rendering. */
|
|
79
|
+
size(): number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Render the stack model into the target list element as structural
|
|
83
|
+
* `<li data-ggui-stack-item="<id>">` placeholders. Idempotent — every
|
|
84
|
+
* call replaces the element's children based on the current model.
|
|
85
|
+
*
|
|
86
|
+
* The DOM shape (data attributes + classnames) is intentionally
|
|
87
|
+
* stable: E2E tests and the React-mount path both need to address
|
|
88
|
+
* stack-item rows by `data-ggui-stack-item` selector.
|
|
89
|
+
*/
|
|
90
|
+
export declare function renderStackInto(target: HTMLElement, model: StackModel): void;
|