@unoverse-platform/studio 0.1.85 → 0.1.87
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 +197 -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/connection.tsx +34 -33
- package/vendor/sdk/lib/core/actions.ts +2 -2
- package/vendor/sdk/lib/core/apps.ts +31 -18
- 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/core/types.ts +8 -0
- 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
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* definition, and `setValue` writes the same instance either way.
|
|
15
15
|
*/
|
|
16
16
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
17
|
-
import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
18
|
-
import { renderNode, keyframesCss, type ActionHandler } from "./render";
|
|
17
|
+
import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
18
|
+
import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
|
|
19
19
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
20
20
|
import { IsolatedRoot } from "./isolate";
|
|
21
21
|
|
|
@@ -192,7 +192,53 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
192
192
|
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
193
193
|
// The shared pieces this definition NAMES travel beside it and are threaded through
|
|
194
194
|
// every recursion, so a reference nested in a Switch branch or an Each item resolves too.
|
|
195
|
-
|
|
195
|
+
// A DEFINITION THAT CARRIES SLOTS RESOLVES THEM — the rule the SDK already applies to
|
|
196
|
+
// an app tree, applied here too. Without it a rendered TEMPLATE drew its frame and every
|
|
197
|
+
// section resolved to null (render.tsx: `slot ? slot(node) : null`), so a page that
|
|
198
|
+
// STREAMED IN rather than being mounted by an app came out blank. Mounted templates were
|
|
199
|
+
// fine because `app.tsx` hands that path a resolver; instances never got one, and a
|
|
200
|
+
// definition rendering its own parts nowhere is a renderer bug, not a design decision.
|
|
201
|
+
//
|
|
202
|
+
// Generic and dumb, like the rest of it: the slot's claim picks instances out of the
|
|
203
|
+
// SAME store by the face they wear, in the order the store holds them. It knows nothing
|
|
204
|
+
// about templates, days or cards — only "which instances match this claim".
|
|
205
|
+
//
|
|
206
|
+
// TEMPLATES ONLY, and the boundary matters: an ordinary component may carry the
|
|
207
|
+
// conversation's FLOW slot (`select: {}` inside a chat's assistant turn), and that one
|
|
208
|
+
// belongs to the app's timeline, which scopes it to a single turn's unclaimed
|
|
209
|
+
// components. Resolving it here too rendered every instance in the store a second time
|
|
210
|
+
// — the composed page appeared in its surface AND in the chat beside it.
|
|
211
|
+
const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
|
|
212
|
+
? (node, key) => {
|
|
213
|
+
const claim = node.select?.where;
|
|
214
|
+
const pointers = store
|
|
215
|
+
.getResponses()
|
|
216
|
+
.flatMap((r) => r.components)
|
|
217
|
+
.filter((p) => {
|
|
218
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
219
|
+
if (n === nodeId) return false; // never claim the instance drawing the slot
|
|
220
|
+
return claim ? matches(claim, (store.get(c, n) ?? {}) as Record<string, unknown>) : true;
|
|
221
|
+
})
|
|
222
|
+
.slice(0, node.select?.limit ?? undefined);
|
|
223
|
+
if (!pointers.length)
|
|
224
|
+
return node.fallback ? renderNode(node.fallback, {}, handleAction, activeTheme, key ?? "fb", undefined, def.defs) : null;
|
|
225
|
+
return pointers.map((p) => {
|
|
226
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
227
|
+
return (
|
|
228
|
+
<UnoverseComponent
|
|
229
|
+
key={n}
|
|
230
|
+
client={client}
|
|
231
|
+
store={store}
|
|
232
|
+
chatId={c}
|
|
233
|
+
nodeId={n}
|
|
234
|
+
onAction={onAction}
|
|
235
|
+
theme={activeTheme}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
: undefined;
|
|
241
|
+
const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, resolveSlot, def.defs);
|
|
196
242
|
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
197
243
|
// the component owns its size; the host responds). A root that declares
|
|
198
244
|
// `height: "full"` needs a definite chain — the container passes the host's height
|
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>;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
15
15
|
import { emitAnalytics } from "./core/analytics";
|
|
16
|
-
import { applyServerMessage,
|
|
16
|
+
import { applyServerMessage, viewOf, type ComponentStore, type ServerMessage } from "./core";
|
|
17
17
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
18
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
19
19
|
// Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
|
|
@@ -344,58 +344,59 @@ export function useUnoverseConnection(
|
|
|
344
344
|
(text: string) => {
|
|
345
345
|
const message = text.trim();
|
|
346
346
|
if (!message) return;
|
|
347
|
-
const turn = mintTurn(); // ONE turn id shared by the user message, the assistant response, and the run
|
|
348
|
-
store.addUserMessage(turn, message); // optimistic user turn
|
|
349
|
-
// Optimistically open the assistant turn too, so the avatar + thinking dots
|
|
350
|
-
// appear IMMEDIATELY on send instead of waiting for the backend's
|
|
351
|
-
// WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
|
|
352
|
-
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
|
|
353
|
-
store.startResponse(turn);
|
|
354
347
|
/**
|
|
355
348
|
* THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
|
|
356
349
|
*
|
|
357
|
-
*
|
|
350
|
+
* Handled HERE rather than in a host, because both hosts call this one function and
|
|
358
351
|
* two hosts each deciding "what happens on send" is how the composer regression
|
|
359
|
-
* happened.
|
|
360
|
-
* belongs in the timeline; the latch only tells the workflow WHERE the answer lands.
|
|
352
|
+
* happened.
|
|
361
353
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
354
|
+
* READ, NOT RE-DERIVED. `app.tsx` runs `resolveLatch` every render and publishes
|
|
355
|
+
* `latched`/`latchKey` into app state for the composer's pill; this reads that same
|
|
356
|
+
* published fact. Deriving it again here is what left the send inert for two weeks:
|
|
357
|
+
* `resolveLatch` needs the app DEFINITION and this file holds a raw MCP `Client` with
|
|
358
|
+
* no definition cache, so the second derivation could never see `def.latches` and
|
|
359
|
+
* silently answered "not latched" on every send.
|
|
366
360
|
*/
|
|
361
|
+
const app = store.getAppState() as Record<string, unknown>;
|
|
362
|
+
const held =
|
|
363
|
+
app.latched === true && typeof app.latchKey === "string" && app.latchKey ? store.split(app.latchKey) : null;
|
|
367
364
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* `peekDefinition` is the SYNCHRONOUS cached read the send path needs (a send cannot
|
|
371
|
-
* await a resources/read), and it lives on `UnoverseClient` — the wrapper holding the
|
|
372
|
-
* definition cache, constructed in appEngine.tsx. THIS file holds a raw MCP `Client`
|
|
373
|
-
* (line 137), which has no cache and no such method, so the lookup finds nothing here.
|
|
365
|
+
* THE RUN REUSES THE LATCHED TURN; the guest's message does not.
|
|
374
366
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
367
|
+
* The user turn is minted normally either way — the guest said something and it
|
|
368
|
+
* belongs in the timeline where they said it. What the latch changes is where the
|
|
369
|
+
* ANSWER opens: `startResponse` and the trigger both take the held component's own
|
|
370
|
+
* chatId, so every render in that run keys `chatId:nodeId` to what is already on
|
|
371
|
+
* screen, and a repeated COMPONENT_INIT on a seen key is already a props merge
|
|
372
|
+
* (applyServerMessage). Hydrate in place, by construction, with no new return path.
|
|
379
373
|
*/
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
374
|
+
const turn = mintTurn();
|
|
375
|
+
store.addUserMessage(turn, message); // optimistic user turn
|
|
376
|
+
const run = held ? held.chatId : turn;
|
|
377
|
+
// Optimistically open the assistant turn too, so the avatar + thinking dots
|
|
378
|
+
// appear IMMEDIATELY on send instead of waiting for the backend's
|
|
379
|
+
// WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
|
|
380
|
+
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId), and
|
|
381
|
+
// while latched that turn is the page's own, which is exactly the point.
|
|
382
|
+
store.startResponse(run);
|
|
386
383
|
trigger(
|
|
387
384
|
held
|
|
388
385
|
? {
|
|
389
386
|
message,
|
|
387
|
+
// `state` is the component's live slice, so the workflow is told what the page
|
|
388
|
+
// currently holds without a round trip, and it stays right after local
|
|
389
|
+
// `setValue` writes the server never saw. Sent as one `latch` object so an
|
|
390
|
+
// unlatched send is byte-for-byte what it was before.
|
|
390
391
|
latch: {
|
|
391
392
|
chatId: held.chatId,
|
|
392
393
|
nodeId: held.nodeId,
|
|
393
|
-
view: held.
|
|
394
|
+
view: viewOf(store.get(held.chatId, held.nodeId) as Record<string, unknown>) ?? "",
|
|
394
395
|
state: store.get(held.chatId, held.nodeId) ?? {},
|
|
395
396
|
},
|
|
396
397
|
}
|
|
397
398
|
: { message },
|
|
398
|
-
|
|
399
|
+
run,
|
|
399
400
|
);
|
|
400
401
|
},
|
|
401
402
|
[mintTurn, store, trigger],
|
|
@@ -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
|
|
@@ -270,11 +271,13 @@ export function resolveActiveView(
|
|
|
270
271
|
* conversation is currently ADDRESSED AT, or none.
|
|
271
272
|
*
|
|
272
273
|
* DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
|
|
273
|
-
* registers a latch and nothing unregisters one.
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
274
|
+
* registers a latch and nothing unregisters one. THE STATE IS WHAT IS LATCHABLE, not the
|
|
275
|
+
* component (moved off the manifest 2026-08-31): an instance holds the latch while it is
|
|
276
|
+
* wearing a state the app declared a `latch` on, and stops the moment it wears one that
|
|
277
|
+
* says nothing — retracted inline, superseded, evicted, all the same answer with no code.
|
|
278
|
+
* There is no set, no stack, and no "latch this one too" — this function returns ONE
|
|
279
|
+
* holder or null, so two latches are not a bug to prevent but a state that cannot be
|
|
280
|
+
* represented.
|
|
278
281
|
*
|
|
279
282
|
* SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
|
|
280
283
|
* slice must be sitting in one of the template's declared states to hold the latch, and
|
|
@@ -289,14 +292,16 @@ export function resolveLatch(
|
|
|
289
292
|
def: UnoverseDefinition | null | undefined,
|
|
290
293
|
store: ComponentStore,
|
|
291
294
|
dismissed?: string,
|
|
292
|
-
): { key: string; chatId: string; nodeId: string; latch:
|
|
295
|
+
): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
|
|
293
296
|
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
294
297
|
if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
|
|
298
|
+
const latches = def?.latches;
|
|
299
|
+
if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
|
|
295
300
|
// `key` is the component key this candidate came from, carried so the return can name the
|
|
296
301
|
// FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
|
|
297
302
|
// not the component inside it.
|
|
298
303
|
let best:
|
|
299
|
-
| { key: string; chatId: string; nodeId: string; latch:
|
|
304
|
+
| { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
|
|
300
305
|
| null = null;
|
|
301
306
|
for (const turn of store.getResponses())
|
|
302
307
|
for (const p of turn.components) {
|
|
@@ -307,15 +312,18 @@ export function resolveLatch(
|
|
|
307
312
|
if (dismissed && dismissed === p) continue;
|
|
308
313
|
const { chatId, nodeId } = store.split(p);
|
|
309
314
|
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
310
|
-
const latch = slice?.latch;
|
|
311
|
-
if (!latch || typeof latch !== "object") continue;
|
|
312
315
|
const view = viewOf(slice);
|
|
313
316
|
if (!view) continue;
|
|
314
317
|
const rank = order.indexOf(view);
|
|
315
318
|
if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
|
|
319
|
+
// THE STATE DECIDES. Same lookup for every instance, so nothing has to be told it is
|
|
320
|
+
// addressable and nothing has to be untold — the face it currently wears is the whole
|
|
321
|
+
// question, and a face with no `latch:` is simply not in this map.
|
|
322
|
+
const latch = latches[view];
|
|
323
|
+
if (!latch) continue;
|
|
316
324
|
const at = store.touchOf(p);
|
|
317
325
|
if (!best || rank < best.rank || (rank === best.rank && at > best.at))
|
|
318
|
-
best = { key: p, chatId, nodeId, latch
|
|
326
|
+
best = { key: p, chatId, nodeId, latch, view, rank, at };
|
|
319
327
|
}
|
|
320
328
|
return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
|
|
321
329
|
}
|
|
@@ -323,11 +331,16 @@ export function resolveLatch(
|
|
|
323
331
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
324
332
|
export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
|
|
325
333
|
const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
|
|
334
|
+
// A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
|
|
335
|
+
// the boundary — never the latest assistant turn. The guest's send opens a new
|
|
336
|
+
// (empty) turn immediately, so latest-turn scoping emptied the mount at every
|
|
337
|
+
// send and refilled it as renders landed: the shelf flicker ruled out in
|
|
338
|
+
// UNOVERSE_TEMPLATE_MODEL §12e. The delivery scope is touch-based for exactly
|
|
339
|
+
// this reason (turn position says nothing about freshness).
|
|
326
340
|
let pointers =
|
|
327
|
-
sel.from === "all"
|
|
341
|
+
sel.from === "all" || scope
|
|
328
342
|
? store.getResponses().flatMap((r) => r.components)
|
|
329
343
|
: (store.latestResponse()?.components ?? []);
|
|
330
|
-
// A SCOPED selection (the mount's hold: latest) sees only the given pointers.
|
|
331
344
|
if (scope) pointers = pointers.filter((p) => scope.has(p));
|
|
332
345
|
|
|
333
346
|
// `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": {
|