@unoverse-platform/studio 0.1.86 → 0.1.88
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
CHANGED
|
@@ -94,6 +94,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
94
94
|
const [templates, setTemplates] = useState<TemplateDef[]>([]);
|
|
95
95
|
const [selected, setSelected] = usePersistentState<string>("wb:template", "");
|
|
96
96
|
const [panelOpen, setPanelOpen] = useState(true);
|
|
97
|
+
const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:templates:meta", false); // manifest meta panel
|
|
97
98
|
const [panelHeight, setPanelHeight] = useState(200);
|
|
98
99
|
const [stageWidth, setStageWidth] = useState<number | null>(768);
|
|
99
100
|
|
|
@@ -247,6 +248,24 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
247
248
|
label="Available"
|
|
248
249
|
/>
|
|
249
250
|
)}
|
|
251
|
+
{/* META — the same disclosure Components and Apps carry, on the same
|
|
252
|
+
mandatory manifest (§9c: a template is discovered like everything
|
|
253
|
+
else, so what it is DISCOVERED BY must be readable where it is
|
|
254
|
+
designed). Disclosure only; it changes nothing. */}
|
|
255
|
+
{current?.spatial && (
|
|
256
|
+
<button
|
|
257
|
+
onClick={() => setMetaOpen((o) => !o)}
|
|
258
|
+
className="flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium"
|
|
259
|
+
style={{
|
|
260
|
+
borderColor: theme.color["border.subtle"],
|
|
261
|
+
color: metaOpen ? theme.color["text.primary"] : theme.color["text.secondary"],
|
|
262
|
+
background: metaOpen ? theme.color["surface.sunken"] : "transparent",
|
|
263
|
+
}}
|
|
264
|
+
title="Show the template's manifest (spatial discovery meta)"
|
|
265
|
+
>
|
|
266
|
+
ⓘ Meta <span className="opacity-60">{metaOpen ? "▴" : "▾"}</span>
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
250
269
|
{shapeNames.length > 1 && (
|
|
251
270
|
<div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
|
|
252
271
|
{shapeNames.map((name, i) => {
|
|
@@ -312,6 +331,42 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
312
331
|
</span>
|
|
313
332
|
</div>
|
|
314
333
|
|
|
334
|
+
{/* MANIFEST META — identical anatomy to the Components and Apps panels:
|
|
335
|
+
title / category chip / muted tag, description, when-to-use callout. */}
|
|
336
|
+
{metaOpen && current?.spatial && (() => {
|
|
337
|
+
const accent = theme.color["action.primary"] ?? "#6366f1";
|
|
338
|
+
return (
|
|
339
|
+
<div className="border-b px-5 py-4" style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}>
|
|
340
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-1">
|
|
341
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
342
|
+
<h2 className="text-[17px] font-semibold leading-tight tracking-tight" style={{ color: theme.color["text.primary"] }}>
|
|
343
|
+
{current.title || current.name}
|
|
344
|
+
</h2>
|
|
345
|
+
{current.category && (
|
|
346
|
+
<span className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style={{ background: `${accent}14`, color: accent }}>
|
|
347
|
+
{current.category}
|
|
348
|
+
</span>
|
|
349
|
+
)}
|
|
350
|
+
<span className="font-mono text-[10px]" style={{ color: theme.color["text.tertiary"] }}>
|
|
351
|
+
manifest.json
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
{current.description && (
|
|
355
|
+
<p className="mt-1 max-w-3xl text-[13px] leading-relaxed" style={{ color: theme.color["text.secondary"] }}>{current.description}</p>
|
|
356
|
+
)}
|
|
357
|
+
{current.whenToUse && (
|
|
358
|
+
<div className="mt-2 max-w-3xl rounded-r py-1.5 pl-3" style={{ borderLeft: `3px solid ${accent}`, background: theme.color["surface.sunken"] }}>
|
|
359
|
+
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
|
|
360
|
+
When to use
|
|
361
|
+
</div>
|
|
362
|
+
<p className="text-[15px] font-medium leading-relaxed" style={{ color: theme.color["text.primary"] }}>{current.whenToUse}</p>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
})()}
|
|
369
|
+
|
|
315
370
|
<div className="flex-1 overflow-auto p-10">
|
|
316
371
|
<div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
|
|
317
372
|
{uri && <StreamedUnoverseApp key={uri} client={client} store={store} uri={uri} theme={theme} />}
|
package/vendor/sdk/lib/app.tsx
CHANGED
|
@@ -331,11 +331,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
331
331
|
*/
|
|
332
332
|
const tstate = store.getAppState() as Record<string, unknown>;
|
|
333
333
|
const dismissedRaw = tstate.latchDismissed;
|
|
334
|
-
const held = def ? resolveLatch(def, store, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
|
|
334
|
+
const held = def ? resolveLatch(def, store, ladder.view, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
|
|
335
335
|
useEffect(() => {
|
|
336
336
|
if (dismissedRaw === true) {
|
|
337
337
|
// Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
|
|
338
|
-
const current = def ? resolveLatch(def, store) : null;
|
|
338
|
+
const current = def ? resolveLatch(def, store, ladder.view) : null;
|
|
339
339
|
store.mergeAppState({ latchDismissed: current ? current.key : "" });
|
|
340
340
|
return;
|
|
341
341
|
}
|
|
@@ -353,6 +353,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
353
353
|
held?.key,
|
|
354
354
|
held?.latch,
|
|
355
355
|
dismissedRaw,
|
|
356
|
+
ladder.view,
|
|
356
357
|
def,
|
|
357
358
|
store,
|
|
358
359
|
tstate.latched,
|
|
@@ -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],
|
|
@@ -271,17 +271,25 @@ export function resolveActiveView(
|
|
|
271
271
|
* conversation is currently ADDRESSED AT, or none.
|
|
272
272
|
*
|
|
273
273
|
* DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
|
|
274
|
-
* registers a latch and nothing unregisters one.
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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.
|
|
279
281
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
282
|
+
* THE HOLDER MUST BE THE STATE ON SCREEN, which is why `active` is a required argument
|
|
283
|
+
* rather than something derived again here. Being ON the ladder is not enough: an app whose
|
|
284
|
+
* ladder is [page, perfect-day] shows `page` the moment a card arrives wearing that face,
|
|
285
|
+
* and the composed page — still live, still ranked, just not showing — went on holding the
|
|
286
|
+
* latch. The guest then reads a pill naming a page that is nowhere on screen and types into
|
|
287
|
+
* it (observed live 2026-08-31: three search cards took the surface and "Your Perfect Day"
|
|
288
|
+
* stayed up over them). A pill is a promise about what the words will reach.
|
|
289
|
+
*
|
|
290
|
+
* SAME WALK AS `resolveActiveView` otherwise: the highest-ranked wins, ties going to the
|
|
291
|
+
* most recent write. So a page retracted to `inline` (superseded by an arrival, released by
|
|
292
|
+
* `cancelBelow`, closed by the guest) silently stops holding it too.
|
|
285
293
|
*
|
|
286
294
|
* Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
|
|
287
295
|
* turn carrying two components would hydrate the wrong one.
|
|
@@ -289,15 +297,23 @@ export function resolveActiveView(
|
|
|
289
297
|
export function resolveLatch(
|
|
290
298
|
def: UnoverseDefinition | null | undefined,
|
|
291
299
|
store: ComponentStore,
|
|
300
|
+
/** The state the app is ACTUALLY showing (`resolveActiveView().view`). Empty = nothing is
|
|
301
|
+
* surfaced, so nothing can be addressed. */
|
|
302
|
+
active: string,
|
|
292
303
|
dismissed?: string,
|
|
293
|
-
): { key: string; chatId: string; nodeId: string; latch:
|
|
304
|
+
): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
|
|
294
305
|
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
295
306
|
if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
|
|
307
|
+
const latches = def?.latches;
|
|
308
|
+
if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
|
|
309
|
+
// WHAT IS ON SCREEN, first: an addressable state that is not the active one is not being
|
|
310
|
+
// shown, and a pill over someone else's surface points at nothing the guest can see.
|
|
311
|
+
if (!active || !latches[active]) return null;
|
|
296
312
|
// `key` is the component key this candidate came from, carried so the return can name the
|
|
297
313
|
// FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
|
|
298
314
|
// not the component inside it.
|
|
299
315
|
let best:
|
|
300
|
-
| { key: string; chatId: string; nodeId: string; latch:
|
|
316
|
+
| { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
|
|
301
317
|
| null = null;
|
|
302
318
|
for (const turn of store.getResponses())
|
|
303
319
|
for (const p of turn.components) {
|
|
@@ -308,15 +324,20 @@ export function resolveLatch(
|
|
|
308
324
|
if (dismissed && dismissed === p) continue;
|
|
309
325
|
const { chatId, nodeId } = store.split(p);
|
|
310
326
|
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
311
|
-
const latch = slice?.latch;
|
|
312
|
-
if (!latch || typeof latch !== "object") continue;
|
|
313
327
|
const view = viewOf(slice);
|
|
314
328
|
if (!view) continue;
|
|
315
329
|
const rank = order.indexOf(view);
|
|
316
330
|
if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
|
|
331
|
+
// THE STATE DECIDES, and it must be the state SHOWING. Same lookup for every instance,
|
|
332
|
+
// so nothing has to be told it is addressable and nothing has to be untold — the face
|
|
333
|
+
// it currently wears is the whole question, and a face that is not the active one is
|
|
334
|
+
// not on screen to be addressed.
|
|
335
|
+
if (view !== active) continue;
|
|
336
|
+
const latch = latches[view];
|
|
337
|
+
if (!latch) continue;
|
|
317
338
|
const at = store.touchOf(p);
|
|
318
339
|
if (!best || rank < best.rank || (rank === best.rank && at > best.at))
|
|
319
|
-
best = { key: p, chatId, nodeId, latch
|
|
340
|
+
best = { key: p, chatId, nodeId, latch, view, rank, at };
|
|
320
341
|
}
|
|
321
342
|
return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
|
|
322
343
|
}
|
|
@@ -208,6 +208,14 @@ export interface UnoverseDefinition {
|
|
|
208
208
|
* ladder and arrival-scan order, served from the manifest. First entry outranks
|
|
209
209
|
* all below it; no name is special. Absent → legacy v1 recency semantics. */
|
|
210
210
|
stateOrder?: string[];
|
|
211
|
+
/** THE LATCH, PER STATE (docs/MCP_COMPLETE_GUIDE.md §The Component Latch, moved off the
|
|
212
|
+
* manifest 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT,
|
|
213
|
+
* keyed by state name, compiled from each state's own `latch:` block.
|
|
214
|
+
*
|
|
215
|
+
* A state absent from this map can never hold the latch, so the SAME instance retracted
|
|
216
|
+
* from its page state to an inline one drops the pill by construction. `resolveLatch`
|
|
217
|
+
* reads this; nothing about the latch travels on the wire with a render. */
|
|
218
|
+
latches?: Record<string, { title: string; icon?: string }>;
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|