@unoverse-platform/studio 0.1.85 → 0.1.86
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 +1 -1
- package/screens/ComponentsView.tsx +4 -3
- package/screens/TemplatesLibraryView.tsx +142 -82
- package/screens/TemplatesView.tsx +49 -8
- package/screens/states.ts +25 -7
- package/src/host.ts +1 -1
- package/vendor/sdk/lib/UnoverseComponent.tsx +49 -3
- package/vendor/sdk/lib/app.tsx +121 -31
- package/vendor/sdk/lib/core/actions.ts +2 -2
- package/vendor/sdk/lib/core/apps.ts +14 -8
- package/vendor/sdk/lib/core/conditions.ts +15 -3
- package/vendor/sdk/lib/core/connection.ts +17 -1
- package/vendor/sdk/lib/core/store.ts +104 -21
- package/vendor/sdk/lib/transition/MountArrangement.tsx +71 -0
- package/vendor/sdk/lib/transition/decide.ts +53 -0
- package/vendor/sdk/lib/transition/guestHold.ts +34 -0
- package/vendor/sdk/lib/transition/index.ts +9 -0
- package/vendor/sdk/lib/transition/viewTransition.ts +29 -0
- package/vendor/skills/unoverse-create/SKILL.md +11 -5
- package/vendor/skills/unoverse-create/references/app.md +35 -21
- package/vendor/skills/unoverse-create/references/component.md +55 -54
- package/vendor/skills/unoverse-create/references/node.md +3 -1
- package/vendor/skills/unoverse-create/references/template.md +133 -0
package/vendor/sdk/lib/app.tsx
CHANGED
|
@@ -39,6 +39,8 @@ 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
|
+
import { MountArrangement } from "./transition/index";
|
|
43
|
+
import { guestHeldPointers } from "./transition/guestHold";
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* FLIP wrapper for a multi-occupant slot: when arrivals reorder the list (a refreshed
|
|
@@ -217,6 +219,29 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
217
219
|
};
|
|
218
220
|
}, [client, uri]);
|
|
219
221
|
|
|
222
|
+
// A PAGE THAT STREAMED IN is on screen exactly like a mounted one, and claims exactly
|
|
223
|
+
// as much — but the app never named it, so nothing has fetched its definition. Its
|
|
224
|
+
// ADDRESS says what it is (`unoverse://templates/…`, §9c), which is all the SDK needs
|
|
225
|
+
// to learn what it claims. Fetched once per instance, into the same map the mounts use.
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
let alive = true;
|
|
228
|
+
for (const p of store.getResponses().flatMap((r) => r.components)) {
|
|
229
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
230
|
+
const url = store.getComponentUrl(c, n);
|
|
231
|
+
if (!url || !url.includes("://templates/")) continue;
|
|
232
|
+
const key = url.replace(/^unoverse:\/\/templates\//, "");
|
|
233
|
+
if (mounted[key]) continue;
|
|
234
|
+
client
|
|
235
|
+
.readDefinition(url)
|
|
236
|
+
.then((t) => alive && t && setMounted((m) => (m[key] ? m : { ...m, [key]: t })))
|
|
237
|
+
.catch((e) => console.warn(`[unoverse] streamed template "${key}" failed to load:`, e));
|
|
238
|
+
}
|
|
239
|
+
return () => {
|
|
240
|
+
alive = false;
|
|
241
|
+
};
|
|
242
|
+
}, [client, store, structureVersion, mounted]);
|
|
243
|
+
|
|
244
|
+
|
|
220
245
|
// ARRIVAL PLACEMENT (STATE_MODEL v2 §5 rule 3): nobody sends a state — the HOST
|
|
221
246
|
// places the actor at spawn. For each arrived component whose slice has no view yet:
|
|
222
247
|
// 1. the component's DECLARED INITIAL, if this template has a state for it (the
|
|
@@ -240,7 +265,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
240
265
|
if (!menu) continue;
|
|
241
266
|
const initial = typeof slice.initialView === "string" ? slice.initialView : undefined;
|
|
242
267
|
const face = initial && order.includes(initial) ? initial : order.find((s) => menu.includes(s));
|
|
243
|
-
if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: {
|
|
268
|
+
if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { state: face } });
|
|
244
269
|
}
|
|
245
270
|
}, [def, store, structureVersion]);
|
|
246
271
|
|
|
@@ -260,6 +285,14 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
260
285
|
// it holds must not also render in the flow — one instance, one placeholder.
|
|
261
286
|
for (const tpl of Object.values(mounted)) {
|
|
262
287
|
for (const name of Object.keys(tpl.layouts ?? {})) views.add(name);
|
|
288
|
+
// A TEMPLATE CLAIMS WHAT ITS SECTIONS CLAIM, not just its state names. A grid-page
|
|
289
|
+
// holds its content in its own states, so the names were enough; a rich page holds
|
|
290
|
+
// its content in PARTS — a written hero, a section head, the band's cards — and
|
|
291
|
+
// each wears the section's face. Without these the parts are unclaimed at app
|
|
292
|
+
// level, so the SDK draws the page in its surface AND every piece of it again in
|
|
293
|
+
// the conversation flow (observed live 2026-08-30: the whole page, twice).
|
|
294
|
+
for (const secs of Object.values((tpl as { sections?: Record<string, { face?: string }[]> }).sections ?? {}))
|
|
295
|
+
for (const sec of secs) if (sec?.face) views.add(sec.face);
|
|
263
296
|
}
|
|
264
297
|
return views;
|
|
265
298
|
}, [activeRoot, def, mounted]);
|
|
@@ -419,38 +452,95 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
419
452
|
// nothing collapses.
|
|
420
453
|
if (spec?.template) {
|
|
421
454
|
const tpl = mounted[spec.template];
|
|
422
|
-
|
|
455
|
+
if (!tpl) return null;
|
|
423
456
|
// HOLD: LATEST (basic form, UNOVERSE_TEMPLATE_MODEL §9b): the mount holds
|
|
424
|
-
// only the CURRENT delivery — the
|
|
457
|
+
// only the CURRENT delivery — spent by the settle or the clear, never the
|
|
458
|
+
// guest's send. A judged DIRECTOR_VERDICT narrows it further (§7); no
|
|
459
|
+
// verdict, or a verdict holding nothing that survives, falls back to the
|
|
460
|
+
// whole delivery.
|
|
461
|
+
const active = resolveActiveLayout(tpl, store);
|
|
425
462
|
const delivery = store.currentDeliveryPointers();
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
463
|
+
const dirScope = store.directorScope(delivery);
|
|
464
|
+
const tplLayouts = (tpl as { layouts?: Record<string, UnoverseNode> }).layouts ?? {};
|
|
465
|
+
// THE GUEST'S HAND OUTRANKS THE SETTLE (§6b rule 3, state priority): an
|
|
466
|
+
// instance the guest holds open in a non-base face (Detail View) stays in
|
|
467
|
+
// the mount's scope through deliveries and settles — a new batch landing
|
|
468
|
+
// below must not unseat it. The hold releases when the guest writes the
|
|
469
|
+
// face back to the base (✕), and the CURRENT arrangement takes over.
|
|
470
|
+
const layoutNames = Object.keys(tplLayouts);
|
|
471
|
+
const baseLayout = (tpl as { defaultLayout?: string }).defaultLayout ?? layoutNames[0] ?? null;
|
|
472
|
+
const guestHeld = guestHeldPointers(
|
|
473
|
+
store.getResponses().flatMap((r) => r.components),
|
|
474
|
+
{
|
|
475
|
+
touchOf: (p) => store.touchOf(p),
|
|
476
|
+
interactionTouchOf: (p) => store.interactionTouchOf(p),
|
|
477
|
+
viewOf: (p) => {
|
|
478
|
+
const { chatId, nodeId } = store.split(p);
|
|
479
|
+
return viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
layoutNames,
|
|
483
|
+
baseLayout,
|
|
484
|
+
);
|
|
485
|
+
const scope = guestHeld.length ? new Set([...(dirScope ?? delivery), ...guestHeld]) : (dirScope ?? delivery);
|
|
486
|
+
const held = active ? collectSlotPointers(active, store, scope) : [];
|
|
487
|
+
// THE TRANSITION MODEL (§13): the mount double-buffers — this render
|
|
488
|
+
// derives the NEXT arrangement; MountArrangement commits it only at a
|
|
489
|
+
// boundary (verdict change, guest layout change, un-directed mount), so
|
|
490
|
+
// a data ripple can never move the shelf, and EMPTY COLLAPSES only when
|
|
491
|
+
// an arrangement holding nothing is COMMITTED. Content inside held
|
|
492
|
+
// instances stays live: the closure reads slices at render time.
|
|
493
|
+
const nextArrangement = {
|
|
494
|
+
layoutName: active ? (Object.entries(tplLayouts).find(([, t]) => t === active)?.[0] ?? null) : null,
|
|
495
|
+
held,
|
|
496
|
+
verdictSig: dirScope ? [...dirScope].sort().join("|") : null,
|
|
497
|
+
};
|
|
498
|
+
const motion = ((theme as { motion?: { mount?: string } }).motion?.mount ?? "") === "transition";
|
|
499
|
+
return (
|
|
500
|
+
<MountArrangement
|
|
501
|
+
key={key ?? cname}
|
|
502
|
+
next={nextArrangement}
|
|
503
|
+
directed={
|
|
504
|
+
// THE ONE HOME (§9b clause 5): the grant is the template's own
|
|
505
|
+
// director SECTION, compiled into the served def's `sections`;
|
|
506
|
+
// the placement's `director:` block is the legacy fallback.
|
|
507
|
+
Object.values(((tpl as { sections?: Record<string, { kind?: string }[]> }).sections ?? {}))
|
|
508
|
+
.flat()
|
|
509
|
+
.some((s) => s?.kind === "director") || Boolean((spec as { director?: unknown }).director)
|
|
510
|
+
}
|
|
511
|
+
motion={motion}
|
|
512
|
+
render={(arr) => {
|
|
513
|
+
if (!arr.held.length) return null;
|
|
514
|
+
const arrActive = (arr.layoutName ? tplLayouts[arr.layoutName] : undefined) ?? active;
|
|
515
|
+
if (!arrActive) return null;
|
|
516
|
+
const arrScope = new Set(arr.held);
|
|
517
|
+
const tree = renderNode(arrActive, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-tpl`, makeResolveSlot(arrScope), tpl.defs);
|
|
518
|
+
// A PLACE DECLARES ITS WIDTH ONCE (docs/design/05), same law as a slot
|
|
519
|
+
// panel: `appWidth` resolves via theme.appSize and sizes the frame box —
|
|
520
|
+
// `flex` is the served "all remaining space" name. Without it the frame's
|
|
521
|
+
// own styles decide (and a width-less frame beside a fixed-width chat
|
|
522
|
+
// collapsed to its padding — observed live 2026-08-28).
|
|
523
|
+
const declaredW = (node as { appWidth?: string }).appWidth;
|
|
524
|
+
const placeW = cssWidth(typeof declaredW === "string" ? (theme.appSize?.[declaredW] ?? declaredW) : undefined);
|
|
525
|
+
// THEME-FREE FALLBACK (same law as computeAppWidth's marker): a theme without
|
|
526
|
+
// an `appSize.flex` token must still mean "fill the remaining host space" —
|
|
527
|
+
// an unresolved name collapsed the place to its padding (observed live
|
|
528
|
+
// 2026-08-28, the light theme's missing `flex`).
|
|
529
|
+
const flexFallback = !placeW && declaredW === "flex";
|
|
530
|
+
const rawFrame = (node as { frame?: UnoverseNode }).frame;
|
|
531
|
+
if (rawFrame) {
|
|
532
|
+
const frame = placeW
|
|
533
|
+
? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), width: placeW, flex: "0 0 auto" } } as UnoverseNode)
|
|
534
|
+
: flexFallback
|
|
535
|
+
? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), flex: "1 1 0%", minWidth: 0 } } as UnoverseNode)
|
|
536
|
+
: rawFrame;
|
|
537
|
+
const innerSlot: SlotResolver = () => tree;
|
|
538
|
+
return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
|
|
539
|
+
}
|
|
540
|
+
return <div style={{ display: "flex", flexDirection: "column", minHeight: 0, ...(placeW ? { width: placeW, flex: "0 0 auto" } : flexFallback ? { flex: "1 1 0%", minWidth: 0 } : {}) }}>{tree}</div>;
|
|
541
|
+
}}
|
|
542
|
+
/>
|
|
543
|
+
);
|
|
454
544
|
}
|
|
455
545
|
const stateNames = spec?.states ? Object.keys(spec.states) : [];
|
|
456
546
|
const appState = store.getAppState() as Record<string, unknown>;
|
|
@@ -69,8 +69,8 @@ export function dispatchAction(
|
|
|
69
69
|
// on the channel path every other server action uses; the server decides whether
|
|
70
70
|
// any hook cares. The payload carries both spellings during the migration (the
|
|
71
71
|
// server's lifecycle scope historically read `layout`).
|
|
72
|
-
if (ctx.nodeId && ("view" in patch || "defaultState" in patch)) {
|
|
73
|
-
const entered = patch.view ?? patch.defaultState;
|
|
72
|
+
if (ctx.nodeId && ("state" in patch || "view" in patch || "defaultState" in patch)) {
|
|
73
|
+
const entered = patch.state ?? patch.view ?? patch.defaultState;
|
|
74
74
|
ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
|
|
75
75
|
}
|
|
76
76
|
} else if (spec.type === "setAppValue") {
|
|
@@ -15,17 +15,18 @@ import type { ComponentStore } from "./store";
|
|
|
15
15
|
import type { UnoverseDefinition, UnoverseNode } from "./types";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* THE PUBLIC AXIS
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* THE PUBLIC AXIS: a component's public state lives in its slice under `state`
|
|
19
|
+
* (the word ruled 2026-08-29, finishing the v2 rename journey); `view` and
|
|
20
|
+
* `defaultState` are the read-compatible legacy aliases during migration.
|
|
21
|
+
* Every derivation reads through these two helpers so the aliases live in ONE place.
|
|
21
22
|
*/
|
|
22
23
|
export const viewOf = (slice: Record<string, unknown> | null | undefined): string | undefined => {
|
|
23
|
-
const v = slice?.view ?? slice?.defaultState;
|
|
24
|
+
const v = slice?.state ?? slice?.view ?? slice?.defaultState;
|
|
24
25
|
return typeof v === "string" ? v : undefined;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
/** Is a `select.where` field the public axis? Accepts
|
|
28
|
-
export const isViewField = (f: unknown): boolean => f === "view" || f === "defaultState";
|
|
28
|
+
/** Is a `select.where` field the public axis? Accepts every spelling during migration. */
|
|
29
|
+
export const isViewField = (f: unknown): boolean => f === "state" || f === "view" || f === "defaultState";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* THE TEMPLATE'S DECLARED ORDER (STATE_MODEL v2 §5 rule 5) — its priority ladder and
|
|
@@ -323,11 +324,16 @@ export function resolveLatch(
|
|
|
323
324
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
324
325
|
export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
|
|
325
326
|
const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
|
|
327
|
+
// A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
|
|
328
|
+
// the boundary — never the latest assistant turn. The guest's send opens a new
|
|
329
|
+
// (empty) turn immediately, so latest-turn scoping emptied the mount at every
|
|
330
|
+
// send and refilled it as renders landed: the shelf flicker ruled out in
|
|
331
|
+
// UNOVERSE_TEMPLATE_MODEL §12e. The delivery scope is touch-based for exactly
|
|
332
|
+
// this reason (turn position says nothing about freshness).
|
|
326
333
|
let pointers =
|
|
327
|
-
sel.from === "all"
|
|
334
|
+
sel.from === "all" || scope
|
|
328
335
|
? store.getResponses().flatMap((r) => r.components)
|
|
329
336
|
: (store.latestResponse()?.components ?? []);
|
|
330
|
-
// A SCOPED selection (the mount's hold: latest) sees only the given pointers.
|
|
331
337
|
if (scope) pointers = pointers.filter((p) => scope.has(p));
|
|
332
338
|
|
|
333
339
|
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
@@ -10,9 +10,21 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Condition, VisibleWhen, UnoverseNode } from "./types";
|
|
12
12
|
|
|
13
|
+
/** THE PUBLIC AXIS ALIASES (`state` is the ruled word, 2026-08-29; `view` and
|
|
14
|
+
* `defaultState` are the legacy spellings): a lookup on any axis word falls
|
|
15
|
+
* through the aliases, so a layout authored in either era reads a slice keyed in
|
|
16
|
+
* either era. Lives HERE so every "react to state" mechanism shares it. */
|
|
17
|
+
const AXIS_WORDS = ["state", "view", "defaultState"] as const;
|
|
18
|
+
function scopeValue(data: Record<string, unknown>, field: string): unknown {
|
|
19
|
+
const v = data[field];
|
|
20
|
+
if (v !== undefined || !(AXIS_WORDS as readonly string[]).includes(field)) return v;
|
|
21
|
+
for (const w of AXIS_WORDS) if (data[w] !== undefined) return data[w];
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/** Does a `Condition` hold against the data scope? `eq`/`ne`/`in`, else a truthy test. */
|
|
14
26
|
export function matches(cond: Condition, data: Record<string, unknown>): boolean {
|
|
15
|
-
const v = data
|
|
27
|
+
const v = scopeValue(data, cond.field);
|
|
16
28
|
if ("eq" in cond) return v === cond.eq;
|
|
17
29
|
if ("ne" in cond) return v !== cond.ne;
|
|
18
30
|
if ("in" in cond) return Array.isArray(cond.in) && cond.in.includes(v);
|
|
@@ -24,7 +36,7 @@ export function matches(cond: Condition, data: Record<string, unknown>): boolean
|
|
|
24
36
|
* `Condition`. Returns whether the node should render. */
|
|
25
37
|
export function visible(vw: VisibleWhen, data: Record<string, unknown>): boolean {
|
|
26
38
|
if (typeof vw === "string") {
|
|
27
|
-
const v = data
|
|
39
|
+
const v = scopeValue(data, vw);
|
|
28
40
|
return !(v == null || v === "" || v === false || (Array.isArray(v) && v.length === 0));
|
|
29
41
|
}
|
|
30
42
|
return matches(vw, data);
|
|
@@ -38,6 +50,6 @@ export function selectCase(
|
|
|
38
50
|
): UnoverseNode | undefined {
|
|
39
51
|
const cases = node.cases;
|
|
40
52
|
if (!cases) return undefined;
|
|
41
|
-
const key = node.on != null ? String(data
|
|
53
|
+
const key = node.on != null ? String(scopeValue(data, node.on) ?? "") : "";
|
|
42
54
|
return cases[key] ?? cases.default;
|
|
43
55
|
}
|
|
@@ -140,10 +140,26 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
140
140
|
case "COMPONENTS_CLEAR": {
|
|
141
141
|
// Rule 5's empty delivery (STATE_MODEL §5, amended 2026-08-26): a turn that ran
|
|
142
142
|
// with cards enabled and surfaced none owns the screen — earlier-turn components
|
|
143
|
-
// in public states retract to inline
|
|
143
|
+
// in public states retract to inline, and the delivery is SPENT (§12e: the
|
|
144
|
+
// confirmed-empty boundary — the mount collapses now, not at the guest's send).
|
|
145
|
+
// The sender decided WHEN; this only obeys.
|
|
146
|
+
store.spendDelivery();
|
|
144
147
|
store.supersedeAll(typeof msg.chatId === "string" ? msg.chatId : undefined);
|
|
145
148
|
return;
|
|
146
149
|
}
|
|
150
|
+
case "DIRECTOR_VERDICT": {
|
|
151
|
+
// The director's judged hold for the delivery (UNOVERSE_TEMPLATE_MODEL §7):
|
|
152
|
+
// ids and order only, judged server-side at run completion. Each item names
|
|
153
|
+
// the chatId it was DELIVERED under (turn or conversation — the durable
|
|
154
|
+
// lane), so the pointer is exact. This only obeys — exactly the
|
|
155
|
+
// COMPONENTS_CLEAR contract, for the held-something verdict.
|
|
156
|
+
const raw = (msg as { items?: unknown }).items;
|
|
157
|
+
const items = Array.isArray(raw)
|
|
158
|
+
? raw.filter((x): x is { chatId: string; nodeId: string } => !!x && typeof (x as { chatId?: unknown }).chatId === "string" && typeof (x as { nodeId?: unknown }).nodeId === "string")
|
|
159
|
+
: [];
|
|
160
|
+
if (items.length) store.applyDirectorVerdict(items);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
147
163
|
// No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
|
|
148
164
|
// NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
|
|
149
165
|
case "APP_DATA": {
|
|
@@ -94,8 +94,15 @@ export class ComponentStore {
|
|
|
94
94
|
// ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
|
|
95
95
|
// Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
|
|
96
96
|
private touches = new Map<string, number>();
|
|
97
|
-
/** The
|
|
97
|
+
/** The CURRENT DELIVERY's floor — advanced by the settle (applyDirectorVerdict)
|
|
98
|
+
* and the confirmed-empty clear (spendDelivery), NEVER by the guest's send
|
|
99
|
+
* (§12e ruling 2: the replacement is the boundary). */
|
|
98
100
|
private deliveryWatermark = 0;
|
|
101
|
+
/** A judged DIRECTOR_VERDICT's held pointers for the current delivery (null = no
|
|
102
|
+
* verdict — the mount holds the whole delivery). Generic bookkeeping: core stores
|
|
103
|
+
* WHICH slices the sender's verdict held, never why (the judging happened
|
|
104
|
+
* server-side; this only obeys, exactly like COMPONENTS_CLEAR). */
|
|
105
|
+
private directorHeld: Set<string> | null = null;
|
|
99
106
|
/** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
|
|
100
107
|
* that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
|
|
101
108
|
private interactionTouches = new Map<string, number>();
|
|
@@ -129,12 +136,22 @@ export class ComponentStore {
|
|
|
129
136
|
* the ONE home for the reset every boundary uses (new turn, guest close, template
|
|
130
137
|
* swap) — never re-hardcode `"inline"` at a call site.
|
|
131
138
|
*/
|
|
139
|
+
/** THE FACE-WRITE KEY: the spelling this slice already speaks — `state` (the
|
|
140
|
+
* ruled word, 2026-08-29), else the legacy alias it was seeded with. New
|
|
141
|
+
* slices always land on `state`. */
|
|
142
|
+
private faceKey(slice: Record<string, unknown>): "state" | "view" | "defaultState" {
|
|
143
|
+
if ("state" in slice) return "state";
|
|
144
|
+
if ("view" in slice) return "view";
|
|
145
|
+
if ("defaultState" in slice) return "defaultState";
|
|
146
|
+
return "state";
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
private retractSlice(key: string, slice: Record<string, unknown>): boolean {
|
|
133
|
-
const current = (slice.view ?? slice.defaultState) as unknown;
|
|
150
|
+
const current = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
134
151
|
const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
|
|
135
152
|
if (typeof current !== "string" || current === base) return false;
|
|
136
153
|
const next: Record<string, unknown> = { ...slice };
|
|
137
|
-
|
|
154
|
+
next[this.faceKey(next)] = base;
|
|
138
155
|
if ("defaultState" in next) next.defaultState = base;
|
|
139
156
|
this.data.set(key, next);
|
|
140
157
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -210,10 +227,10 @@ export class ComponentStore {
|
|
|
210
227
|
for (const [key, slice] of this.data) {
|
|
211
228
|
if (arrivingChatId && key.startsWith(`${arrivingChatId}:`)) continue;
|
|
212
229
|
if (slice.lifetime === "conversation") continue;
|
|
213
|
-
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
230
|
+
const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
214
231
|
if (typeof v !== "string" || !v || v === "inline") continue;
|
|
215
232
|
const next: Record<string, unknown> = { ...slice };
|
|
216
|
-
|
|
233
|
+
next[this.faceKey(next)] = "inline";
|
|
217
234
|
if ("defaultState" in next) next.defaultState = "inline";
|
|
218
235
|
this.data.set(key, next);
|
|
219
236
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -227,11 +244,17 @@ export class ComponentStore {
|
|
|
227
244
|
let changed = false;
|
|
228
245
|
for (const [key, slice] of this.data) {
|
|
229
246
|
if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
|
|
247
|
+
// A DIRECTOR-HELD slice is under the director's authority (§12e): its
|
|
248
|
+
// replacement belongs to the settle, never to the mechanical refresh — a
|
|
249
|
+
// new batch's first card must not retract the held shelf mid-search (the
|
|
250
|
+
// between-turns flicker, observed live after the durable shield retired).
|
|
251
|
+
// Un-directed apps are untouched: no verdict, no hold, rule 5 as ever.
|
|
252
|
+
if (this.directorHeld?.has(key)) continue;
|
|
230
253
|
if (slice.lifetime === "conversation") continue;
|
|
231
|
-
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
254
|
+
const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
232
255
|
if (v !== view) continue;
|
|
233
256
|
const next: Record<string, unknown> = { ...slice };
|
|
234
|
-
|
|
257
|
+
next[this.faceKey(next)] = "inline";
|
|
235
258
|
if ("defaultState" in next) next.defaultState = "inline";
|
|
236
259
|
this.data.set(key, next);
|
|
237
260
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -257,8 +280,8 @@ export class ComponentStore {
|
|
|
257
280
|
let changed = false;
|
|
258
281
|
for (const [key, slice] of this.data) {
|
|
259
282
|
if (slice.lifetime === "conversation") continue;
|
|
260
|
-
if (!below.has((slice.view ?? slice.defaultState) as string)) continue;
|
|
261
|
-
this.data.set(key, { ...slice, ...("defaultState" in slice ? { defaultState: "inline" } : {
|
|
283
|
+
if (!below.has((slice.state ?? slice.view ?? slice.defaultState) as string)) continue;
|
|
284
|
+
this.data.set(key, { ...slice, [this.faceKey(slice)]: "inline", ...("defaultState" in slice ? { defaultState: "inline" } : {}) });
|
|
262
285
|
this.touches.set(key, ++this.touchSeq);
|
|
263
286
|
changed = true;
|
|
264
287
|
}
|
|
@@ -331,6 +354,24 @@ export class ComponentStore {
|
|
|
331
354
|
this.bump();
|
|
332
355
|
}
|
|
333
356
|
|
|
357
|
+
/**
|
|
358
|
+
* THE GUEST'S HAND OUTRANKS AN ARRIVAL'S RE-FACE (STATE_MODEL guest-control law,
|
|
359
|
+
* TEMPLATE_MODEL §6b rule 3: the director — and its deliveries — sit UNDER the
|
|
360
|
+
* guest's hand). Once the slice's LATEST write is the guest's own (an open Detail
|
|
361
|
+
* View), an inbound payload keeps its content but loses its face words: a
|
|
362
|
+
* re-delivered card must refresh WHAT the guest is reading, never yank them out
|
|
363
|
+
* of it (observed live 2026-08-29: a broad follow-up re-served the open card,
|
|
364
|
+
* its seeded `view: "grid"` overwrote the guest's `page`, the ladder fell to
|
|
365
|
+
* grid). The guest releases the face themselves (✕); from then on the server
|
|
366
|
+
* writes it again as ever.
|
|
367
|
+
*/
|
|
368
|
+
private shieldGuestFace(key: string, payload: Record<string, unknown>): Record<string, unknown> {
|
|
369
|
+
if (!("state" in payload) && !("view" in payload) && !("defaultState" in payload)) return payload;
|
|
370
|
+
if ((this.interactionTouches.get(key) ?? -1) < (this.touches.get(key) ?? 0)) return payload;
|
|
371
|
+
const { state: _s, view: _v, defaultState: _d, ...content } = payload;
|
|
372
|
+
return content;
|
|
373
|
+
}
|
|
374
|
+
|
|
334
375
|
/** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
|
|
335
376
|
apply(event: UnoverseEvent): void {
|
|
336
377
|
const key = `${event.chatId}:${event.nodeId}`;
|
|
@@ -338,10 +379,10 @@ export class ComponentStore {
|
|
|
338
379
|
this.types.set(key, event.component.type);
|
|
339
380
|
if (event.component.componentUrl) this.urls.set(key, event.component.componentUrl);
|
|
340
381
|
this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
|
|
341
|
-
this.merge(key, event.component.props ?? {}); // data: seed
|
|
382
|
+
this.merge(key, this.shieldGuestFace(key, event.component.props ?? {})); // data: seed
|
|
342
383
|
// Rule 5 — an arrival carrying a public state refreshes that state.
|
|
343
384
|
const seeded = event.component.props as Record<string, unknown> | undefined;
|
|
344
|
-
this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
|
|
385
|
+
this.supersede(event.chatId, seeded?.state ?? seeded?.view ?? seeded?.defaultState);
|
|
345
386
|
} else {
|
|
346
387
|
// NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
|
|
347
388
|
// LAST pointer in its turn (something was placed after it), fresh server data
|
|
@@ -354,10 +395,10 @@ export class ComponentStore {
|
|
|
354
395
|
resp.components.push(key);
|
|
355
396
|
this.bump();
|
|
356
397
|
}
|
|
357
|
-
this.merge(key, event.data);
|
|
398
|
+
this.merge(key, this.shieldGuestFace(key, event.data));
|
|
358
399
|
// Rule 5 — a server data-write that PLACES a component into a public state
|
|
359
400
|
// (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
|
|
360
|
-
this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
|
|
401
|
+
this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.state ?? (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
|
|
361
402
|
}
|
|
362
403
|
}
|
|
363
404
|
|
|
@@ -370,9 +411,11 @@ export class ComponentStore {
|
|
|
370
411
|
* guest's next message; a stale rail is cleared by the cards that replace it,
|
|
371
412
|
* never by the message that asked for them. */
|
|
372
413
|
addUserMessage(chatId: string, text: string): void {
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
414
|
+
// RULE 7 RESTORED (UNOVERSE_TEMPLATE_MODEL §12e ruling 2): typing changes
|
|
415
|
+
// NOTHING. The delivery boundary is the REPLACEMENT — the judged batch's
|
|
416
|
+
// settle (applyDirectorVerdict) or the confirmed-empty clear (spendDelivery)
|
|
417
|
+
// — never the message that asked for it. The held set stays on screen
|
|
418
|
+
// through the whole search.
|
|
376
419
|
this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
|
|
377
420
|
this.evict();
|
|
378
421
|
this.bump();
|
|
@@ -564,11 +607,10 @@ export class ComponentStore {
|
|
|
564
607
|
getTimeline(): readonly Turn[] {
|
|
565
608
|
return this.timeline;
|
|
566
609
|
}
|
|
567
|
-
/** THE CURRENT DELIVERY (hold: latest
|
|
568
|
-
* the pointers placed since the
|
|
569
|
-
* these
|
|
570
|
-
*
|
|
571
|
-
* delivery's. */
|
|
610
|
+
/** THE CURRENT DELIVERY (hold: latest — UNOVERSE_TEMPLATE_MODEL §9b, boundary per
|
|
611
|
+
* §12e): the pointers placed since the last SETTLE or CLEAR. A mounted template
|
|
612
|
+
* holds only these; the set survives the guest's next send and is spent exactly
|
|
613
|
+
* when its replacement arrives (or emptiness is confirmed) — never sooner. */
|
|
572
614
|
currentDeliveryPointers(): Set<string> {
|
|
573
615
|
// TOUCH-BASED, not turn-based: durable-keyed instances (conversation-chatId
|
|
574
616
|
// renders) file into ONE assistant turn across the whole conversation, so turn
|
|
@@ -581,6 +623,47 @@ export class ComponentStore {
|
|
|
581
623
|
if ((this.touches.get(p) ?? 0) > this.deliveryWatermark) out.add(p);
|
|
582
624
|
return out;
|
|
583
625
|
}
|
|
626
|
+
/** THE DIRECTOR'S HOLD (UNOVERSE_TEMPLATE_MODEL §7): the verdict's pointers that are
|
|
627
|
+
* still in the given delivery. null = no verdict, or nothing it held survives — the
|
|
628
|
+
* caller falls back to the whole delivery (fail open, §6c rule 9: the screen never
|
|
629
|
+
* pays for a stale or broken verdict). */
|
|
630
|
+
directorScope(delivery: Set<string>): Set<string> | null {
|
|
631
|
+
if (!this.directorHeld) return null;
|
|
632
|
+
const out = new Set<string>();
|
|
633
|
+
for (const p of this.directorHeld) if (delivery.has(p)) out.add(p);
|
|
634
|
+
return out.size ? out : null;
|
|
635
|
+
}
|
|
636
|
+
/** Apply a judged DIRECTOR_VERDICT: these instances are the delivery's held set, in
|
|
637
|
+
* this order. Each item carries the chatId it was DELIVERED under (the turn's, or
|
|
638
|
+
* the conversation's for durable renders) so the pointer is exact. ORDER IS RECENCY
|
|
639
|
+
* — the strongest is re-stamped LAST so the existing touch-desc sort draws it
|
|
640
|
+
* first: the platform's one ordering word, no new machinery. Only slices that
|
|
641
|
+
* exist are stamped; unknown ids are ignored (selection only — a verdict can
|
|
642
|
+
* never invent an instance). */
|
|
643
|
+
applyDirectorVerdict(items: Array<{ chatId: string; nodeId: string }>): void {
|
|
644
|
+
// THE SETTLE IS THE DELIVERY BOUNDARY (§12e ruling 2): spend everything
|
|
645
|
+
// older, then stamp the held set above the new line — one atomic replace,
|
|
646
|
+
// so the previous cards survive the whole search and leave exactly when
|
|
647
|
+
// their replacement arrives.
|
|
648
|
+
this.deliveryWatermark = this.touchSeq;
|
|
649
|
+
const held = new Set<string>();
|
|
650
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
651
|
+
const p = `${items[i].chatId}:${items[i].nodeId}`;
|
|
652
|
+
if (!this.data.has(p)) continue;
|
|
653
|
+
held.add(p);
|
|
654
|
+
this.touches.set(p, ++this.touchSeq);
|
|
655
|
+
}
|
|
656
|
+
this.directorHeld = held.size ? held : null;
|
|
657
|
+
this.bump();
|
|
658
|
+
}
|
|
659
|
+
/** The confirmed-empty boundary (COMPONENTS_CLEAR): the run delivered nothing,
|
|
660
|
+
* so the current set is spent with nothing replacing it — the mount collapses.
|
|
661
|
+
* Same spend as the settle, without a new held set. */
|
|
662
|
+
spendDelivery(): void {
|
|
663
|
+
this.deliveryWatermark = this.touchSeq;
|
|
664
|
+
this.directorHeld = null;
|
|
665
|
+
this.bump();
|
|
666
|
+
}
|
|
584
667
|
/** Just the assistant responses, in order. */
|
|
585
668
|
getResponses(): AssistantTurn[] {
|
|
586
669
|
return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The double-buffered mount (UNOVERSE_TEMPLATE_MODEL §13.3): renders its LAST
|
|
3
|
+
* COMMITTED arrangement and commits the next one only when the decision allows —
|
|
4
|
+
* so a data ripple can never move the shelf, and the boundary commit is the one
|
|
5
|
+
* designed (and, with theme motion, animated) change. Content inside held
|
|
6
|
+
* instances stays live automatically: the render closure reads slices from the
|
|
7
|
+
* store at render time; only membership and order are buffered here.
|
|
8
|
+
*
|
|
9
|
+
* Fail open (§13.5): a "hold" with no boundary inside the grace window commits
|
|
10
|
+
* anyway — the screen never pays for machinery failure.
|
|
11
|
+
*
|
|
12
|
+
* RENDER-LAYER state, deliberately: the portable core stays frozen and "no UI
|
|
13
|
+
* state in the store" holds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { decideCommit, type Arrangement } from "./decide";
|
|
19
|
+
import { commitArrangement } from "./viewTransition";
|
|
20
|
+
|
|
21
|
+
/** A held-back change commits anyway after this long without a boundary. */
|
|
22
|
+
const GRACE_MS = 4000;
|
|
23
|
+
|
|
24
|
+
export function MountArrangement({
|
|
25
|
+
next,
|
|
26
|
+
directed,
|
|
27
|
+
motion,
|
|
28
|
+
render,
|
|
29
|
+
}: {
|
|
30
|
+
/** The arrangement the store derives RIGHT NOW. */
|
|
31
|
+
next: Arrangement;
|
|
32
|
+
/** The placement grants a director — every delivery settles, so membership
|
|
33
|
+
* waits for the settle (decide.ts). */
|
|
34
|
+
directed: boolean;
|
|
35
|
+
/** Theme-requested motion (§13.4) — absent theme word = instant commits. */
|
|
36
|
+
motion: boolean;
|
|
37
|
+
/** Build the tree for an arrangement; slices are read live inside. */
|
|
38
|
+
render: (arrangement: Arrangement) => ReactNode;
|
|
39
|
+
}) {
|
|
40
|
+
const [committed, setCommitted] = useState<Arrangement | null>(null);
|
|
41
|
+
const graceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
|
+
const decision = decideCommit(committed, next, directed);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (decision === "none") return;
|
|
46
|
+
if (decision === "hold") {
|
|
47
|
+
if (!graceTimer.current) {
|
|
48
|
+
graceTimer.current = setTimeout(() => {
|
|
49
|
+
graceTimer.current = null;
|
|
50
|
+
setCommitted(next);
|
|
51
|
+
}, GRACE_MS);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (graceTimer.current) {
|
|
56
|
+
clearTimeout(graceTimer.current);
|
|
57
|
+
graceTimer.current = null;
|
|
58
|
+
}
|
|
59
|
+
if (decision === "transition") commitArrangement(() => setCommitted(next), motion);
|
|
60
|
+
else setCommitted(next);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
useEffect(
|
|
64
|
+
() => () => {
|
|
65
|
+
if (graceTimer.current) clearTimeout(graceTimer.current);
|
|
66
|
+
},
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return <>{render(committed ?? next)}</>;
|
|
71
|
+
}
|