@unoverse-platform/studio 0.1.102 → 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 -393
  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 -383
  79. package/vendor/sdk/responseView.tsx +0 -82
@@ -1,730 +0,0 @@
1
- /**
2
- * <StreamedUnoverseApp> — renders a TEMPLATE definition (the layout) and
3
- * fills its ComponentSlots from the store's timeline.
4
- *
5
- * A template owns nothing (UNOVERSE_SPEC.md §2e-0): it reads the shared state
6
- * and arranges what it finds. This component is that arrangement —
7
- * - reads the template recipe by URI (resources/read, via the client),
8
- * - subscribes to the store so it re-renders as components stream in,
9
- * - resolves each ComponentSlot against the timeline and renders the matched
10
- * leaves with the SAME UnoverseComponent used everywhere else (store-backed).
11
- *
12
- * This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
13
- * chat primitives (Conversation/Input/…) come in later slices.
14
- */
15
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
16
- import {
17
- collectSurfacedViews,
18
- cssWidth,
19
- placeWidthStyle,
20
- dispatchAction,
21
- formatRelative,
22
- isViewField,
23
- mountAliases,
24
- orderedClaims,
25
- propDefaults,
26
- resolveActiveLayout,
27
- resolveActiveView,
28
- selectPointers,
29
- viewOf,
30
- type ComponentStore,
31
- type ResolvedTheme,
32
- type UnoverseClient,
33
- type UnoverseDefinition,
34
- type UnoverseNode,
35
- } from "./core";
36
- import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
37
-
38
- // The pure template derivations moved to core/apps.ts (the portable brain —
39
- // framework-agnostic, guarded by sdk-core-portable.test.ts). Re-exported here so
40
- // existing consumers keep their import path.
41
- export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
42
- import { UnoverseComponent } from "./UnoverseComponent";
43
- import { MountArrangement } from "./transition/index";
44
- import { guestHeldPointers } from "./transition/guestHold";
45
-
46
- /**
47
- * FLIP wrapper for a multi-occupant slot: when arrivals reorder the list (a refreshed
48
- * card floating to the front, a new card pushing the rest along), the existing cards
49
- * SLIDE to their new position instead of jumping. Generic list behavior, no styling
50
- * owned: each child sits in an unstyled measuring div; on every commit the previous
51
- * rect is compared by key and the delta is played back as a transform that eases to
52
- * identity. A key seen for the first time animates nothing (arrivals appear in place).
53
- */
54
- function FlipList({ items }: { items: { key: string; node: ReactNode }[] }) {
55
- const positions = useRef(new Map<string, { left: number; top: number }>());
56
- const order = useRef("");
57
- const els = useRef(new Map<string, HTMLDivElement>());
58
- useLayoutEffect(() => {
59
- // ANIMATE ONLY A REORDER. The template re-renders for plenty of reasons that are not
60
- // this list (typing writes the draft into template state every keystroke), and chrome
61
- // above the list growing or the page scrolling shifts every measured position a
62
- // little — playing those back made the cards wobble while the guest typed. Order is
63
- // the thing this animates, so unchanged order = remeasure silently and do nothing.
64
- // Positions are layout-relative (offsetLeft/Top), not viewport-relative, for the
65
- // same reason: scroll must not read as movement.
66
- const nextOrder = items.map((i) => i.key).join(" ");
67
- // The guest's OS-level reduce-motion setting wins outright: reorders land instantly.
68
- const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
69
- const moved = !reduced && order.current !== "" && order.current !== nextOrder;
70
- const next = new Map<string, { left: number; top: number }>();
71
- for (const [k, el] of els.current) {
72
- const pos = { left: el.offsetLeft, top: el.offsetTop };
73
- next.set(k, pos);
74
- const prev = positions.current.get(k);
75
- const dx = prev && moved ? prev.left - pos.left : 0;
76
- const dy = prev && moved ? prev.top - pos.top : 0;
77
- if (dx || dy) {
78
- el.style.transition = "none";
79
- el.style.transform = `translate(${dx}px, ${dy}px)`;
80
- requestAnimationFrame(() => {
81
- el.style.transition = "transform 240ms ease";
82
- el.style.transform = "";
83
- });
84
- }
85
- }
86
- positions.current = next;
87
- order.current = nextOrder;
88
- });
89
- return (
90
- <>
91
- {items.map(({ key, node }) => (
92
- <div
93
- key={key}
94
- ref={(el) => {
95
- if (el) els.current.set(key, el);
96
- else els.current.delete(key);
97
- }}
98
- >
99
- {node}
100
- </div>
101
- ))}
102
- </>
103
- );
104
- }
105
- import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
106
- import { IsolatedRoot } from "./isolate";
107
- import { useVoiceService, type UseVoiceServiceConfig, type VoiceService } from "./voice";
108
-
109
- export interface StreamedUnoverseAppProps {
110
- client: UnoverseClient;
111
- store: ComponentStore;
112
- /** e.g. "unoverse://apps/acme/KeyService/definition" */
113
- uri: string;
114
- onAction?: ActionHandler;
115
- theme?: ResolvedTheme;
116
- /** Channel overrides for the template's declared props (e.g. per-tenant logoUrl/brandName).
117
- * Merged OVER the definition's own `props` defaults into the root data scope. */
118
- props?: Record<string, unknown>;
119
- /** Render inside a Shadow DOM for full CSS isolation (default true). See UnoverseComponent. */
120
- isolate?: boolean;
121
- /** DEV CHROME ON A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): a host-supplied render
122
- * hook drawn ABOVE a Container's content — Studio uses it for per-container state
123
- * tabs. The SDK stays styleless: it hands the facts (name, the container's states,
124
- * the held interface's active state, a setter) and draws whatever comes back. */
125
- templateChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
126
- /** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
127
- * `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
128
- * token/session) it already holds; the renderer OWNS the service so every host is
129
- * identical. Absent → the service is inert (no mic/WS ever opens). This is the ONE
130
- * shared home for the voice buttons: `startCall`/`endCall`/`toggleMute` are answered
131
- * HERE, not hand-wired per host (that drift is what broke the demo's Start Call). */
132
- voice?: UseVoiceServiceConfig;
133
- /** The voice service itself, when the host already runs one to share with the page's own
134
- * templates (appEngine). Given, the renderer uses it instead of starting a second. */
135
- voiceService?: VoiceService;
136
- }
137
-
138
- /** Inert voice config for non-voice templates — the hook is called unconditionally
139
- * (rules of hooks) but nothing opens until a `startCall` this template never fires.
140
- * No `store` → not a producer, so it writes no callState into ordinary templates. */
141
- export const INERT_VOICE: UseVoiceServiceConfig = {
142
- apiUrl: "",
143
- session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
144
- };
145
-
146
- /** Every pointer a mounted template layout's slots currently select — its occupancy. */
147
- function collectSlotPointers(node: unknown, store: ComponentStore, scope?: Set<string>): string[] {
148
- const out: string[] = [];
149
- const walk = (n: unknown): void => {
150
- if (!n || typeof n !== "object") return;
151
- const un = n as UnoverseNode & { children?: unknown[]; item?: unknown; frame?: unknown; cases?: Record<string, unknown> };
152
- if (un.type === "ComponentSlot") out.push(...selectPointers(store, un, scope));
153
- (un.children ?? []).forEach(walk);
154
- if (un.item) walk(un.item);
155
- if (un.frame) walk(un.frame);
156
- if (un.cases) Object.values(un.cases).forEach(walk);
157
- };
158
- walk(node);
159
- return [...new Set(out)];
160
- }
161
-
162
- export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, voiceService, templateChrome }: StreamedUnoverseAppProps) {
163
- const [def, setDef] = useState<UnoverseDefinition | null>(null);
164
- const [error, setError] = useState<string | null>(null);
165
- // MOUNTED TEMPLATES: an app entry with `use:` names a template definition; it is
166
- // fetched once per app def and its own machinery runs inside the Template place.
167
- const [mounted, setMounted] = useState<Record<string, UnoverseDefinition>>({});
168
- // The voice native service lives HERE (shared), not in each host. Called every render
169
- // (rules of hooks); inert until a `startCall` only a voice template has a button for.
170
- // The audio WS lane travels WITH the SDK: derive it from the client's own server origin
171
- // (same server that hosts /mcp + /stream + /ws/gravity), NEVER from the host — a host's
172
- // `location.origin` is "null" in a sandboxed srcdoc iframe. Overrides any host wsUrl.
173
- const own = useVoiceService(
174
- voiceConfig && !voiceService ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
175
- );
176
- const voice = voiceService ?? own;
177
- // No theme passed → fetch from the server (the SDK owns zero token values). The
178
- // theme REF comes from the definition itself: a template belongs to a client org,
179
- // and that org names its theme — `<org>/light`. Wait for the def before fetching
180
- // (null = no fetch) so the wrong theme never flashes.
181
- const fetched = useUnoverseTheme(client, themeProp ? null : def ? (def.org ? `${def.org}/light` : "light") : null);
182
- const theme = themeProp ?? fetched;
183
- // Register the theme's webfonts (served DATA) at document level — see ensureFontStylesheets.
184
- useEffect(() => ensureFontStylesheets(theme), [theme]);
185
-
186
- // Re-render on STRUCTURE changes only (turns placed/completed, template state,
187
- // lifecycle). COMPONENT_DATA merges don't move the timeline — the leaves subscribe
188
- // to their own slices — so streaming chunks never re-walk the whole template tree.
189
- // Stable subscribe fn: an inline arrow would tear down + re-create the subscription
190
- // every render.
191
- const structureVersion = useSyncExternalStore(
192
- useCallback((cb) => store.subscribe(cb), [store]),
193
- useCallback(() => store.getStructureVersion(), [store]),
194
- );
195
-
196
- useEffect(() => {
197
- let alive = true;
198
- setDef(null);
199
- setError(null);
200
- setMounted({});
201
- client
202
- .readDefinition(uri)
203
- .then((d) => {
204
- if (!alive) return;
205
- setDef(d);
206
- const entries = ((d as { templates?: Record<string, { template?: string }> }).templates ?? {});
207
- for (const entry of Object.values(entries)) {
208
- const use = entry?.template;
209
- if (!use) continue;
210
- // A failed fetch may be a transient (dev-server restart mid-selection):
211
- // retry once, and never fail SILENTLY — an empty mount with no console
212
- // trace cost a debugging session (2026-08-28).
213
- const turi = use.includes("://") ? use : `unoverse://templates/${use}`;
214
- client
215
- .readDefinition(turi)
216
- .catch(() => new Promise((r) => setTimeout(r, 1500)).then(() => client.readDefinition(turi)))
217
- .then((t) => alive && t && setMounted((m) => ({ ...m, [use]: t })))
218
- .catch((e) => console.warn(`[unoverse] mounted template "${use}" failed to load:`, e));
219
- }
220
- })
221
- .catch((e) => alive && setError(String(e?.message ?? e)));
222
- return () => {
223
- alive = false;
224
- };
225
- }, [client, uri]);
226
-
227
- // A PAGE THAT STREAMED IN is on screen exactly like a mounted one, and claims exactly
228
- // as much — but the app never named it, so nothing has fetched its definition. Its
229
- // ADDRESS says what it is (`unoverse://templates/…`, §9c), which is all the SDK needs
230
- // to learn what it claims. Fetched once per instance, into the same map the mounts use.
231
- useEffect(() => {
232
- let alive = true;
233
- for (const p of store.getResponses().flatMap((r) => r.components)) {
234
- const { chatId: c, nodeId: n } = store.split(p);
235
- const url = store.getComponentUrl(c, n);
236
- if (!url || !url.includes("://templates/")) continue;
237
- const key = url.replace(/^unoverse:\/\/templates\//, "");
238
- if (mounted[key]) continue;
239
- client
240
- .readDefinition(url)
241
- .then((t) => alive && t && setMounted((m) => (m[key] ? m : { ...m, [key]: t })))
242
- .catch((e) => console.warn(`[unoverse] streamed template "${key}" failed to load:`, e));
243
- }
244
- return () => {
245
- alive = false;
246
- };
247
- }, [client, store, structureVersion, mounted]);
248
-
249
-
250
- // ARRIVAL PLACEMENT (STATE_MODEL v2 §5 rule 3): nobody sends a state — the HOST
251
- // places the actor at spawn. For each arrived component whose slice has no view yet:
252
- // 1. the component's DECLARED INITIAL, if this template has a state for it (the
253
- // component's preference stands whenever the host can honor it);
254
- // 2. else the SCAN — the first name in THIS template's declared order that the
255
- // component's public menu (`publicStates`, server-injected at init) also has;
256
- // 3. no overlap at all → leave unset: the component wakes in its own initial and
257
- // rides inline (rule 6).
258
- // The write is a data merge (never an interaction touch — placement is not the
259
- // guest's hand), it happens ONCE per instance (a placed slice has a view), and the
260
- // component owns the axis from then on: the template never writes it again.
261
- useEffect(() => {
262
- // An app with places is drawn by the screen document: nothing is placed by a scan.
263
- if (def?.places?.length) return;
264
- const order = def?.stateOrder;
265
- if (!order?.length) return;
266
- for (const turn of store.getResponses())
267
- for (const p of turn.components) {
268
- const { chatId, nodeId } = store.split(p);
269
- const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
270
- if (!slice || viewOf(slice) !== undefined) continue;
271
- const menu = Array.isArray(slice.publicStates) ? (slice.publicStates as unknown[]) : undefined;
272
- if (!menu) continue;
273
- const initial = typeof slice.initialView === "string" ? slice.initialView : undefined;
274
- const face = initial && order.includes(initial) ? initial : order.find((s) => menu.includes(s));
275
- if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { state: face } });
276
- }
277
- }, [def, store, structureVersion]);
278
-
279
- // STATE OWNS LAYOUT (STATE_MODEL v2): the tree this template presents — the active
280
- // state's layout (multi-layout templates), else the classic root. The resolved node
281
- // is a stable per-def object, so downstream memos key off it.
282
- const activeRoot = def ? resolveActiveLayout(def, store) : undefined;
283
-
284
- // The CLAIM set — the states this template reacts to. v2: the declared order
285
- // (stateOrder) IS the claim list; legacy defs fall back to layout names + surface
286
- // claims. The timeline uses it to keep a claimed instance out of the flow (one
287
- // instance → one placeholder).
288
- const surfacedViews = useMemo(() => {
289
- const views = new Set<string>(orderedClaims(def));
290
- if (activeRoot) collectSurfacedViews(activeRoot, views, def?.defs as Record<string, unknown> | undefined);
291
- // A MOUNTED template claims every state it declares (base included): instances
292
- // it holds must not also render in the flow — one instance, one placeholder.
293
- for (const tpl of Object.values(mounted)) {
294
- for (const name of Object.keys(tpl.layouts ?? {})) views.add(name);
295
- // A TEMPLATE CLAIMS WHAT ITS SECTIONS CLAIM, not just its state names. A grid-page
296
- // holds its content in its own states, so the names were enough; a rich page holds
297
- // its content in PARTS — a written hero, a section head, the band's cards — and
298
- // each wears the section's face. Without these the parts are unclaimed at app
299
- // level, so the SDK draws the page in its surface AND every piece of it again in
300
- // the conversation flow (observed live 2026-08-30: the whole page, twice).
301
- for (const secs of Object.values((tpl as { sections?: Record<string, { face?: string }[]> }).sections ?? {}))
302
- for (const sec of secs) if (sec?.face) views.add(sec.face);
303
- }
304
- // `inline` IS NEVER A CLAIM: it is the universal fallback face, drawn in the flow. A
305
- // streamed template's own `inline` layout (its compact chip) marked it claimed, so the
306
- // flow skipped the chip (observed live 2026-09-06).
307
- views.delete("inline");
308
- return views;
309
- }, [activeRoot, def, mounted]);
310
-
311
- // RULE 4: the winner clears the states below it — WHEN A DELIVERY PUT IT THERE.
312
- // An arrival taking a higher rung means the screen has moved on, so stale claims below
313
- // it let go and rule 6's re-walk lands on the base. The guest's own navigation is not
314
- // that: tapping a rail card to open its page must leave the rail's claims intact, or
315
- // closing the page has nothing to return to. Priority still decides what SHOWS in both
316
- // cases; this only decides what gets thrown away. Derived above the loading guards so
317
- // this stays a hook, and off the SAME derivation the render path uses.
318
- const ladder = def ? resolveActiveView(def, store, surfacedViews) : { view: "", byGuest: false };
319
- useEffect(() => {
320
- if (def?.places?.length) return;
321
- if (ladder.view && !ladder.byGuest) {
322
- const order = orderedClaims(def);
323
- store.cancelBelow(ladder.view, order, mountAliases(def, new Set(order)));
324
- }
325
- // `structureVersion`: EVERY arrival re-runs this, not only the ones that change the
326
- // winner. A card landing while a higher state is ALREADY active would otherwise keep
327
- // its claim forever — the walk never re-ran — and closing the higher state dropped
328
- // straight onto it (observed: a rail still there after a focus surface closed, because
329
- // the card's render landed two seconds after the finder took focus). `cancelBelow`
330
- // only bumps when it changed something, so this settles in one extra pass.
331
- }, [ladder.view, ladder.byGuest, def, store, structureVersion]);
332
-
333
-
334
- // Loading/error hints carry NO styling — the SDK owns zero styles.
335
- if (error) return <div>Unoverse error: {error}</div>;
336
- if (!def || !theme) return <div>Loading {uri}…</div>;
337
-
338
- // ONE action interpreter for the whole tree: template chrome runs the same
339
- // `dispatchAction` (core/actions.ts) every leaf runs — `setAppValue` writes
340
- // template state, `then` chains, anything else routes to the channel. Template
341
- // chrome has NO component slice, so `setValue` here is an authoring error (it
342
- // belongs on the component); the empty ids make that write inert.
343
- const dispatch: ActionHandler = (action, data, meta) => {
344
- // VOICE NATIVE-SERVICE actions are answered by the shared service (§2e-1) — never
345
- // forwarded to the host/workflow. This is the single wiring point that makes every
346
- // host behave identically; audio rides the /ws/gravity lane, not the component stream.
347
- if (typeof action === "string") {
348
- if (action === "startCall") return void voice.startCall();
349
- if (action === "endCall") return void voice.endCall();
350
- if (action === "toggleMute") return void voice.toggleMute();
351
- // "back to chat" leaves the voice surface — tear the call down first, then let the
352
- // host handle any navigation it owns (falls through).
353
- if (action === "backToChat") voice.endCall();
354
- }
355
- // GUEST CLOSE from TEMPLATE CHROME: a ✕ on the layout itself (a rail/panel header —
356
- // not inside a single card) fires the CloseButton atom's `setValue view:"inline"`
357
- // (legacy defs write `defaultState`). Template chrome has no card slice to write, so
358
- // that write would be inert; instead it means "un-surface the current view" → the
359
- // store retracts every surface to its declared base (the same reset a new turn
360
- // runs). A card that closes ITSELF keeps using the plain component write (real
361
- // ids), untouched. `then` still chains via dispatchAction below.
362
- if (
363
- action && typeof action === "object" &&
364
- (action as { type?: string }).type === "setValue" &&
365
- (action as { values?: { key?: string; value?: unknown }[] }).values?.some(
366
- (v) => isViewField(v.key) && v.value === "inline",
367
- )
368
- ) {
369
- store.closeSurfaces();
370
- return;
371
- }
372
- dispatchAction(action as Parameters<typeof dispatchAction>[0], (data ?? {}) as Record<string, unknown>, {
373
- store,
374
- chatId: "",
375
- nodeId: "",
376
- sendToServer: onAction ? (t, payload) => onAction(t, payload, meta) : undefined,
377
- });
378
- };
379
-
380
- const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
381
- const { chatId, nodeId } = store.split(pointer);
382
- // Keyed by the INSTANCE (nodeId), not the pointer: the same instance re-delivered in
383
- // a later turn keeps its React identity, so a refresh reconciles in place instead of
384
- // remounting (the remount was the visible flicker). Unique among siblings — a turn's
385
- // nodeIds are unique, and selectPointers dedupes cross-turn copies to one.
386
- return <UnoverseComponent key={nodeId} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
387
- };
388
-
389
- // THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
390
- // the most recently surfaced view ("" = the conversation/welcome base). DERIVED off
391
- // the store, never stored. Exactly one reaction surface renders: the active state's.
392
- //
393
- // PINNED LAYOUTS — the guest-control law: a layout authored `"pinned": true` changes
394
- // ONLY by the guest's hand. While a pinned view is surfaced, background arrivals
395
- // (cards from later searches, streamed renders) cannot displace it (observed live:
396
- // the panel flapped between a card rail and the composing page). It yields to a
397
- // GUEST write anywhere (tap-to-focus, its own ✕), to its component leaving the view,
398
- // or to a template swap. Authored in the layout file — the SDK only enforces it.
399
- // SAME derivation as resolveActiveLayout (resolveActiveView) — the layout on screen
400
- // and the state its surfaces gate against must NEVER come from two different loops.
401
- const surfacedView = resolveActiveView(def, store, surfacedViews).view;
402
-
403
- // ComponentSlot — filter the timeline to matching component pointers (KeyService).
404
- // A FACTORY: a mounted template's slots select within a SCOPE (the current
405
- // delivery — hold: latest, basic form); the app's own slots stay unscoped.
406
- // THE STATE THE SCREEN PLACED IT IN (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.6): a place draws
407
- // each interface in that face, so a guest's tap shows once the screen has placed it, and an open
408
- // task can never draw in the conversation.
409
- const placed = (p: string): Record<string, unknown> | undefined => {
410
- const state = store.placedState(p);
411
- return state ? { state } : undefined;
412
- };
413
- // WHAT A SLOT OR A PLACE HOLDS, drawn: its fallback when empty; else its frame around the
414
- // leaves, a single occupant filling the frame; else the leaves as a list.
415
- const drawPointers = (node: UnoverseNode, pointers: string[], key: React.Key | undefined, fill: boolean): ReactNode => {
416
- if (pointers.length === 0) {
417
- return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
418
- }
419
- // `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
420
- // The frame subtree renders with a slot resolver that serves the outer slot's
421
- // matches to any bare nested ComponentSlot — so a focus overlay is BORN when a
422
- // component enters the state and DIES when it leaves (§5b: derived, not stored).
423
- if (node.frame) {
424
- // A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): a limit-1
425
- // surface (detail panel, focus overlay) gives its occupant the frame's full
426
- // height — the component's `height: "full"` finally has a definite container,
427
- // with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
428
- // Multi-occupant surfaces (a rail) animate reorders; a single occupant fills its
429
- // frame directly — a measuring wrapper there would break the height chain.
430
- const innerSlot: SlotResolver = () =>
431
- fill
432
- ? pointers.map((p) => leaf(p, placed(p), fill))
433
- : <FlipList items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p, placed(p)) }))} />;
434
- // A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
435
- // (computeAppWidth) AND sizes the frame box here — the two can never disagree.
436
- // Named sizes resolve via theme.appSize like any token; unresolvable → no width.
437
- // `flex: 0 0 auto` — a panel is its declared width ALWAYS. A container that
438
- // can't fit the sum clips at the edge; panels never compress into garbage.
439
- // ONE WIDTH LAW (core/apps placeWidthStyle), the same as a template place and
440
- // computeAppWidth. This frame once had its own copy without the `flex` word, and the
441
- // task surface rendered in a zero-width frame beside a full-width app the moment the
442
- // org's flex token went (SAB's comparison, blank, 2026-09-07).
443
- const placeStyle = placeWidthStyle((node as { appWidth?: string }).appWidth, theme.appSize);
444
- const frame = placeStyle ? ({ ...node.frame, style: { ...(node.frame.style ?? {}), ...placeStyle } } as UnoverseNode) : node.frame;
445
- return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
446
- }
447
- return <FlipList key={key ?? "slot"} items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p, placed(p)) }))} />;
448
- };
449
-
450
- const makeResolveSlot = (scope?: Set<string>): SlotResolver => {
451
- const resolveSlot: SlotResolver = (node: UnoverseNode, key?: React.Key): ReactNode => {
452
- // A PLACE draws what the screen document put in it (docs/unoverse/design/screen/UNOVERSE_SCREEN.md).
453
- if (node.type === "Place") return drawPointers(node, [...store.placeItems(String((node as { name?: string }).name ?? ""))], key, node.holds === "one");
454
- // A TEMPLATE PLACE (the container noun folded into templates, 2026-08-28): the
455
- // layout places it by NAME and owns its width; the manifest's `templates:` block
456
- // owns what it holds. An entry with `use:` MOUNTS a template definition (its own
457
- // states, shapes and slots run here over the shared store); an entry with
458
- // states/interface keeps the old holder behavior (a template with one shape).
459
- if (node.type === "Template") {
460
- const cname = (node as { name?: string }).name ?? "";
461
- const spec = (def as { templates?: Record<string, { template?: string; states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
462
- ?.templates?.[cname];
463
- // THE MOUNT: `template:` names a template definition (the holder grammar:
464
- // a place names what it holds); its OWN machinery runs —
465
- // resolveActiveLayout derives its state from the instances' faces, its slots
466
- // claim by face, sub-states stay contained in each instance's slice. EMPTY
467
- // COLLAPSES: nothing held → nothing drawn (frame included) — a place with
468
- // nothing collapses.
469
- if (spec?.template) {
470
- const tpl = mounted[spec.template];
471
- if (!tpl) return null;
472
- // HOLD: LATEST (basic form, UNOVERSE_TEMPLATE_MODEL §9b): the mount holds
473
- // only the CURRENT delivery — spent by the settle or the clear, never the
474
- // guest's send. A judged DIRECTOR_VERDICT narrows it further (§7); no
475
- // verdict, or a verdict holding nothing that survives, falls back to the
476
- // whole delivery.
477
- const active = resolveActiveLayout(tpl, store);
478
- const delivery = store.currentDeliveryPointers();
479
- const dirScope = store.directorScope(delivery);
480
- const tplLayouts = (tpl as { layouts?: Record<string, UnoverseNode> }).layouts ?? {};
481
- // THE GUEST'S HAND OUTRANKS THE SETTLE (§6b rule 3, state priority): an
482
- // instance the guest holds open in a non-base face (Detail View) stays in
483
- // the mount's scope through deliveries and settles — a new batch landing
484
- // below must not unseat it. The hold releases when the guest writes the
485
- // face back to the base (✕), and the CURRENT arrangement takes over.
486
- const layoutNames = Object.keys(tplLayouts);
487
- const baseLayout = (tpl as { defaultLayout?: string }).defaultLayout ?? layoutNames[0] ?? null;
488
- const guestHeld = guestHeldPointers(
489
- store.getResponses().flatMap((r) => r.components),
490
- {
491
- touchOf: (p) => store.touchOf(p),
492
- interactionTouchOf: (p) => store.interactionTouchOf(p),
493
- viewOf: (p) => {
494
- const { chatId, nodeId } = store.split(p);
495
- return viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
496
- },
497
- },
498
- layoutNames,
499
- baseLayout,
500
- );
501
- const scope = guestHeld.length ? new Set([...(dirScope ?? delivery), ...guestHeld]) : (dirScope ?? delivery);
502
- const held = active ? collectSlotPointers(active, store, scope) : [];
503
- // THE TRANSITION MODEL (§13): the mount double-buffers — this render
504
- // derives the NEXT arrangement; MountArrangement commits it only at a
505
- // boundary (verdict change, guest layout change, un-directed mount), so
506
- // a data ripple can never move the shelf, and EMPTY COLLAPSES only when
507
- // an arrangement holding nothing is COMMITTED. Content inside held
508
- // instances stays live: the closure reads slices at render time.
509
- const nextArrangement = {
510
- layoutName: active ? (Object.entries(tplLayouts).find(([, t]) => t === active)?.[0] ?? null) : null,
511
- held,
512
- verdictSig: dirScope ? [...dirScope].sort().join("|") : null,
513
- };
514
- const motion = ((theme as { motion?: { mount?: string } }).motion?.mount ?? "") === "transition";
515
- return (
516
- <MountArrangement
517
- key={key ?? cname}
518
- next={nextArrangement}
519
- directed={
520
- // THE ONE HOME (§9b clause 5): the grant is the template's own
521
- // director SECTION, compiled into the served def's `sections`;
522
- // the placement's `director:` block is the legacy fallback.
523
- Object.values(((tpl as { sections?: Record<string, { kind?: string }[]> }).sections ?? {}))
524
- .flat()
525
- .some((s) => s?.kind === "director") || Boolean((spec as { director?: unknown }).director)
526
- }
527
- motion={motion}
528
- render={(arr) => {
529
- if (!arr.held.length) return null;
530
- const arrActive = (arr.layoutName ? tplLayouts[arr.layoutName] : undefined) ?? active;
531
- if (!arrActive) return null;
532
- const arrScope = new Set(arr.held);
533
- const tree = renderNode(arrActive, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-tpl`, makeResolveSlot(arrScope), tpl.defs);
534
- // A PLACE DECLARES ITS WIDTH ONCE (docs/design/05), same law as a slot
535
- // panel: `appWidth` resolves via theme.appSize and sizes the frame box —
536
- // `flex` is the served "all remaining space" name. Without it the frame's
537
- // own styles decide (and a width-less frame beside a fixed-width chat
538
- // collapsed to its padding — observed live 2026-08-28).
539
- // ONE WIDTH LAW (core/apps placeWidthStyle): a named size or raw CSS sizes the
540
- // place; `flex` with no token is the SDK's own word, fill the remaining host space
541
- // (an unresolved name once collapsed the place to its padding, 2026-08-28).
542
- const placeStyle = placeWidthStyle((node as { appWidth?: string }).appWidth, theme.appSize);
543
- const rawFrame = (node as { frame?: UnoverseNode }).frame;
544
- if (rawFrame) {
545
- const frame = placeStyle ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), ...placeStyle } } as UnoverseNode) : rawFrame;
546
- const innerSlot: SlotResolver = () => tree;
547
- return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
548
- }
549
- return <div style={{ display: "flex", flexDirection: "column", minHeight: 0, ...((placeStyle ?? {}) as React.CSSProperties) }}>{tree}</div>;
550
- }}
551
- />
552
- );
553
- }
554
- const stateNames = spec?.states ? Object.keys(spec.states) : [];
555
- const appState = store.getAppState() as Record<string, unknown>;
556
- const active = (appState[`template:${cname}`] as string | undefined) ?? stateNames[0];
557
- const heldInterface = spec?.states ? spec.states[active ?? ""]?.interface : spec?.interface;
558
- // AN EMPTY STATE HOLDS NOTHING (`closed: {}` is a collapsed drawer): a declared
559
- // container whose active state names no interface renders EMPTY — never a bare
560
- // slot, which would grab every interface in the store and un-collapse the drawer.
561
- const emptyState = Boolean(spec?.states) && !heldInterface;
562
- // (legacy holder path continues below unchanged)
563
- node = {
564
- ...node,
565
- type: "ComponentSlot",
566
- select: heldInterface
567
- ? ({ type: heldInterface, limit: 1 } as never)
568
- : emptyState
569
- ? ({ limit: 0 } as never)
570
- : (node.select ?? {}),
571
- } as UnoverseNode;
572
- if (templateChrome && spec) {
573
- const set = (state: string) => store.mergeAppState({ [`template:${cname}`]: state });
574
- const inner = node;
575
- // THE STATE'S WIDTH: a container state may declare one (a collapsed drawer's slim
576
- // edge). Resolved like every panel width (app-size name, else raw CSS); absent,
577
- // the wrapper hugs the held interface, whose active state owns its size.
578
- const declaredW = spec?.states?.[active ?? ""]?.width;
579
- const stateW = declaredW ? (cssWidth(theme.appSize?.[declaredW] ?? declaredW) ?? declaredW) : undefined;
580
- return (
581
- <div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", ...(stateW ? { width: stateW, flex: "0 0 auto", overflow: "hidden" } : {}) }}>
582
- {templateChrome({ name: cname, states: stateNames, active, set })}
583
- <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>{resolveSlot(inner, `${String(key ?? cname)}-inner`)}</div>
584
- </div>
585
- );
586
- }
587
- }
588
- // DECLARE = REARRANGE, CLAIM = FILL (UNOVERSE_INTERFACE_MODEL §4). A claim on a
589
- // DECLARED reaction state renders only while that view IS the app's active state:
590
- // those surfaces are what the app rearranges around, and they never stack. A claim
591
- // on an UNDECLARED view is a CONTAINER: it fills whenever an interface holds that
592
- // view, independent of every other region — which is how a base arrangement holds
593
- // a list and an open page at once. Until 2026-08-25 the gate applied to every
594
- // claim, so a base layout's claimed regions could never fill at all.
595
- const claim = node.select?.where;
596
- const declaredClaim = typeof claim?.eq === "string" && (def?.stateOrder ?? []).includes(claim.eq);
597
- if (isViewField(claim?.field) && typeof claim?.eq === "string" && declaredClaim && claim.eq !== surfacedView) {
598
- return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
599
- }
600
- return drawPointers(node, selectPointers(store, node, scope), key, node.select?.limit === 1);
601
- };
602
- return resolveSlot;
603
- };
604
- const resolveSlot = makeResolveSlot();
605
-
606
- // Timeline — GENERIC iterator. Walks the timeline and renders the `user` / `assistant`
607
- // DATA sub-tree per turn. Zero chat UX in the SDK: bubbles, alignment, structure all live
608
- // in those sub-trees (design/templates/chatlayout/*.json). The assistant sub-tree's ComponentSlot
609
- // is scoped to THAT turn's components.
610
- const resolveTimeline = (node: UnoverseNode, key?: React.Key): ReactNode => {
611
- // An app with places shows in the conversation exactly what the screen put there.
612
- const inChat = def.places?.length ? new Set(store.placeItems("chat")) : null;
613
- return (
614
- <div key={key} style={styleToCss(node.style, theme)}>
615
- {store.getTimeline().map((turn) => {
616
- const sub = turn.role === "user" ? node.user : node.assistant;
617
- if (!sub) return null;
618
- // One instance → one placeholder (STATE_MODEL §5b). An instance whose active
619
- // VIEW a reaction surface is showing is CLAIMED — the flow (the `inline`
620
- // placeholder) must not render it too, or the same card paints twice. Instances
621
- // in a view no surface claims stay here (the fallback). This is what makes "a
622
- // component lifts into its surface" true instead of double-rendering it inline.
623
- const shown =
624
- turn.role === "assistant"
625
- ? turn.components.filter((p) => {
626
- if (inChat) return inChat.has(p);
627
- const { chatId, nodeId } = store.split(p);
628
- const view = viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
629
- return !view || !surfacedViews.has(view);
630
- })
631
- : [];
632
- // Slot scoped to this turn's UNCLAIMED components (assistant turns only).
633
- const turnSlot: SlotResolver = (slotNode, k) => {
634
- let ptrs = [...shown];
635
- if (slotNode.select?.limit != null) ptrs = ptrs.slice(0, slotNode.select.limit);
636
- if (ptrs.length === 0) return slotNode.fallback ? renderNode(slotNode.fallback, {}, dispatch, theme, k ?? "fb", undefined, def?.defs) : null;
637
- // Pass the turn's streaming state into each component's scope so a streamed
638
- // component (e.g. StreamingText) can stop its own loading dots when complete.
639
- const streaming = turn.role === "assistant" && turn.streamingState === "streaming";
640
- return ptrs.map((p) => leaf(p, { streaming, ...placed(p) }));
641
- };
642
- // Data scope per turn = author's static data (avatarUrl, thinkingText…)
643
- // + NEUTRAL projections of the timeline model. The SDK exposes facts, never
644
- // UX policy: `text` (user message), `streaming` (the turn's streamingState),
645
- // `empty` (no components yet). It does NOT decide "show a thinking indicator" —
646
- // the definition composes that from `streaming` + `empty` (nested visibleWhen =
647
- // AND). Static author data is forwarded verbatim; the SDK interprets none of it.
648
- const time = formatRelative(turn.createdAt);
649
- const data =
650
- turn.role === "user"
651
- ? { ...(node.userData ?? {}), text: turn.text, time }
652
- : {
653
- ...(node.assistantData ?? {}),
654
- time,
655
- streaming: turn.streamingState === "streaming",
656
- // `empty`/`active` count only UNCLAIMED components: a turn whose sole card
657
- // lifted into a surface goes inactive (the definition hides it) rather than
658
- // leaving an empty avatar behind; a still-streaming turn stays active.
659
- empty: shown.length === 0,
660
- // `active` = in progress OR has visible content. A completed-empty turn is
661
- // inactive → the definition hides it (legacy ChatHistoryItem null-returns).
662
- active: turn.streamingState === "streaming" || shown.length > 0,
663
- };
664
- return renderNode(sub, data, dispatch, theme, turn.id, turnSlot, def?.defs);
665
- })}
666
- </div>
667
- );
668
- };
669
-
670
- // One resolver for all store-backed template primitives.
671
- const resolve: SlotResolver = (node, key) =>
672
- node.type === "Timeline" ? resolveTimeline(node, key) : resolveSlot(node, key);
673
-
674
- // Root data scope = the author's declared prop defaults (logoUrl, brandName, suggestions…)
675
- // + channel overrides + NEUTRAL conversation-level facts derived from the store (isEmpty/
676
- // hasMessages). This is what lets the page AROUND the message list (header, welcome screen,
677
- // suggestion cards) bind + show/hide — the whole-template counterpart of the per-turn scope
678
- // Timeline injects. The SDK adds no UX policy: just author content + model projections.
679
- const isEmpty = store.getTimeline().length === 0;
680
- const rootData: Record<string, unknown> = {
681
- ...propDefaults(def.props),
682
- ...((def as { state?: Record<string, unknown> }).state ?? {}),
683
- ...(props ?? {}),
684
- // The active template's OWN state — one generic bag of the DEV's chosen keys (draft,
685
- // openPanel, suggestions data, focusMode, voice call state, …). No per-feature
686
- // projections; the engine knows no key names (UNOVERSE_STATE_MODEL §2).
687
- ...store.getAppState(),
688
- // Conversation-derived facts — READ OFF the timeline / lifecycle, never stored.
689
- isEmpty,
690
- hasMessages: !isEmpty,
691
- surfacedView,
692
- lifecycle: store.getLifecycle(),
693
- isThinking: store.getLifecycle() === "thinking",
694
- isStreaming: store.getLifecycle() === "thinking" || store.getLifecycle() === "streaming",
695
- };
696
-
697
- // Inject the served keyframes ONCE for this render root so any `style.animation` in a
698
- // definition resolves (the SDK animates generically — no per-animation primitive).
699
- // The wrapper applies the SERVED base render settings (theme.root — font-smoothing, etc.):
700
- // `display: contents` keeps it out of layout while its inherited props cascade to the tree.
701
- // The SDK authors only `display: contents` (structure); every value comes from theme.root.
702
- // Isolated → theme.root on the shadow-root container (reliable cascade); else → display:contents.
703
- const keyframes = <style>{keyframesCss(theme)}</style>;
704
- // A layout root never carries `appWidth` (guard-enforced) — panels inside it do.
705
- const rootNode = (activeRoot ?? def.root) as UnoverseNode;
706
- // The template's own shared pieces, threaded into every branch it draws: chrome, a
707
- // slot's fallback, a timeline turn. A reference in any of them resolves against the
708
- // same map rather than rendering nothing.
709
- const tree = renderNode(rootNode, rootData, dispatch, theme, undefined, resolve, def?.defs);
710
- // A template is a SURFACE: its render-root fills the box the host gives it, so a root
711
- // that declares `height: "full"` (DATA) can resolve. This only fills when the host's box
712
- // is definite-height; when the host sizes to content the 100% resolves to content — so a
713
- // content-sized app (fluidHeight:false) still wraps its component. The SDK adds no layout
714
- // policy beyond "a template fills its container"; the template DATA owns everything else.
715
- const surfaceRoot = { ...(theme.root as CSSProperties), height: "100%" };
716
- if (isolate) {
717
- return (
718
- <IsolatedRoot rootStyle={surfaceRoot}>
719
- {keyframes}
720
- {tree}
721
- </IsolatedRoot>
722
- );
723
- }
724
- return (
725
- <>
726
- {keyframes}
727
- <div style={{ display: "contents", ...surfaceRoot }}>{tree}</div>
728
- </>
729
- );
730
- }