@unoverse-platform/studio 0.1.103 → 0.1.104

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 (79) hide show
  1. package/package.json +4 -2
  2. package/screens/AtomsView.tsx +2 -1
  3. package/screens/ComponentsView.tsx +2 -1
  4. package/screens/NativeAppHost.tsx +1 -2
  5. package/screens/TemplatesLibraryView.tsx +14 -1
  6. package/screens/TemplatesView.tsx +2 -1
  7. package/screens/index.ts +4 -0
  8. package/screens/react/unoverse.tsx +281 -0
  9. package/vendor/base/src/agent-mcp/index.ts +1 -1
  10. package/vendor/base/src/agent-mcp/invoke.ts +6 -2
  11. package/vendor/base/src/agent-mcp/tools.ts +14 -5
  12. package/vendor/base/src/agent-mcp/types.ts +6 -0
  13. package/vendor/base/src/auth/auth.ts +90 -23
  14. package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
  15. package/vendor/base/src/definitions/load/compile/template.ts +23 -2
  16. package/vendor/base/src/definitions/model.ts +9 -0
  17. package/vendor/base/src/definitions/registry/catalog.ts +6 -7
  18. package/vendor/base/src/executor.ts +25 -1
  19. package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
  20. package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
  21. package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
  22. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
  23. package/vendor/base/src/platform/serviceCalls.ts +13 -0
  24. package/vendor/sdk/appEngine.ts +363 -0
  25. package/vendor/sdk/lib/actions.ts +36 -0
  26. package/vendor/sdk/lib/citations.ts +48 -0
  27. package/vendor/sdk/lib/core/apps.ts +18 -0
  28. package/vendor/sdk/lib/core/client.ts +3 -3
  29. package/vendor/sdk/lib/core/store.ts +6 -0
  30. package/vendor/sdk/lib/dom/app.ts +546 -0
  31. package/vendor/sdk/lib/dom/chrome.ts +125 -0
  32. package/vendor/sdk/lib/dom/component.ts +333 -0
  33. package/vendor/sdk/lib/dom/connection.ts +309 -0
  34. package/vendor/sdk/lib/dom/element.ts +69 -0
  35. package/vendor/sdk/lib/dom/isolate.ts +37 -0
  36. package/vendor/sdk/lib/dom/markdown.ts +388 -0
  37. package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
  38. package/vendor/sdk/lib/dom/mounted.ts +29 -0
  39. package/vendor/sdk/lib/dom/primitives.ts +689 -0
  40. package/vendor/sdk/lib/dom/region.ts +195 -0
  41. package/vendor/sdk/lib/dom/render.ts +177 -0
  42. package/vendor/sdk/lib/dom/theme.ts +38 -0
  43. package/vendor/sdk/lib/dom/types.ts +36 -0
  44. package/vendor/sdk/lib/index.ts +26 -17
  45. package/vendor/sdk/lib/orb/canvas.ts +139 -0
  46. package/vendor/sdk/lib/orb/index.ts +8 -18
  47. package/vendor/sdk/lib/orb/presets.ts +1 -1
  48. package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
  49. package/vendor/sdk/lib/realtime/capture.ts +152 -0
  50. package/vendor/sdk/lib/realtime/playback.ts +129 -0
  51. package/vendor/sdk/lib/realtime/socket.ts +117 -0
  52. package/vendor/sdk/lib/style.ts +45 -37
  53. package/vendor/sdk/lib/voiceService.ts +344 -0
  54. package/vendor/sdk/main.ts +107 -0
  55. package/vendor/sdk/mcpAppBridge.ts +125 -102
  56. package/vendor/sdk/pageInterfaces.ts +452 -0
  57. package/vendor/sdk/responseView.ts +193 -0
  58. package/vite.config.ts +3 -3
  59. package/vendor/sdk/appEngine.tsx +0 -395
  60. package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
  61. package/vendor/sdk/lib/app.tsx +0 -730
  62. package/vendor/sdk/lib/connection.tsx +0 -433
  63. package/vendor/sdk/lib/isolate.tsx +0 -70
  64. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
  65. package/vendor/sdk/lib/orb/theme.ts +0 -83
  66. package/vendor/sdk/lib/primitives.tsx +0 -943
  67. package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
  68. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
  69. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
  70. package/vendor/sdk/lib/render.tsx +0 -244
  71. package/vendor/sdk/lib/streamed.tsx +0 -30
  72. package/vendor/sdk/lib/theme.ts +0 -55
  73. package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
  74. package/vendor/sdk/lib/transition/index.ts +0 -9
  75. package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
  76. package/vendor/sdk/lib/voice.tsx +0 -422
  77. package/vendor/sdk/main.tsx +0 -125
  78. package/vendor/sdk/pageInterfaces.tsx +0 -388
  79. package/vendor/sdk/responseView.tsx +0 -82
