@unoverse-platform/studio 0.1.86 → 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
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} />}
|
|
@@ -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,11 +271,13 @@ 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
282
|
* SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
|
|
281
283
|
* slice must be sitting in one of the template's declared states to hold the latch, and
|
|
@@ -290,14 +292,16 @@ export function resolveLatch(
|
|
|
290
292
|
def: UnoverseDefinition | null | undefined,
|
|
291
293
|
store: ComponentStore,
|
|
292
294
|
dismissed?: string,
|
|
293
|
-
): { key: string; chatId: string; nodeId: string; latch:
|
|
295
|
+
): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
|
|
294
296
|
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
295
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
|
|
296
300
|
// `key` is the component key this candidate came from, carried so the return can name the
|
|
297
301
|
// FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
|
|
298
302
|
// not the component inside it.
|
|
299
303
|
let best:
|
|
300
|
-
| { 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 }
|
|
301
305
|
| null = null;
|
|
302
306
|
for (const turn of store.getResponses())
|
|
303
307
|
for (const p of turn.components) {
|
|
@@ -308,15 +312,18 @@ export function resolveLatch(
|
|
|
308
312
|
if (dismissed && dismissed === p) continue;
|
|
309
313
|
const { chatId, nodeId } = store.split(p);
|
|
310
314
|
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
311
|
-
const latch = slice?.latch;
|
|
312
|
-
if (!latch || typeof latch !== "object") continue;
|
|
313
315
|
const view = viewOf(slice);
|
|
314
316
|
if (!view) continue;
|
|
315
317
|
const rank = order.indexOf(view);
|
|
316
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;
|
|
317
324
|
const at = store.touchOf(p);
|
|
318
325
|
if (!best || rank < best.rank || (rank === best.rank && at > best.at))
|
|
319
|
-
best = { key: p, chatId, nodeId, latch
|
|
326
|
+
best = { key: p, chatId, nodeId, latch, view, rank, at };
|
|
320
327
|
}
|
|
321
328
|
return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
|
|
322
329
|
}
|
|
@@ -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) ----
|