@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.
@@ -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
- containerChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
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
- export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, containerChrome }: StreamedUnoverseAppProps) {
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) => alive && setDef(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
- return <UnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
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
- const resolveSlot = (node: UnoverseNode, key?: React.Key): ReactNode => {
296
- // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): the layout places it by NAME and owns
297
- // its width; the manifest's `containers:` block owns its claim. Resolved here into
298
- // the slot it is — one authoring word (`state`), translated to the wire key.
299
- if (node.type === "Container") {
300
- // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): its states each HOLD AN INTERFACE —
301
- // the page state holds the deal, the chat state holds text-chat — and the tabs
302
- // switch between them. The container's ACTIVE state is app chrome (`setAppValue`
303
- // territory, the one thing an app owns), keyed `container:<name>`; the shorthand
304
- // `interface:` is a one-state container and never draws tabs.
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 { containers?: Record<string, { states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
307
- ?.containers?.[cname];
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[`container:${cname}`] as string | undefined) ?? stateNames[0];
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 (containerChrome && spec) {
326
- const set = (state: string) => store.mergeAppState({ [`container:${cname}`]: state });
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
- {containerChrome({ name: cname, states: stateNames, active, set })}
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
- const innerSlot: SlotResolver = () => pointers.map((p) => leaf(p, undefined, fill));
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
- const authFetch = getAccessToken
242
- ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
243
- const token = await getAccessToken();
244
- const headers = new Headers(init?.headers);
245
- if (token) headers.set("Authorization", `Bearer ${token}`);
246
- return fetch(url, { ...init, headers });
247
- }
248
- : undefined;
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
- authFetch ? { fetch: authFetch } : undefined,
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 || !valid.has(v)) continue;
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(v);
222
- if (!b || t > b.at) best.set(v, { at: t, byGuest: store.interactionTouchOf(p) >= t });
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
- const authFetch = getToken
70
- ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
71
- const token = await getToken();
72
- const headers = new Headers(init?.headers);
73
- if (token) headers.set("Authorization", `Bearer ${token}`);
74
- return fetch(url, { ...init, headers });
75
- }
76
- : undefined;
77
- const transport = new StreamableHTTPClientTransport(
78
- new URL(this.url),
79
- authFetch ? { fetch: authFetch } : undefined,
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
- if (!resp.components.includes(key)) resp.components.push(key);
380
- this.bump();
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
- this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
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");