@@ -0,0 +1,452 @@
1
+ /**
2
+ * THE PAGE'S OWN TEMPLATES (docs/unoverse/design/sdk/UNOVERSE_HOST_PAGE.md rule 3), plain DOM.
3
+ *
4
+ * A website that is the app marks where its unoverse templates go:
5
+ *
6
+ * <div data-unoverse-interface="unoverse/presence-avatar"></div>
7
+ *
8
+ * This runtime is already loaded (the embed started the app with no chrome). It reads each
9
+ * marked template by URI over MCP, exactly as it reads the app, and draws it INTO the page's
10
+ * element, in its own shadow root. One runtime, many shadow roots: one client, one
11
+ * conversation, one sign-in for every part of the page.
12
+ *
13
+ * A template ARRIVES WITH ITS PAGE, as Studio previews it and as a delivery lands it: one
14
+ * instance per linked part (`static:`), at its face, with the part's own values (the served
15
+ * `sections` and their `seeds`), then the template itself. Drawn bare, its parts' slots hold
16
+ * nothing and only the frame shows.
17
+ *
18
+ * The frame shares the page's origin (EMBED_CLIENT.md §1), so the page's document is reachable
19
+ * from here, and a part drawn into the page is as live as one drawn in the frame.
20
+ */
21
+ import { ComponentStore } from "./lib/core/store";
22
+ import type { UnoverseDefinition } from "./lib/core/types";
23
+ import type { ActionHandler, ActionMeta } from "./lib/actions";
24
+ import { mountComponent, type ComponentMount, type DefinitionSource } from "./lib/dom/component";
25
+ import type { VoiceService } from "./lib/voiceService";
26
+
27
+ const MARK = "data-unoverse-interface";
28
+ const CHAT = "page";
29
+ const TEMPLATE_NODE = "~template";
30
+
31
+ /** The page this runtime serves, or null when there is none it may reach (an MCP host). */
32
+ function pageDocument(): Document | null {
33
+ try {
34
+ return window.parent !== window ? window.parent.document : null;
35
+ } catch {
36
+ return null; // a cross-origin host: there is no page of ours to draw into
37
+ }
38
+ }
39
+
40
+ /**
41
+ * WHICH TEMPLATE THE ARROW KEYS BELONG TO: exactly one. The one the visitor last pressed in;
42
+ * before any press, the one taking the most of the screen. Every template's state is its own,
43
+ * so a key must never move two.
44
+ */
45
+ let engaged: HTMLElement | null = null;
46
+ function keyOwner(doc: Document): HTMLElement | null {
47
+ const full = doc.fullscreenElement as HTMLElement | null;
48
+ if (full?.hasAttribute(MARK)) return full; // a fullscreen template is the only one on screen
49
+ if (engaged && doc.contains(engaged)) return engaged;
50
+ const vh = doc.defaultView?.innerHeight ?? 0;
51
+ let best: HTMLElement | null = null;
52
+ let area = 0;
53
+ for (const el of doc.querySelectorAll<HTMLElement>(`[${MARK}]`)) {
54
+ const r = el.getBoundingClientRect();
55
+ const seen = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)) * r.width;
56
+ if (seen > area) (area = seen), (best = el);
57
+ }
58
+ return best;
59
+ }
60
+
61
+ /** A page template's address, from the name its element is marked with. */
62
+ const uriOf = (name: string) => (name.includes("://") ? name : `unoverse://templates/${name}`);
63
+
64
+ /** THE AVATARS: page templates whose steps are the call's phases (`listening`, `speaking`).
65
+ * They are how the page is spoken to, so they are never the task in view; they follow the call. */
66
+ const avatars = new Set<HTMLElement>();
67
+ const CALL_PHASES = ["listening", "speaking"];
68
+
69
+ /** The workflow a template names in its manifest (UNOVERSE_HOST_PAGE.md rule 4). */
70
+ export type Binding = { workflow?: string; trigger?: string };
71
+
72
+ /** THE TASK IN VIEW: the marked template, not an avatar, spanning the middle of the screen. */
73
+ function taskInView(doc: Document): HTMLElement | null {
74
+ const mid = (doc.defaultView?.innerHeight ?? 0) / 2;
75
+ for (const el of doc.querySelectorAll<HTMLElement>(`[${MARK}]`)) {
76
+ if (avatars.has(el)) continue;
77
+ const r = el.getBoundingClientRect();
78
+ if (r.top <= mid && r.bottom >= mid) return el;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ type Section = { kind?: string; face?: string; ref?: string; preview?: string[]; seeds?: Record<string, Record<string, unknown>> };
84
+ type StateTree = { name: string; on?: string; initial?: string; states?: { name: string }[]; fullscreen?: boolean; arrows?: boolean }[];
85
+
86
+ /**
87
+ * THE AVATAR'S SPOT WHILE A TEMPLATE IS FULLSCREEN. The browser shows only the fullscreen
88
+ * element, so the avatar's own element (in the site's navigation) is hidden. A fullscreen
89
+ * state's layout carries a `Place`; it draws this element, and the page's avatar draws there
90
+ * until the place goes. It is the same avatar, so it keeps following the call.
91
+ */
92
+ class Dock {
93
+ readonly slot: HTMLDivElement;
94
+ at: HTMLElement | null = null;
95
+ private watchers = new Set<() => void>();
96
+ constructor(doc: Document) {
97
+ this.slot = doc.createElement("div");
98
+ }
99
+ /** Whether a place is drawing the slot now; checked after any template draws. */
100
+ check(): void {
101
+ queueMicrotask(() => {
102
+ const at = this.slot.isConnected ? this.slot : null;
103
+ if (at === this.at) return;
104
+ this.at = at;
105
+ this.watchers.forEach((f) => f());
106
+ });
107
+ }
108
+ watch(f: () => void): () => void {
109
+ this.watchers.add(f);
110
+ return () => void this.watchers.delete(f);
111
+ }
112
+ }
113
+
114
+ interface PageTemplateOptions {
115
+ client: DefinitionSource;
116
+ uri: string;
117
+ el: HTMLElement;
118
+ voice: VoiceService;
119
+ appStore: ComponentStore;
120
+ dock: Dock;
121
+ waiting: () => boolean;
122
+ onBinding: (uri: string, binding: Binding) => void;
123
+ onCall: ActionHandler;
124
+ }
125
+
126
+ /** One marked template: read it, give it its parts, draw it. */
127
+ function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy(): void } {
128
+ const { client, uri, el, voice, appStore, dock } = o;
129
+ const store = new ComponentStore();
130
+ const stops: (() => void)[] = [];
131
+ let parts: string[] = [];
132
+ let def: { stateTree?: StateTree } | null = null;
133
+ let comp: ComponentMount | null = null;
134
+ let alive = true;
135
+
136
+ // Where the template draws: its element, or the dock while a fullscreen template holds one
137
+ // and this template is an avatar.
138
+ const place = () => {
139
+ if (!comp) return;
140
+ const into = dock.at && avatars.has(el) ? dock.at : el;
141
+ for (const n of comp.nodes) if (n.parentNode !== into) into.appendChild(n);
142
+ };
143
+
144
+ const ready = () => {
145
+ comp = mountComponent({ client, store, chatId: CHAT, nodeId: TEMPLATE_NODE, onAction: o.onCall, place: () => [dock.slot], appStore, doc: el.ownerDocument, arrowKeys: false });
146
+ place();
147
+ stops.push(dock.watch(place), store.subscribe(() => dock.check()));
148
+ dock.check();
149
+
150
+ // A TEMPLATE FOLLOWS ITS TASK. Whatever runs the task publishes where it is as
151
+ // `tasks.<its URI>` in the app state (SIGNAL.md 6c): this template moves to that step, and its
152
+ // `props`, keyed by part, merge into those parts. THE SAME TEMPLATE, STREAMED, hydrates this
153
+ // copy part by part; a copy still on its first step moves to the next as its values land.
154
+ let lastProps = "";
155
+ let lastStreamed = "";
156
+ const lastPart = new Map<string, string>();
157
+ const fillParts = (byPart: Record<string, unknown>) => {
158
+ for (const [part, values] of Object.entries(byPart)) {
159
+ if (!values || typeof values !== "object" || Array.isArray(values)) continue;
160
+ for (const nodeId of parts.filter((id) => id.startsWith(`${part}~`))) store.mergeComponentState(CHAT, nodeId, values as Record<string, unknown>);
161
+ }
162
+ };
163
+ const moveTo = (at: unknown) => {
164
+ if (typeof at !== "string") return;
165
+ const tree = def?.stateTree?.find((t) => t.on && t.states?.some((x) => x.name === at));
166
+ if (!tree?.on) return;
167
+ const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
168
+ if (tpl?.[tree.on] !== at) store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: at });
169
+ };
170
+ const follow = () => {
171
+ const task = ((appStore.getAppState().tasks ?? {}) as Record<string, { step?: string; props?: Record<string, unknown> }>)[uri];
172
+ if (task?.props && typeof task.props === "object") {
173
+ const next = JSON.stringify(task.props);
174
+ if (next !== lastProps) {
175
+ lastProps = next;
176
+ fillParts(task.props);
177
+ }
178
+ }
179
+ moveTo(task?.step);
180
+ for (const r of appStore.getResponses()) {
181
+ for (const pointer of r.components) {
182
+ const { chatId, nodeId } = appStore.split(pointer);
183
+ // A PART FOLLOWS THE COMPONENT OF ITS NAME, STREAMED (an Agent's agent-card); it keeps its own face.
184
+ const kind = (appStore.typeOf(pointer) ?? "").split("/").pop() ?? "";
185
+ if (kind && parts.some((id) => id.startsWith(`${kind}~`))) {
186
+ const { state: _face, ...values } = (appStore.get(chatId, nodeId) ?? {}) as Record<string, unknown>;
187
+ const next = JSON.stringify(values);
188
+ if (lastPart.get(kind) !== next) {
189
+ lastPart.set(kind, next);
190
+ fillParts({ [kind]: values });
191
+ }
192
+ continue;
193
+ }
194
+ if ((appStore.getComponentUrl(chatId, nodeId) ?? "").split("#")[0] !== uri) continue;
195
+ const streamed = appStore.get(chatId, nodeId) ?? {};
196
+ const next = JSON.stringify(streamed);
197
+ if (next === lastStreamed) continue;
198
+ lastStreamed = next;
199
+ // A copy with no part filled yet changes nothing, so the template stays on its first step.
200
+ if (!parts.some((id) => { const v = (streamed as Record<string, unknown>)[id.split("~")[0]]; return !!v && typeof v === "object" && !Array.isArray(v); })) continue;
201
+ fillParts(streamed as Record<string, unknown>);
202
+ const tree = def?.stateTree?.find((t) => t.on && (t.states?.length ?? 0) > 1);
203
+ const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
204
+ if (tree?.on && (tpl?.[tree.on] ?? tree.initial) === tree.initial) moveTo(tree.states![1].name);
205
+ }
206
+ }
207
+ };
208
+ follow();
209
+ stops.push(appStore.subscribe(follow));
210
+
211
+ const doc = pageDocument();
212
+ if (doc) {
213
+ // THE ARROW KEYS STEP IT, on the PAGE (the keys are the page's), when its state says
214
+ // `arrows: true`. Typing never steps, and only the template that owns the keys moves.
215
+ const onKey = (e: KeyboardEvent) => {
216
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
217
+ if (keyOwner(doc) !== el) return;
218
+ const t = e.target as HTMLElement | null;
219
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
220
+ const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
221
+ const face = String(tpl?.state ?? "");
222
+ const tree = def?.stateTree?.find((s) => s.name === face);
223
+ const steps = tree?.states?.map((s) => s.name) ?? [];
224
+ if (!tree?.on || !tree.arrows || steps.length < 2) return;
225
+ const cur = String(tpl?.[tree.on] ?? tree.initial ?? steps[0]);
226
+ const i = steps.indexOf(cur) + (e.key === "ArrowRight" ? 1 : -1);
227
+ if (i < 0 || i >= steps.length) return;
228
+ e.preventDefault();
229
+ store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: steps[i] });
230
+ };
231
+ doc.addEventListener("keydown", onKey);
232
+ stops.push(() => doc.removeEventListener("keydown", onKey));
233
+
234
+ // FULLSCREEN IS A STATE. Entering a state marked `fullscreen` takes this element
235
+ // fullscreen, inside the store write the visitor's click made (a browser grants fullscreen
236
+ // only to a click); leaving it hands the screen back. Refused, or left with Esc, the
237
+ // template goes back to the state it came from.
238
+ const states = () => def?.stateTree ?? [];
239
+ const isFull = (name: string) => states().some((t) => t.name === name && t.fullscreen);
240
+ const face = () => String((store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined)?.state ?? "");
241
+ let from = isFull(face()) ? (states().find((t) => !t.fullscreen)?.name ?? "") : face();
242
+ let asking = false;
243
+ const back = () => store.mergeComponentState(CHAT, TEMPLATE_NODE, { state: from });
244
+ const sync = () => {
245
+ const now = face();
246
+ if (!isFull(now)) {
247
+ from = now;
248
+ if (doc.fullscreenElement === el) void doc.exitFullscreen();
249
+ return;
250
+ }
251
+ if (doc.fullscreenElement === el || asking) return;
252
+ asking = true;
253
+ el.requestFullscreen()
254
+ .catch(back)
255
+ .finally(() => (asking = false));
256
+ };
257
+ const onChange = () => {
258
+ if (doc.fullscreenElement !== el && !asking && isFull(face())) back();
259
+ };
260
+ stops.push(store.subscribe(sync));
261
+ doc.addEventListener("fullscreenchange", onChange);
262
+ stops.push(() => doc.removeEventListener("fullscreenchange", onChange));
263
+ }
264
+
265
+ // AN AVATAR FOLLOWS THE CALL: its steps are the call's phases, so its step is the call's
266
+ // state; `waiting` rides beside it (a task in view the call is not working yet).
267
+ if (avatars.has(el)) {
268
+ const tree = def?.stateTree?.find((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)));
269
+ if (tree?.on) {
270
+ const on = tree.on;
271
+ let last = "";
272
+ const call = () => {
273
+ const next = JSON.stringify([voice.state.callState, o.waiting()]);
274
+ if (next === last) return;
275
+ last = next;
276
+ store.mergeComponentState(CHAT, TEMPLATE_NODE, { [on]: voice.state.callState, waiting: o.waiting() });
277
+ };
278
+ call();
279
+ stops.push(voice.subscribe(call));
280
+ refreshCall = call;
281
+ }
282
+ }
283
+ };
284
+ let refreshCall = () => {};
285
+
286
+ client
287
+ .readDefinition(uri)
288
+ .then((raw) => {
289
+ if (!alive || !raw) return;
290
+ const d = raw as UnoverseDefinition & { defaultLayout?: string; layouts?: Record<string, unknown>; sections?: Record<string, Section[]>; binding?: Binding; stateTree?: StateTree };
291
+ if (d.binding?.workflow) o.onBinding(uri, d.binding);
292
+ def = d;
293
+ const base = d.defaultLayout ?? (d.layouts ? Object.keys(d.layouts)[0] : undefined);
294
+ parts = [];
295
+ for (const sec of (base && d.sections?.[base]) || []) {
296
+ const refs = sec.ref ? [sec.ref] : sec.kind === "director" ? (sec.preview ?? []) : [];
297
+ refs.forEach((ref, i) => {
298
+ const nodeId = `${sec.face}~${ref}~${i}`;
299
+ parts.push(nodeId);
300
+ store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId, component: { type: ref, props: sec.seeds?.[ref] ?? {} } });
301
+ if (sec.face) store.mergeComponentState(CHAT, nodeId, { state: sec.face });
302
+ });
303
+ }
304
+ const ref = uri.replace(/^unoverse:\/\/templates\//, "");
305
+ store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId: TEMPLATE_NODE, component: { type: ref, componentUrl: uri, props: {} } });
306
+ if (base) store.mergeComponentState(CHAT, TEMPLATE_NODE, { state: base });
307
+ if ((d.stateTree ?? []).some((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)))) avatars.add(el);
308
+ ready();
309
+ })
310
+ .catch((e) => console.warn(`[unoverse] page template "${uri}" failed to load:`, e));
311
+
312
+ return {
313
+ /** Something this template shows from outside it changed (whether a task waits). */
314
+ refresh: () => refreshCall(),
315
+ destroy() {
316
+ alive = false;
317
+ stops.forEach((s) => s());
318
+ comp?.destroy();
319
+ comp?.nodes.forEach((n) => n.parentNode?.removeChild(n));
320
+ },
321
+ };
322
+ }
323
+
324
+ export interface PageInterfacesOptions {
325
+ org: string;
326
+ client: DefinitionSource;
327
+ voice: VoiceService;
328
+ appStore: ComponentStore;
329
+ toServer: (t: string, data: Record<string, unknown>, meta?: ActionMeta) => void;
330
+ startTask: (binding: Binding, task: string, submit?: Record<string, unknown>) => void;
331
+ }
332
+
333
+ /**
334
+ * ONE APP STATE FOR THE WHOLE PAGE. Each template keeps its own slice in its own store, and all
335
+ * of them read and write the app's state in `appStore`, the store the call and the workflow
336
+ * write to. A name one template saves is a name every other template can show.
337
+ */
338
+ export function mountPageInterfaces(o: PageInterfacesOptions): { destroy(): void } {
339
+ const doc = pageDocument();
340
+ if (!doc) return { destroy() {} };
341
+ const win = doc.defaultView;
342
+ const dock = new Dock(doc);
343
+ const mounted = new Map<HTMLElement, ReturnType<typeof mountPageTemplate>>();
344
+ // Each template's own workflow, as it loads; a template without one is never called.
345
+ const bindings: Record<string, Binding> = {};
346
+ // The tasks this page has started. Each starts once: a second press starts nothing.
347
+ const started = new Set<string>();
348
+ // The task in view, by its URI, measured again as the page scrolls or resizes.
349
+ let inView: string | null = null;
350
+
351
+ // THE AVATAR STARTS THE TASK'S WORKFLOW (UNOVERSE_HOST_PAGE.md rule 4), like any button. A task
352
+ // in view with a workflow, not already running, is WAITING: a press starts it at the
353
+ // template's trigger. Nothing in view, or nothing bound: nothing.
354
+ const target = () => (inView && bindings[inView]?.workflow ? inView : null);
355
+ const waiting = () => {
356
+ const t = target();
357
+ return !!t && !started.has(t);
358
+ };
359
+ const refreshAll = () => mounted.forEach((m) => m.refresh());
360
+
361
+ // A TEMPLATE'S OWN ACTIONS go where the app's go: its `submit` answers whoever opened the task;
362
+ // a template with its own workflow also has that workflow called with the answers (the
363
+ // task's last call, design/tasks.md "The submit"). The call's three are the page's.
364
+ const onCallFor = (uri: string): ActionHandler => (spec, data, meta) => {
365
+ const action = typeof spec === "string" ? spec : spec?.type;
366
+ if (action === "submit" || action === "cancel") {
367
+ o.toServer(action, data, meta);
368
+ const state = (meta?.state ?? data) as Record<string, unknown> | undefined;
369
+ if (action === "submit" && bindings[uri]?.workflow && state && typeof state === "object") o.startTask(bindings[uri], uri, state);
370
+ } else if (action === "startCall") {
371
+ const t = target();
372
+ if (!t || started.has(t)) return;
373
+ started.add(t);
374
+ refreshAll();
375
+ // The press is the click the browser needs before it plays a voice the workflow may have.
376
+ o.voice.unlock();
377
+ o.startTask(bindings[t], t);
378
+ } else if (action === "endCall") void o.voice.endCall();
379
+ else if (action === "toggleMute") o.voice.toggleMute();
380
+ // Another app on the page, opened or closed by its `<org>/<app>` (its own tag, EMBED_CLIENT.md).
381
+ else if (action === "toggleApp") {
382
+ const app = (data as { app?: unknown } | undefined)?.app;
383
+ if (typeof app === "string") (win as (Window & { unoverseApps?: Record<string, { toggle(): void }> }) | null)?.unoverseApps?.[app]?.toggle();
384
+ }
385
+ };
386
+
387
+ const measure = () => {
388
+ const name = taskInView(doc)?.getAttribute(MARK);
389
+ const next = name ? uriOf(name) : null;
390
+ if (next === inView) return;
391
+ inView = next;
392
+ refreshAll();
393
+ };
394
+ const scan = () => {
395
+ // Only this app's own org's templates: another app on the page draws its own.
396
+ const found = [...doc.querySelectorAll<HTMLElement>(`[${MARK}]`)].filter((el) => el.getAttribute(MARK)?.startsWith(`${o.org}/`));
397
+ for (const [el, m] of mounted)
398
+ if (!found.includes(el)) {
399
+ m.destroy();
400
+ mounted.delete(el);
401
+ }
402
+ for (const el of found) {
403
+ if (mounted.has(el)) continue;
404
+ const name = el.getAttribute(MARK) ?? "";
405
+ if (!name) continue;
406
+ const uri = uriOf(name);
407
+ mounted.set(
408
+ el,
409
+ mountPageTemplate({
410
+ client: o.client,
411
+ uri,
412
+ el,
413
+ voice: o.voice,
414
+ appStore: o.appStore,
415
+ dock,
416
+ waiting,
417
+ onBinding: (u, b) => {
418
+ if (bindings[u]) return;
419
+ bindings[u] = b;
420
+ refreshAll();
421
+ },
422
+ onCall: onCallFor(uri),
423
+ }),
424
+ );
425
+ }
426
+ measure();
427
+ };
428
+ scan();
429
+ // The page may render its parts after this runtime starts (a client-rendered page), so it
430
+ // keeps looking; only a change in the marked set mounts or drops a template.
431
+ const watch = new MutationObserver(scan);
432
+ watch.observe(doc.body, { childList: true, subtree: true });
433
+ // A press inside a marked template makes it the one the keys move; outside every template
434
+ // the keys go back to the one most in view.
435
+ const onPress = (e: Event) => {
436
+ engaged = (e.composedPath().find((n) => n instanceof HTMLElement && n.hasAttribute(MARK)) as HTMLElement | undefined) ?? null;
437
+ };
438
+ doc.addEventListener("pointerdown", onPress, true);
439
+ win?.addEventListener("scroll", measure, { passive: true });
440
+ win?.addEventListener("resize", measure);
441
+
442
+ return {
443
+ destroy() {
444
+ watch.disconnect();
445
+ doc.removeEventListener("pointerdown", onPress, true);
446
+ win?.removeEventListener("scroll", measure);
447
+ win?.removeEventListener("resize", measure);
448
+ mounted.forEach((m) => m.destroy());
449
+ mounted.clear();
450
+ },
451
+ };
452
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * THE RESPONSE VIEW, in plain DOM: what an UNNAMED result draws (THE NAMING RULE,
3
+ * docs/mcp/UNOVERSE_MCP_APP_PROTOCOL § THE NAMING RULE).
4
+ *
5
+ * An MCP Action's result carries `interface: { kind: "components", components }`, the
6
+ * component events the run produced. This view draws EXACTLY those: seed a fresh store with
7
+ * the events, resolve the theme, draw each instance with the standard component. No app
8
+ * mount, no home state, no auto-trigger: an app mounts only when something NAMES it.
9
+ *
10
+ * Definitions and theme resolve over the result's serverUrl (`/mcp` reads): the response is
11
+ * data; the vocabulary lives on the universe.
12
+ */
13
+ import { UnoverseClient } from "./lib/core/client";
14
+ import { ComponentStore } from "./lib/core/store";
15
+ import { applyServerMessage } from "./lib/core/connection";
16
+ import { mountComponent, type ComponentMount } from "./lib/dom/component";
17
+ import { openConnection, type UnoverseConnection } from "./lib/dom/connection";
18
+ import { setChildren } from "./lib/dom/element";
19
+ import { fetchTheme } from "./lib/dom/theme";
20
+ import type { ResolvedTheme } from "./lib/core/types";
21
+ import { handAnswerToHost, hostMode, onDisplayMode, showAs } from "./mcpAppBridge";
22
+
23
+ export interface ResponseViewOptions {
24
+ serverUrl: string;
25
+ /** The run's component events (COMPONENT_INIT / COMPONENT_DATA), as collected server-side
26
+ * and carried in the result's `interface.components`. */
27
+ events: Record<string, unknown>[];
28
+ /** THE LIVE PAGE: with the caller's identity and a page handle, the view listens on
29
+ * /stream under that page's conversation, so a later `-update` call (which opens no
30
+ * window) hydrates THESE instances in place. docs/mcp/MCP_2026-07-28.md §3a. */
31
+ live?: { userId: string; conversationId: string; token?: string };
32
+ /** The client reading definitions; one per server by default. */
33
+ client?: UnoverseClient;
34
+ }
35
+
36
+ export interface ResponseView {
37
+ destroy(): void;
38
+ }
39
+
40
+ /** Draw a result's components into `el`. */
41
+ export function mountResponseView(el: HTMLElement, o: ResponseViewOptions): ResponseView {
42
+ const doc = el.ownerDocument;
43
+ const client = o.client ?? new UnoverseClient(`${o.serverUrl}/mcp`, {});
44
+
45
+ // Seed with the same pure mapper the live stream uses, so a seeded component is
46
+ // indistinguishable from a streamed one (merge-not-replace and all).
47
+ const store = new ComponentStore();
48
+ const seen = new Set<string>();
49
+ for (const e of o.events) {
50
+ try {
51
+ applyServerMessage(store, e as never, seen);
52
+ } catch {
53
+ /* one malformed event must not blank the whole response */
54
+ }
55
+ }
56
+
57
+ // One card per INSTANCE, in arrival order. A PART LIVES IN ITS PAGE: the page's slot draws
58
+ // it from the store, so it never gets a card of its own here.
59
+ const instances: { chatId: string; nodeId: string }[] = [];
60
+ const keys = new Set<string>();
61
+ for (const e of o.events) {
62
+ const ev = e as { type?: string; chatId?: string; nodeId?: string; part?: boolean };
63
+ if (ev.type !== "COMPONENT_INIT" || !ev.chatId || !ev.nodeId || ev.part) continue;
64
+ const key = `${ev.chatId}:${ev.nodeId}`;
65
+ if (keys.has(key)) continue;
66
+ keys.add(key);
67
+ instances.push({ chatId: ev.chatId, nodeId: ev.nodeId });
68
+ }
69
+
70
+ // Hydrate in place: pushes for this page's conversation merge into the seeded store.
71
+ const token = o.live?.token;
72
+ const conn: UnoverseConnection | null =
73
+ o.live?.userId && o.live?.conversationId
74
+ ? openConnection(
75
+ { apiUrl: o.serverUrl, streamUrl: o.serverUrl, getAccessToken: token ? async () => token : undefined },
76
+ { workflowId: "", targetTriggerNode: "", userId: o.live.userId, conversationId: o.live.conversationId, chatId: o.live.conversationId },
77
+ store,
78
+ )
79
+ : null;
80
+
81
+ // THE HOST'S DISPLAY MODE IS THE TASK'S STATE (MCP Apps, Display Modes: the host decides
82
+ // the mode and tells the window each change, `host-context-changed` `displayMode`).
83
+ // 1. When the window connects, the task tells the host where it wants to be.
84
+ // 2. After that the host's mode is the truth: in the chat the task shows its `inline`
85
+ // face; in the panel it shows open, in the face it last had open.
86
+ // 3. Our own changes (a chip tapped, a ✕, a push from the server) ask the host for the
87
+ // mode their state needs, and the host's answer settles it.
88
+ // Outside an MCP Apps host there is no mode, and none of this runs.
89
+ const stateOf = (i: { chatId: string; nodeId: string }) => {
90
+ const s = store.get(i.chatId, i.nodeId) as Record<string, unknown> | undefined;
91
+ const view = s?.state ?? s?.initialView;
92
+ return typeof view === "string" ? view : undefined;
93
+ };
94
+ // The face each task last had open, to show again when the host opens it.
95
+ const openFace = new Map<string, string>();
96
+ const faceToOpen = (i: { chatId: string; nodeId: string }) => {
97
+ const last = openFace.get(`${i.chatId}:${i.nodeId}`);
98
+ if (last) return last;
99
+ const menu = (store.get(i.chatId, i.nodeId) as { publicStates?: unknown } | undefined)?.publicStates;
100
+ return Array.isArray(menu) ? (menu.find((v) => typeof v === "string" && v !== "inline") as string | undefined) : undefined;
101
+ };
102
+ const setState = (i: { chatId: string; nodeId: string }, state: string) =>
103
+ store.apply({ type: "COMPONENT_DATA", chatId: i.chatId, nodeId: i.nodeId, data: { state } } as never);
104
+ /** The host's mode, applied to the task: in the chat its `inline` face, in the panel open. */
105
+ const follow = (mode: string) => {
106
+ for (const i of instances) {
107
+ const v = stateOf(i);
108
+ if (mode !== "fullscreen" && v !== undefined && v !== "inline") setState(i, "inline");
109
+ if (mode === "fullscreen" && v === "inline") {
110
+ const face = faceToOpen(i);
111
+ if (face) setState(i, face);
112
+ }
113
+ }
114
+ };
115
+ /** Ask the host for the mode the task's state needs; however the ask ends (granted,
116
+ * refused, failed, nothing to ask), the task then takes the mode the host is showing. */
117
+ const ask = () => {
118
+ const view = instances.map(stateOf).find((v) => v !== undefined);
119
+ if (view === undefined || hostMode() === null) return;
120
+ void showAs(view).finally(() => {
121
+ const mode = hostMode();
122
+ if (mode !== null) follow(mode);
123
+ });
124
+ };
125
+ let told = false;
126
+ const stopFollow = store.subscribe(() => {
127
+ for (const i of instances) {
128
+ const v = stateOf(i);
129
+ if (v && v !== "inline") openFace.set(`${i.chatId}:${i.nodeId}`, v);
130
+ }
131
+ if (told) ask();
132
+ });
133
+ const stopMode = onDisplayMode((mode) => {
134
+ if (hostMode() === null) return;
135
+ if (!told) {
136
+ told = true;
137
+ return ask();
138
+ }
139
+ follow(mode);
140
+ });
141
+
142
+ // Fills the panel, fits its content in the chat (the bridge sizes the page by display
143
+ // mode). No padding: the component's own edge is the window's edge.
144
+ const box = doc.createElement("div");
145
+ Object.assign(box.style, { display: "flex", flexDirection: "column", gap: "12px", boxSizing: "border-box", height: "100%" });
146
+ const cards: ComponentMount[] = [];
147
+ const wrappers: HTMLDivElement[] = [];
148
+ let alive = true;
149
+
150
+ const draw = (theme: ResolvedTheme | undefined) => {
151
+ if (!alive) return;
152
+ instances.forEach((i, n) => {
153
+ const opts = {
154
+ client,
155
+ store,
156
+ chatId: i.chatId,
157
+ nodeId: i.nodeId,
158
+ theme,
159
+ doc,
160
+ // Steps run locally (`setValue` into this instance's slice). A SUBMIT inside an
161
+ // outside host goes back to the host's model: no server call waits for it there.
162
+ onAction: (action: unknown, data: Record<string, unknown>, meta?: { state?: Record<string, unknown> }) => {
163
+ const t = typeof action === "string" ? action : (action as { type?: string })?.type;
164
+ if (t === "submit") handAnswerToHost((meta?.state ?? data) as Record<string, unknown>);
165
+ },
166
+ };
167
+ if (cards[n]) return cards[n].update(opts as never);
168
+ const w = doc.createElement("div");
169
+ Object.assign(w.style, { flex: "1", minHeight: "0", display: "flex", flexDirection: "column" });
170
+ cards[n] = mountComponent(opts as never);
171
+ wrappers[n] = w;
172
+ setChildren(w, cards[n].nodes);
173
+ });
174
+ setChildren(box, wrappers);
175
+ };
176
+
177
+ if (instances.length) {
178
+ el.appendChild(box);
179
+ draw(undefined);
180
+ fetchTheme(client, "light").then(draw, () => {});
181
+ }
182
+
183
+ return {
184
+ destroy() {
185
+ alive = false;
186
+ stopFollow();
187
+ stopMode();
188
+ conn?.close();
189
+ cards.forEach((c) => c.destroy());
190
+ box.remove();
191
+ },
192
+ };
193
+ }
package/vite.config.ts CHANGED
@@ -66,8 +66,8 @@ export default defineConfig({
66
66
  external: ["@unoverse-platform/plugin-base", "@unoverse-platform/marketplace"],
67
67
  },
68
68
  optimizeDeps: {
69
- // The SDK pulls in the MCP SDK, whose ajv-provider does `import Ajv from "ajv"`.
70
- // ajv is CJS with no default ESM export, so pre-bundle for the interop shim.
71
- include: ["ajv", "@modelcontextprotocol/sdk/client/index.js", "@modelcontextprotocol/sdk/client/streamableHttp.js"],
69
+ // MCP SDK v2's client (Studio's host and the vendored webSDK) picks its bundled
70
+ // CfWorker validator in a browser, so it no longer pulls ajv in.
71
+ include: ["@modelcontextprotocol/client"],
72
72
  },
73
73
  });