@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.
- package/package.json +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -393
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -383
- package/vendor/sdk/responseView.tsx +0 -82
package/vendor/sdk/lib/app.tsx
DELETED
|
@@ -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
|
-
}
|