@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,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
+ }
@@ -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;