@unoverse-platform/studio 0.1.80 → 0.1.82
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/local/mcp.ts +3 -3
- package/local/plugin.ts +19 -3
- package/package.json +1 -1
- package/screens/TemplatesLibraryView.tsx +317 -0
- package/screens/TemplatesView.tsx +19 -5
- package/screens/index.ts +3 -0
- package/src/App.tsx +6 -1
- package/vendor/sdk/lib/app.tsx +181 -25
- package/vendor/sdk/lib/connection.tsx +11 -9
- package/vendor/sdk/lib/core/apps.ts +42 -5
- package/vendor/sdk/lib/core/client.ts +15 -12
- package/vendor/sdk/lib/core/connection.ts +7 -0
- package/vendor/sdk/lib/core/store.ts +76 -3
- package/vendor/sdk/lib/core/types.ts +20 -3
- package/vendor/sdk/lib/primitives.tsx +23 -0
- package/vendor/sdk/lib/render.tsx +5 -2
- package/vendor/sdk/lib/style.ts +4 -1
- package/vendor/sdk/main.tsx +26 -3
- package/vendor/sdk/responseView.tsx +82 -0
package/vendor/sdk/lib/app.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
|
|
13
13
|
* chat primitives (Conversation/Input/…) come in later slices.
|
|
14
14
|
*/
|
|
15
|
-
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
15
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
16
16
|
import {
|
|
17
17
|
collectSurfacedViews,
|
|
18
18
|
cssWidth,
|
|
@@ -39,6 +39,66 @@ import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotReso
|
|
|
39
39
|
// existing consumers keep their import path.
|
|
40
40
|
export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
|
|
41
41
|
import { UnoverseComponent } from "./UnoverseComponent";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* FLIP wrapper for a multi-occupant slot: when arrivals reorder the list (a refreshed
|
|
45
|
+
* card floating to the front, a new card pushing the rest along), the existing cards
|
|
46
|
+
* SLIDE to their new position instead of jumping. Generic list behavior, no styling
|
|
47
|
+
* owned: each child sits in an unstyled measuring div; on every commit the previous
|
|
48
|
+
* rect is compared by key and the delta is played back as a transform that eases to
|
|
49
|
+
* identity. A key seen for the first time animates nothing (arrivals appear in place).
|
|
50
|
+
*/
|
|
51
|
+
function FlipList({ items }: { items: { key: string; node: ReactNode }[] }) {
|
|
52
|
+
const positions = useRef(new Map<string, { left: number; top: number }>());
|
|
53
|
+
const order = useRef("");
|
|
54
|
+
const els = useRef(new Map<string, HTMLDivElement>());
|
|
55
|
+
useLayoutEffect(() => {
|
|
56
|
+
// ANIMATE ONLY A REORDER. The template re-renders for plenty of reasons that are not
|
|
57
|
+
// this list (typing writes the draft into template state every keystroke), and chrome
|
|
58
|
+
// above the list growing or the page scrolling shifts every measured position a
|
|
59
|
+
// little — playing those back made the cards wobble while the guest typed. Order is
|
|
60
|
+
// the thing this animates, so unchanged order = remeasure silently and do nothing.
|
|
61
|
+
// Positions are layout-relative (offsetLeft/Top), not viewport-relative, for the
|
|
62
|
+
// same reason: scroll must not read as movement.
|
|
63
|
+
const nextOrder = items.map((i) => i.key).join(" ");
|
|
64
|
+
// The guest's OS-level reduce-motion setting wins outright: reorders land instantly.
|
|
65
|
+
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
66
|
+
const moved = !reduced && order.current !== "" && order.current !== nextOrder;
|
|
67
|
+
const next = new Map<string, { left: number; top: number }>();
|
|
68
|
+
for (const [k, el] of els.current) {
|
|
69
|
+
const pos = { left: el.offsetLeft, top: el.offsetTop };
|
|
70
|
+
next.set(k, pos);
|
|
71
|
+
const prev = positions.current.get(k);
|
|
72
|
+
const dx = prev && moved ? prev.left - pos.left : 0;
|
|
73
|
+
const dy = prev && moved ? prev.top - pos.top : 0;
|
|
74
|
+
if (dx || dy) {
|
|
75
|
+
el.style.transition = "none";
|
|
76
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
el.style.transition = "transform 240ms ease";
|
|
79
|
+
el.style.transform = "";
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
positions.current = next;
|
|
84
|
+
order.current = nextOrder;
|
|
85
|
+
});
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
{items.map(({ key, node }) => (
|
|
89
|
+
<div
|
|
90
|
+
key={key}
|
|
91
|
+
ref={(el) => {
|
|
92
|
+
if (el) els.current.set(key, el);
|
|
93
|
+
else els.current.delete(key);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{node}
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
42
102
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
43
103
|
import { IsolatedRoot } from "./isolate";
|
|
44
104
|
import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
|
|
@@ -59,7 +119,7 @@ export interface StreamedUnoverseAppProps {
|
|
|
59
119
|
* hook drawn ABOVE a Container's content — Studio uses it for per-container state
|
|
60
120
|
* tabs. The SDK stays styleless: it hands the facts (name, the container's states,
|
|
61
121
|
* the held interface's active state, a setter) and draws whatever comes back. */
|
|
62
|
-
|
|
122
|
+
templateChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
|
|
63
123
|
/** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
|
|
64
124
|
* `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
|
|
65
125
|
* token/session) it already holds; the renderer OWNS the service so every host is
|
|
@@ -77,9 +137,28 @@ const INERT_VOICE: UseVoiceServiceConfig = {
|
|
|
77
137
|
session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
|
|
78
138
|
};
|
|
79
139
|
|
|
80
|
-
|
|
140
|
+
/** Every pointer a mounted template layout's slots currently select — its occupancy. */
|
|
141
|
+
function collectSlotPointers(node: unknown, store: ComponentStore, scope?: Set<string>): string[] {
|
|
142
|
+
const out: string[] = [];
|
|
143
|
+
const walk = (n: unknown): void => {
|
|
144
|
+
if (!n || typeof n !== "object") return;
|
|
145
|
+
const un = n as UnoverseNode & { children?: unknown[]; template?: unknown; frame?: unknown; cases?: Record<string, unknown> };
|
|
146
|
+
if (un.type === "ComponentSlot") out.push(...selectPointers(store, un, scope));
|
|
147
|
+
(un.children ?? []).forEach(walk);
|
|
148
|
+
if (un.template) walk(un.template);
|
|
149
|
+
if (un.frame) walk(un.frame);
|
|
150
|
+
if (un.cases) Object.values(un.cases).forEach(walk);
|
|
151
|
+
};
|
|
152
|
+
walk(node);
|
|
153
|
+
return [...new Set(out)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, templateChrome }: StreamedUnoverseAppProps) {
|
|
81
157
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
82
158
|
const [error, setError] = useState<string | null>(null);
|
|
159
|
+
// MOUNTED TEMPLATES: an app entry with `use:` names a template definition; it is
|
|
160
|
+
// fetched once per app def and its own machinery runs inside the Template place.
|
|
161
|
+
const [mounted, setMounted] = useState<Record<string, UnoverseDefinition>>({});
|
|
83
162
|
// The voice native service lives HERE (shared), not in each host. Called every render
|
|
84
163
|
// (rules of hooks); inert until a `startCall` only a voice template has a button for.
|
|
85
164
|
// The audio WS lane travels WITH the SDK: derive it from the client's own server origin
|
|
@@ -111,9 +190,27 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
111
190
|
let alive = true;
|
|
112
191
|
setDef(null);
|
|
113
192
|
setError(null);
|
|
193
|
+
setMounted({});
|
|
114
194
|
client
|
|
115
195
|
.readDefinition(uri)
|
|
116
|
-
.then((d) =>
|
|
196
|
+
.then((d) => {
|
|
197
|
+
if (!alive) return;
|
|
198
|
+
setDef(d);
|
|
199
|
+
const entries = ((d as { templates?: Record<string, { template?: string }> }).templates ?? {});
|
|
200
|
+
for (const entry of Object.values(entries)) {
|
|
201
|
+
const use = entry?.template;
|
|
202
|
+
if (!use) continue;
|
|
203
|
+
// A failed fetch may be a transient (dev-server restart mid-selection):
|
|
204
|
+
// retry once, and never fail SILENTLY — an empty mount with no console
|
|
205
|
+
// trace cost a debugging session (2026-08-28).
|
|
206
|
+
const turi = use.includes("://") ? use : `unoverse://templates/${use}`;
|
|
207
|
+
client
|
|
208
|
+
.readDefinition(turi)
|
|
209
|
+
.catch(() => new Promise((r) => setTimeout(r, 1500)).then(() => client.readDefinition(turi)))
|
|
210
|
+
.then((t) => alive && t && setMounted((m) => ({ ...m, [use]: t })))
|
|
211
|
+
.catch((e) => console.warn(`[unoverse] mounted template "${use}" failed to load:`, e));
|
|
212
|
+
}
|
|
213
|
+
})
|
|
117
214
|
.catch((e) => alive && setError(String(e?.message ?? e)));
|
|
118
215
|
return () => {
|
|
119
216
|
alive = false;
|
|
@@ -159,8 +256,13 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
159
256
|
const surfacedViews = useMemo(() => {
|
|
160
257
|
const views = new Set<string>(orderedClaims(def));
|
|
161
258
|
if (activeRoot) collectSurfacedViews(activeRoot, views, def?.defs as Record<string, unknown> | undefined);
|
|
259
|
+
// A MOUNTED template claims every state it declares (base included): instances
|
|
260
|
+
// it holds must not also render in the flow — one instance, one placeholder.
|
|
261
|
+
for (const tpl of Object.values(mounted)) {
|
|
262
|
+
for (const name of Object.keys(tpl.layouts ?? {})) views.add(name);
|
|
263
|
+
}
|
|
162
264
|
return views;
|
|
163
|
-
}, [activeRoot, def]);
|
|
265
|
+
}, [activeRoot, def, mounted]);
|
|
164
266
|
|
|
165
267
|
// RULE 4: the winner clears the states below it — WHEN A DELIVERY PUT IT THERE.
|
|
166
268
|
// An arrival taking a higher rung means the screen has moved on, so stale claims below
|
|
@@ -274,7 +376,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
274
376
|
|
|
275
377
|
const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
|
|
276
378
|
const { chatId, nodeId } = store.split(pointer);
|
|
277
|
-
|
|
379
|
+
// Keyed by the INSTANCE (nodeId), not the pointer: the same instance re-delivered in
|
|
380
|
+
// a later turn keeps its React identity, so a refresh reconciles in place instead of
|
|
381
|
+
// remounting (the remount was the visible flicker). Unique among siblings — a turn's
|
|
382
|
+
// nodeIds are unique, and selectPointers dedupes cross-turn copies to one.
|
|
383
|
+
return <UnoverseComponent key={nodeId} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
|
|
278
384
|
};
|
|
279
385
|
|
|
280
386
|
// THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
|
|
@@ -292,27 +398,69 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
292
398
|
const surfacedView = resolveActiveView(def, store, surfacedViews).view;
|
|
293
399
|
|
|
294
400
|
// ComponentSlot — filter the timeline to matching component pointers (KeyService).
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
401
|
+
// A FACTORY: a mounted template's slots select within a SCOPE (the current
|
|
402
|
+
// delivery — hold: latest, basic form); the app's own slots stay unscoped.
|
|
403
|
+
const makeResolveSlot = (scope?: Set<string>): SlotResolver => {
|
|
404
|
+
const resolveSlot: SlotResolver = (node: UnoverseNode, key?: React.Key): ReactNode => {
|
|
405
|
+
// A TEMPLATE PLACE (the container noun folded into templates, 2026-08-28): the
|
|
406
|
+
// layout places it by NAME and owns its width; the manifest's `templates:` block
|
|
407
|
+
// owns what it holds. An entry with `use:` MOUNTS a template definition (its own
|
|
408
|
+
// states, shapes and slots run here over the shared store); an entry with
|
|
409
|
+
// states/interface keeps the old holder behavior (a template with one shape).
|
|
410
|
+
if (node.type === "Template") {
|
|
305
411
|
const cname = (node as { name?: string }).name ?? "";
|
|
306
|
-
const spec = (def as {
|
|
307
|
-
?.
|
|
412
|
+
const spec = (def as { templates?: Record<string, { template?: string; states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
|
|
413
|
+
?.templates?.[cname];
|
|
414
|
+
// THE MOUNT: `template:` names a template definition (the holder grammar:
|
|
415
|
+
// a place names what it holds); its OWN machinery runs —
|
|
416
|
+
// resolveActiveLayout derives its state from the instances' faces, its slots
|
|
417
|
+
// claim by face, sub-states stay contained in each instance's slice. EMPTY
|
|
418
|
+
// COLLAPSES: nothing held → nothing drawn (frame included) — a place with
|
|
419
|
+
// nothing collapses.
|
|
420
|
+
if (spec?.template) {
|
|
421
|
+
const tpl = mounted[spec.template];
|
|
422
|
+
const active = tpl ? resolveActiveLayout(tpl, store) : undefined;
|
|
423
|
+
// HOLD: LATEST (basic form, UNOVERSE_TEMPLATE_MODEL §9b): the mount holds
|
|
424
|
+
// only the CURRENT delivery — the guest's send clears the set.
|
|
425
|
+
const delivery = store.currentDeliveryPointers();
|
|
426
|
+
const held = active ? collectSlotPointers(active, store, delivery) : [];
|
|
427
|
+
if (!tpl) return null;
|
|
428
|
+
if (!active) return null;
|
|
429
|
+
if (!held.length) return null;
|
|
430
|
+
const tree = renderNode(active, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-tpl`, makeResolveSlot(delivery), tpl.defs);
|
|
431
|
+
// A PLACE DECLARES ITS WIDTH ONCE (docs/design/05), same law as a slot
|
|
432
|
+
// panel: `appWidth` resolves via theme.appSize and sizes the frame box —
|
|
433
|
+
// `flex` is the served "all remaining space" name. Without it the frame's
|
|
434
|
+
// own styles decide (and a width-less frame beside a fixed-width chat
|
|
435
|
+
// collapsed to its padding — observed live 2026-08-28).
|
|
436
|
+
const declaredW = (node as { appWidth?: string }).appWidth;
|
|
437
|
+
const placeW = cssWidth(typeof declaredW === "string" ? (theme.appSize?.[declaredW] ?? declaredW) : undefined);
|
|
438
|
+
// THEME-FREE FALLBACK (same law as computeAppWidth's marker): a theme without
|
|
439
|
+
// an `appSize.flex` token must still mean "fill the remaining host space" —
|
|
440
|
+
// an unresolved name collapsed the place to its padding (observed live
|
|
441
|
+
// 2026-08-28, the light theme's missing `flex`).
|
|
442
|
+
const flexFallback = !placeW && declaredW === "flex";
|
|
443
|
+
const rawFrame = (node as { frame?: UnoverseNode }).frame;
|
|
444
|
+
if (rawFrame) {
|
|
445
|
+
const frame = placeW
|
|
446
|
+
? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), width: placeW, flex: "0 0 auto" } } as UnoverseNode)
|
|
447
|
+
: flexFallback
|
|
448
|
+
? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), flex: "1 1 0%", minWidth: 0 } } as UnoverseNode)
|
|
449
|
+
: rawFrame;
|
|
450
|
+
const innerSlot: SlotResolver = () => tree;
|
|
451
|
+
return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
|
|
452
|
+
}
|
|
453
|
+
return <div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, ...(placeW ? { width: placeW, flex: "0 0 auto" } : flexFallback ? { flex: "1 1 0%", minWidth: 0 } : {}) }}>{tree}</div>;
|
|
454
|
+
}
|
|
308
455
|
const stateNames = spec?.states ? Object.keys(spec.states) : [];
|
|
309
456
|
const appState = store.getAppState() as Record<string, unknown>;
|
|
310
|
-
const active = (appState[`
|
|
457
|
+
const active = (appState[`template:${cname}`] as string | undefined) ?? stateNames[0];
|
|
311
458
|
const heldInterface = spec?.states ? spec.states[active ?? ""]?.interface : spec?.interface;
|
|
312
459
|
// AN EMPTY STATE HOLDS NOTHING (`closed: {}` is a collapsed drawer): a declared
|
|
313
460
|
// container whose active state names no interface renders EMPTY — never a bare
|
|
314
461
|
// slot, which would grab every interface in the store and un-collapse the drawer.
|
|
315
462
|
const emptyState = Boolean(spec?.states) && !heldInterface;
|
|
463
|
+
// (legacy holder path continues below unchanged)
|
|
316
464
|
node = {
|
|
317
465
|
...node,
|
|
318
466
|
type: "ComponentSlot",
|
|
@@ -322,8 +470,8 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
322
470
|
? ({ limit: 0 } as never)
|
|
323
471
|
: (node.select ?? {}),
|
|
324
472
|
} as UnoverseNode;
|
|
325
|
-
if (
|
|
326
|
-
const set = (state: string) => store.mergeAppState({ [`
|
|
473
|
+
if (templateChrome && spec) {
|
|
474
|
+
const set = (state: string) => store.mergeAppState({ [`template:${cname}`]: state });
|
|
327
475
|
const inner = node;
|
|
328
476
|
// THE STATE'S WIDTH: a container state may declare one (a collapsed drawer's slim
|
|
329
477
|
// edge). Resolved like every panel width (app-size name, else raw CSS); absent,
|
|
@@ -332,7 +480,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
332
480
|
const stateW = declaredW ? (cssWidth(theme.appSize?.[declaredW] ?? declaredW) ?? declaredW) : undefined;
|
|
333
481
|
return (
|
|
334
482
|
<div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", ...(stateW ? { width: stateW, flex: "0 0 auto", overflow: "hidden" } : {}) }}>
|
|
335
|
-
{
|
|
483
|
+
{templateChrome({ name: cname, states: stateNames, active, set })}
|
|
336
484
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>{resolveSlot(inner, `${String(key ?? cname)}-inner`)}</div>
|
|
337
485
|
</div>
|
|
338
486
|
);
|
|
@@ -350,7 +498,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
350
498
|
if (isViewField(claim?.field) && typeof claim?.eq === "string" && declaredClaim && claim.eq !== surfacedView) {
|
|
351
499
|
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
|
|
352
500
|
}
|
|
353
|
-
const pointers = selectPointers(store, node);
|
|
501
|
+
const pointers = selectPointers(store, node, scope);
|
|
354
502
|
if (pointers.length === 0) {
|
|
355
503
|
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
|
|
356
504
|
}
|
|
@@ -364,7 +512,12 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
364
512
|
// height — the component's `height: "full"` finally has a definite container,
|
|
365
513
|
// with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
|
|
366
514
|
const fill = node.select?.limit === 1;
|
|
367
|
-
|
|
515
|
+
// Multi-occupant surfaces (a rail) animate reorders; a single occupant fills its
|
|
516
|
+
// frame directly — a measuring wrapper there would break the height chain.
|
|
517
|
+
const innerSlot: SlotResolver = () =>
|
|
518
|
+
fill
|
|
519
|
+
? pointers.map((p) => leaf(p, undefined, fill))
|
|
520
|
+
: <FlipList items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
|
|
368
521
|
// A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
|
|
369
522
|
// (computeAppWidth) AND sizes the frame box here — the two can never disagree.
|
|
370
523
|
// Named sizes resolve via theme.appSize like any token; unresolvable → no width.
|
|
@@ -377,8 +530,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
377
530
|
: node.frame;
|
|
378
531
|
return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
|
|
379
532
|
}
|
|
380
|
-
return pointers.map((p) => leaf(p))
|
|
533
|
+
return <FlipList key={key ?? "slot"} items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
|
|
534
|
+
};
|
|
535
|
+
return resolveSlot;
|
|
381
536
|
};
|
|
537
|
+
const resolveSlot = makeResolveSlot();
|
|
382
538
|
|
|
383
539
|
// Timeline — GENERIC iterator. Walks the timeline and renders the `user` / `assistant`
|
|
384
540
|
// DATA sub-tree per turn. Zero chat UX in the SDK: bubbles, alignment, structure all live
|
|
@@ -238,18 +238,20 @@ export function useUnoverseConnection(
|
|
|
238
238
|
};
|
|
239
239
|
|
|
240
240
|
// Inject the bearer on every underlying request (fresh per call → refresh-safe).
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
241
|
+
// ALWAYS wrapped: dev tunnels need the ngrok bypass header, token or not
|
|
242
|
+
// (ngrok free interstitial = 200 HTML no CORS = dead /stream in sandboxed
|
|
243
|
+
// hosts; header is hostname-gated so prod requests are untouched).
|
|
244
|
+
const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
|
245
|
+
const token = getAccessToken ? await getAccessToken() : undefined;
|
|
246
|
+
const headers = new Headers(init?.headers);
|
|
247
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
248
|
+
if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
|
|
249
|
+
return fetch(url, { ...init, headers });
|
|
250
|
+
};
|
|
249
251
|
try {
|
|
250
252
|
const transport = new StreamableHTTPClientTransport(
|
|
251
253
|
new URL(`${streamUrl}${STREAM_ENDPOINT}`),
|
|
252
|
-
|
|
254
|
+
{ fetch: authFetch },
|
|
253
255
|
);
|
|
254
256
|
await client.connect(transport);
|
|
255
257
|
if (cancelled || clientRef.current !== client) {
|
|
@@ -122,6 +122,7 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
|
|
|
122
122
|
const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
|
|
123
123
|
const r = root as Record<string, unknown> | null | undefined;
|
|
124
124
|
const widths: string[] = [];
|
|
125
|
+
let flexOpen = false;
|
|
125
126
|
// Gather the tree's width-bearing nodes: always-open core panels and the reaction
|
|
126
127
|
// SURFACES (view → panel width).
|
|
127
128
|
const surfaces = new Map<string, string | undefined>();
|
|
@@ -152,6 +153,12 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
|
|
|
152
153
|
} else if (n.appWidth !== undefined) {
|
|
153
154
|
const w = rw(n.appWidth);
|
|
154
155
|
if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
|
|
156
|
+
// The `flex` MARKER on an always-open place (a mounted Template) — same law
|
|
157
|
+
// as the active flex surface below: the panel completes the viewport, so the
|
|
158
|
+
// app is the full host width. Without this the marker was silently DROPPED
|
|
159
|
+
// here and the app box computed to the chat alone, flexing the place to 0px
|
|
160
|
+
// (measured live 2026-08-28: grid column track 0px, cards 667px tall).
|
|
161
|
+
else if (n.appWidth === "flex") flexOpen = true;
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
|
|
@@ -166,7 +173,7 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
|
|
|
166
173
|
const activePanel = active ? surfaces.get(active) : undefined;
|
|
167
174
|
// An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
|
|
168
175
|
// surface flexes into whatever the fixed panels leave.
|
|
169
|
-
if (activePanel === "flex") return "100%";
|
|
176
|
+
if (flexOpen || activePanel === "flex") return "100%";
|
|
170
177
|
if (activePanel) widths.push(activePanel);
|
|
171
178
|
if (!widths.length) return undefined;
|
|
172
179
|
return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
|
|
@@ -210,16 +217,32 @@ export function resolveActiveView(
|
|
|
210
217
|
): { view: string; byGuest: boolean } {
|
|
211
218
|
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
212
219
|
const valid = order ? new Set(order) : (validViews ?? new Set(orderedClaims(def)));
|
|
220
|
+
// A STATE THAT MOUNTS A TEMPLATE claims the template's own states (loader-stamped
|
|
221
|
+
// `claims`, UNOVERSE_TEMPLATE_MODEL §12): an instance arriving in any of them is a
|
|
222
|
+
// match FOR THE MOUNT STATE — "the grid holds cards" is what enters `courses`, and
|
|
223
|
+
// the empty grid is what releases it. Same walk, one more spelling of a match.
|
|
224
|
+
const aliasOf = new Map<string, string>();
|
|
225
|
+
for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
|
|
226
|
+
if (!entry?.template || !valid.has(sname)) continue;
|
|
227
|
+
for (const c of entry.claims ?? []) aliasOf.set(c, sname);
|
|
228
|
+
}
|
|
229
|
+
// A mount-claim match counts only within THE CURRENT DELIVERY (hold: latest):
|
|
230
|
+
// released instances must release the state too, or the app would sit in an
|
|
231
|
+
// arrangement whose template just emptied.
|
|
232
|
+
const delivery = aliasOf.size ? store.currentDeliveryPointers() : null;
|
|
213
233
|
// One pass over the timeline: the best (latest) match per view.
|
|
214
234
|
const best = new Map<string, { at: number; byGuest: boolean }>();
|
|
215
235
|
for (const turn of store.getResponses())
|
|
216
236
|
for (const p of turn.components) {
|
|
217
237
|
const { chatId, nodeId } = store.split(p);
|
|
218
238
|
const v = viewOf(store.get(chatId, nodeId) as Record<string, unknown>);
|
|
219
|
-
if (!v
|
|
239
|
+
if (!v) continue;
|
|
240
|
+
const aliasTarget = aliasOf.get(v);
|
|
241
|
+
const target = valid.has(v) ? v : aliasTarget && delivery?.has(p) ? aliasTarget : undefined;
|
|
242
|
+
if (!target) continue;
|
|
220
243
|
const t = store.touchOf(p);
|
|
221
|
-
const b = best.get(
|
|
222
|
-
if (!b || t > b.at) best.set(
|
|
244
|
+
const b = best.get(target);
|
|
245
|
+
if (!b || t > b.at) best.set(target, { at: t, byGuest: store.interactionTouchOf(p) >= t });
|
|
223
246
|
}
|
|
224
247
|
if (order) {
|
|
225
248
|
// THE LADDER: first declared state with any match wins. No word is special.
|
|
@@ -298,12 +321,14 @@ export function resolveLatch(
|
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
301
|
-
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
324
|
+
export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
|
|
302
325
|
const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
|
|
303
326
|
let pointers =
|
|
304
327
|
sel.from === "all"
|
|
305
328
|
? store.getResponses().flatMap((r) => r.components)
|
|
306
329
|
: (store.latestResponse()?.components ?? []);
|
|
330
|
+
// A SCOPED selection (the mount's hold: latest) sees only the given pointers.
|
|
331
|
+
if (scope) pointers = pointers.filter((p) => scope.has(p));
|
|
307
332
|
|
|
308
333
|
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
309
334
|
// component whose own slice matches, ordered newest state-write first so `limit: 1`
|
|
@@ -313,6 +338,18 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode): strin
|
|
|
313
338
|
// interface holds it whatever state it is in. Placement, not reaction — the one case
|
|
314
339
|
// where selecting by definition name is the point rather than the anti-pattern.
|
|
315
340
|
if (sel.type) pointers = pointers.filter((p) => store.typeOf(p) === sel.type);
|
|
341
|
+
// ONE INSTANCE, ONE CARD. An instance is its nodeId; the same instance re-delivered in
|
|
342
|
+
// a later turn (a row card re-surfaced by a later search) is a REFRESH of that card,
|
|
343
|
+
// never a sibling. `from: "all"` spans turns and would otherwise show both copies, so
|
|
344
|
+
// keep the most recently written pointer per instance. Per-turn selections are
|
|
345
|
+
// unaffected — a turn's nodeIds are already unique.
|
|
346
|
+
const byInstance = new Map<string, string>();
|
|
347
|
+
for (const p of pointers) {
|
|
348
|
+
const { nodeId } = store.split(p);
|
|
349
|
+
const held = byInstance.get(nodeId);
|
|
350
|
+
if (!held || store.touchOf(p) >= store.touchOf(held)) byInstance.set(nodeId, p);
|
|
351
|
+
}
|
|
352
|
+
if (byInstance.size !== pointers.length) pointers = [...byInstance.values()];
|
|
316
353
|
if (sel.where) {
|
|
317
354
|
const { field, eq, ne } = sel.where;
|
|
318
355
|
const within = (sel.where as { in?: unknown[] }).in;
|
|
@@ -66,18 +66,21 @@ export class UnoverseClient {
|
|
|
66
66
|
this.connecting = (async () => {
|
|
67
67
|
const getToken = this.options.getAccessToken;
|
|
68
68
|
// Inject the bearer on every underlying request (fresh per call → refresh-safe).
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
// ALWAYS wrapped (not only when a token getter exists): dev tunnels need the
|
|
70
|
+
// ngrok bypass header below, token or not.
|
|
71
|
+
const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
|
72
|
+
const token = getToken ? await getToken() : undefined;
|
|
73
|
+
const headers = new Headers(init?.headers);
|
|
74
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
75
|
+
// DEV TUNNEL ONLY: ngrok's free tier answers browser requests with an
|
|
76
|
+
// interstitial (200 HTML, no CORS) unless this header rides along — which
|
|
77
|
+
// read as "blocked by CORS" inside ChatGPT's widget sandbox (observed live
|
|
78
|
+
// 2026-08-28). Hostname-gated so production traffic never grows a
|
|
79
|
+
// preflight-triggering custom header.
|
|
80
|
+
if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
|
|
81
|
+
return fetch(url, { ...init, headers });
|
|
82
|
+
};
|
|
83
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.url), { fetch: authFetch });
|
|
81
84
|
await this.client.connect(transport);
|
|
82
85
|
this.connected = true;
|
|
83
86
|
})().catch((err) => {
|
|
@@ -137,6 +137,13 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
137
137
|
else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") store.setLifecycle("error");
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
|
+
case "COMPONENTS_CLEAR": {
|
|
141
|
+
// Rule 5's empty delivery (STATE_MODEL §5, amended 2026-08-26): a turn that ran
|
|
142
|
+
// with cards enabled and surfaced none owns the screen — earlier-turn components
|
|
143
|
+
// in public states retract to inline. The sender decided WHEN; this only obeys.
|
|
144
|
+
store.supersedeAll(typeof msg.chatId === "string" ? msg.chatId : undefined);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
140
147
|
// No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
|
|
141
148
|
// NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
|
|
142
149
|
case "APP_DATA": {
|
|
@@ -27,6 +27,19 @@
|
|
|
27
27
|
/** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
|
|
28
28
|
export type Pointer = string;
|
|
29
29
|
|
|
30
|
+
/** Structural equality for wire values: identity first, JSON compare for the rest.
|
|
31
|
+
* Wire payloads are plain JSON, so stringify is faithful there; anything it cannot
|
|
32
|
+
* serialize (cycles, functions) counts as CHANGED, which errs on re-rendering. */
|
|
33
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
34
|
+
if (Object.is(a, b)) return true;
|
|
35
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export interface UserTurn {
|
|
31
44
|
role: "user";
|
|
32
45
|
id: string;
|
|
@@ -81,6 +94,8 @@ export class ComponentStore {
|
|
|
81
94
|
// ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
|
|
82
95
|
// Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
|
|
83
96
|
private touches = new Map<string, number>();
|
|
97
|
+
/** The touch sequence at the guest's last send — the CURRENT DELIVERY's floor. */
|
|
98
|
+
private deliveryWatermark = 0;
|
|
84
99
|
/** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
|
|
85
100
|
* that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
|
|
86
101
|
private interactionTouches = new Map<string, number>();
|
|
@@ -182,6 +197,31 @@ export class ComponentStore {
|
|
|
182
197
|
* refresh is VISIBLE is the ladder's business alone (rule 4: higher states mask;
|
|
183
198
|
* lower states update silently underneath).
|
|
184
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* RULE 5's EMPTY DELIVERY (STATE_MODEL §5 rule 5, amended 2026-08-26): a discovery
|
|
202
|
+
* turn that ran with cards enabled and surfaced NONE still owns the screen — the
|
|
203
|
+
* server says so with COMPONENTS_CLEAR, and every earlier-turn component sitting in
|
|
204
|
+
* a public state retracts to inline. `supersede` for ALL states at once: same
|
|
205
|
+
* durable (`lifetime: "conversation"`) opt-out, same same-turn exemption. Rule 7
|
|
206
|
+
* stands — this rides the answer's delivery, never the guest's typing.
|
|
207
|
+
*/
|
|
208
|
+
supersedeAll(arrivingChatId?: string): void {
|
|
209
|
+
let changed = false;
|
|
210
|
+
for (const [key, slice] of this.data) {
|
|
211
|
+
if (arrivingChatId && key.startsWith(`${arrivingChatId}:`)) continue;
|
|
212
|
+
if (slice.lifetime === "conversation") continue;
|
|
213
|
+
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
214
|
+
if (typeof v !== "string" || !v || v === "inline") continue;
|
|
215
|
+
const next: Record<string, unknown> = { ...slice };
|
|
216
|
+
if ("view" in next || !("defaultState" in next)) next.view = "inline";
|
|
217
|
+
if ("defaultState" in next) next.defaultState = "inline";
|
|
218
|
+
this.data.set(key, next);
|
|
219
|
+
this.touches.set(key, ++this.touchSeq);
|
|
220
|
+
changed = true;
|
|
221
|
+
}
|
|
222
|
+
if (changed) this.bump();
|
|
223
|
+
}
|
|
224
|
+
|
|
185
225
|
private supersede(arrivingChatId: string, view: unknown): void {
|
|
186
226
|
if (typeof view !== "string" || !view || view === "inline") return;
|
|
187
227
|
let changed = false;
|
|
@@ -330,6 +370,9 @@ export class ComponentStore {
|
|
|
330
370
|
* guest's next message; a stale rail is cleared by the cards that replace it,
|
|
331
371
|
* never by the message that asked for them. */
|
|
332
372
|
addUserMessage(chatId: string, text: string): void {
|
|
373
|
+
// The guest's send is the delivery boundary (hold: latest, basic form): every
|
|
374
|
+
// pointer not server-touched after this moment leaves the current set.
|
|
375
|
+
this.deliveryWatermark = this.touchSeq;
|
|
333
376
|
this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
|
|
334
377
|
this.evict();
|
|
335
378
|
this.bump();
|
|
@@ -376,8 +419,12 @@ export class ComponentStore {
|
|
|
376
419
|
this.timeline.push(resp);
|
|
377
420
|
this.evict();
|
|
378
421
|
}
|
|
379
|
-
|
|
380
|
-
|
|
422
|
+
// A reload of an already-placed pointer changes no structure — bump only when
|
|
423
|
+
// something actually moved, so re-delivering a card does not re-walk the template.
|
|
424
|
+
if (!resp.components.includes(key)) {
|
|
425
|
+
resp.components.push(key);
|
|
426
|
+
this.bump();
|
|
427
|
+
}
|
|
381
428
|
}
|
|
382
429
|
|
|
383
430
|
private responseFor(chatId: string): AssistantTurn | undefined {
|
|
@@ -409,7 +456,16 @@ export class ComponentStore {
|
|
|
409
456
|
private merge(key: string, patch: Record<string, unknown>): void {
|
|
410
457
|
// merge-not-replace. Fresh object identity per merge = the per-slice snapshot
|
|
411
458
|
// React bindings key on (useSyncExternalStore bails out for untouched slices).
|
|
412
|
-
|
|
459
|
+
//
|
|
460
|
+
// AN UNCHANGED MERGE IS A TRUE NO-OP: a reload that re-delivers the same props must
|
|
461
|
+
// not bump recency (which would reorder recency-sorted slots) or mint a new snapshot
|
|
462
|
+
// (which would re-render the card). Cheap identity check per key first; only keys
|
|
463
|
+
// that fail it pay for the structural compare — wire payloads are plain JSON, so
|
|
464
|
+
// JSON.stringify is a faithful equality there, and a non-serializable value simply
|
|
465
|
+
// counts as changed.
|
|
466
|
+
const prev = this.data.get(key);
|
|
467
|
+
if (prev && Object.keys(patch).every((k) => k in prev && sameValue(prev[k], patch[k]))) return;
|
|
468
|
+
this.data.set(key, { ...(prev ?? {}), ...patch });
|
|
413
469
|
this.touches.set(key, ++this.touchSeq);
|
|
414
470
|
this.bumpData();
|
|
415
471
|
}
|
|
@@ -508,6 +564,23 @@ export class ComponentStore {
|
|
|
508
564
|
getTimeline(): readonly Turn[] {
|
|
509
565
|
return this.timeline;
|
|
510
566
|
}
|
|
567
|
+
/** THE CURRENT DELIVERY (hold: latest, basic form — UNOVERSE_TEMPLATE_MODEL §9b):
|
|
568
|
+
* the pointers placed since the guest's LAST SEND. A mounted template holds only
|
|
569
|
+
* these, so the guest's own send clears the set — nothing stale ever survives a
|
|
570
|
+
* turn, and the disappearance happens at the guest's action, never a background
|
|
571
|
+
* delivery's. */
|
|
572
|
+
currentDeliveryPointers(): Set<string> {
|
|
573
|
+
// TOUCH-BASED, not turn-based: durable-keyed instances (conversation-chatId
|
|
574
|
+
// renders) file into ONE assistant turn across the whole conversation, so turn
|
|
575
|
+
// position says nothing about freshness. A pointer is current when it was
|
|
576
|
+
// WRITTEN since the guest's last send — which also makes a re-delivered card
|
|
577
|
+
// part of the new set, exactly as hold: latest wants.
|
|
578
|
+
const out = new Set<string>();
|
|
579
|
+
for (const r of this.getResponses())
|
|
580
|
+
for (const p of r.components)
|
|
581
|
+
if ((this.touches.get(p) ?? 0) > this.deliveryWatermark) out.add(p);
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
511
584
|
/** Just the assistant responses, in order. */
|
|
512
585
|
getResponses(): AssistantTurn[] {
|
|
513
586
|
return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
|