@unoverse-platform/studio 0.1.26 → 0.1.27
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/AtomsView.tsx +40 -2
- package/screens/ComponentsView.tsx +112 -21
- package/screens/NativeAppHost.tsx +8 -4
- package/screens/TemplatesView.tsx +50 -20
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +18 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/index.ts +18 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +252 -0
- package/vendor/sdk/lib/core/types.ts +20 -7
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +157 -0
- package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
- package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
- package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
- package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
- package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/types.ts +14 -0
- package/vendor/sdk/lib/orb/engine/web.ts +98 -0
- package/vendor/sdk/lib/orb/index.ts +20 -0
- package/vendor/sdk/lib/orb/presets.ts +104 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +73 -0
- package/vendor/sdk/lib/primitives.tsx +111 -2
- package/vendor/sdk/lib/render.tsx +9 -5
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +77 -218
- package/vendor/skills/unoverse-create/SKILL.md +18 -18
- package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
- package/vendor/skills/unoverse-create/references/block.md +5 -5
- package/vendor/skills/unoverse-create/references/component.md +150 -75
- package/vendor/skills/unoverse-create/references/node.md +1 -1
- package/vendor/skills/unoverse-create/references/template.md +142 -82
- package/vendor/skills/unoverse-create/references/workflow.md +15 -15
package/package.json
CHANGED
package/screens/AtomsView.tsx
CHANGED
|
@@ -14,6 +14,10 @@ import { usePersistentState } from "studio-host";
|
|
|
14
14
|
interface PropDef {
|
|
15
15
|
type?: string;
|
|
16
16
|
default?: unknown;
|
|
17
|
+
/** Studio-only sample value — demo content that must NEVER reach a live render. A prop's
|
|
18
|
+
* `default` is a RUNTIME fallback, so sample copy cannot live there: it would show in
|
|
19
|
+
* place of an empty stream. Same key ComponentsView reads, for the same reason. */
|
|
20
|
+
preview?: unknown;
|
|
17
21
|
}
|
|
18
22
|
interface AtomDef {
|
|
19
23
|
name: string;
|
|
@@ -55,7 +59,7 @@ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
|
55
59
|
const toText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
|
|
56
60
|
const defaultsOf = (a: AtomDef): Record<string, unknown> =>
|
|
57
61
|
Object.fromEntries(
|
|
58
|
-
Object.entries(a.props ?? {}).map(([k, v]) => [k, v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : v.type === "boolean" ? false : "")]),
|
|
62
|
+
Object.entries(a.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : v.type === "boolean" ? false : "")]),
|
|
59
63
|
);
|
|
60
64
|
const slug = (n: string) => n.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
61
65
|
|
|
@@ -180,7 +184,41 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
|
|
|
180
184
|
|
|
181
185
|
<div className="flex-1 overflow-auto p-10">
|
|
182
186
|
<div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
|
|
183
|
-
{uri &&
|
|
187
|
+
{uri && (
|
|
188
|
+
<UnoverseComponent
|
|
189
|
+
client={client}
|
|
190
|
+
uri={uri}
|
|
191
|
+
data={values}
|
|
192
|
+
theme={theme}
|
|
193
|
+
// TYPING IN A PREVIEW. A controlled Input reports every keystroke as an
|
|
194
|
+
// `input` action carrying WHICH field changed. Rendering from a plain
|
|
195
|
+
// object means this view owns the slice, so it applies that write itself:
|
|
196
|
+
// without it the box shows the control's value and never what was typed.
|
|
197
|
+
onAction={(action, payload) => {
|
|
198
|
+
if (action === "input") {
|
|
199
|
+
const { field, text: typed } = (payload ?? {}) as { field?: string; text?: string };
|
|
200
|
+
if (!field) return;
|
|
201
|
+
setValues((v) => ({ ...v, [field]: typed ?? "" }));
|
|
202
|
+
setText((t) => ({ ...t, [field]: typed ?? "" }));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// A SWITCH FLIPS IN THE PREVIEW. An atom never owns its own write — the
|
|
206
|
+
// HOST supplies the action, because only the host knows which key the
|
|
207
|
+
// answer belongs to. With no host here, a toggle would sit dead on the
|
|
208
|
+
// stage and read as broken, so the preview stands in for one: `toggle`
|
|
209
|
+
// inverts this atom's boolean props, which is exactly what the host's
|
|
210
|
+
// `setValue` would do with the field's real name.
|
|
211
|
+
if (action === "toggle") {
|
|
212
|
+
const flags = Object.entries(current?.props ?? {})
|
|
213
|
+
.filter(([, def]) => def.type === "boolean")
|
|
214
|
+
.map(([key]) => key);
|
|
215
|
+
if (!flags.length) return;
|
|
216
|
+
setValues((v) => ({ ...v, ...Object.fromEntries(flags.map((k) => [k, !v[k]])) }));
|
|
217
|
+
setText((t) => ({ ...t, ...Object.fromEntries(flags.map((k) => [k, String(!values[k])])) }));
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
184
222
|
</div>
|
|
185
223
|
</div>
|
|
186
224
|
</section>
|
|
@@ -8,7 +8,7 @@ import { ComponentStore } from "@unoverse/sdk";
|
|
|
8
8
|
import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
|
|
9
9
|
import { client, authedFetch, hasSourceAccess } from "studio-host";
|
|
10
10
|
import { SourceView } from "./SourceView";
|
|
11
|
-
import { stateActivation, faceOf, switchLayers } from "./states";
|
|
11
|
+
import { stateActivation, faceOf, faceAxisOf, switchLayers } from "./states";
|
|
12
12
|
import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
13
13
|
|
|
14
14
|
const CHAT_ID = "demo";
|
|
@@ -21,6 +21,9 @@ interface PropDef {
|
|
|
21
21
|
* when seeding the preview stage. */
|
|
22
22
|
preview?: unknown;
|
|
23
23
|
input?: boolean;
|
|
24
|
+
/** Closed value set — the control renders as a dropdown instead of free text
|
|
25
|
+
* (e.g. an orb's nine animation states). Mirrors the node configSchema's enum. */
|
|
26
|
+
enum?: (string | number)[];
|
|
24
27
|
}
|
|
25
28
|
interface ComponentDef {
|
|
26
29
|
name: string;
|
|
@@ -127,6 +130,21 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
127
130
|
setValues(defaults);
|
|
128
131
|
setText(Object.fromEntries(Object.entries(defaults).map(([k, v]) => [k, toText(v)])));
|
|
129
132
|
store.apply({ type: "COMPONENT_INIT", chatId: CHAT_ID, nodeId: c.name, component: { type: c.name, props: defaults } });
|
|
133
|
+
// SEED THE ARRIVAL FACE, from the one place it lives. `manifest.defaultState` is the
|
|
134
|
+
// render contract, and the server merges it into the composed `state` block
|
|
135
|
+
// (definitions.ts — "so the SDK, which merges def.state beneath live data, renders
|
|
136
|
+
// that face on arrival"). The RENDERER honours it; this toolbar reads the live SLICE,
|
|
137
|
+
// which is empty on open, so it fell back to "inline" and highlighted a face that was
|
|
138
|
+
// not on screen. Reading the same `state.defaultState` the renderer resolves keeps one
|
|
139
|
+
// source of truth. Picking a face overwrites it exactly as before, and a component
|
|
140
|
+
// that declares no arrival face still falls back to inline.
|
|
141
|
+
// v2 (STATE_MODEL §5): the compiled scalar `state.view` (the tree's initial) is
|
|
142
|
+
// the arrival face; `state.defaultState` is the legacy spelling. Seed whichever
|
|
143
|
+
// the def declares, under the same key the renderer resolves.
|
|
144
|
+
const st = c.state as Record<string, unknown> | undefined;
|
|
145
|
+
if (typeof st?.view === "string") store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { view: st.view } });
|
|
146
|
+
else if (typeof st?.defaultState === "string")
|
|
147
|
+
store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { defaultState: st.defaultState } });
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
useEffect(() => {
|
|
@@ -155,31 +173,42 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
155
173
|
// that key into the slice — the same generic setValue/COMPONENT_DATA path a real
|
|
156
174
|
// component's own buttons use. `focused` also drives the grow-to-fill container.
|
|
157
175
|
const slice = selected ? store.get(CHAT_ID, selected) : undefined;
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
176
|
+
// The PUBLIC AXIS (STATE_MODEL v2): `view`, with `defaultState`/`displayState` as
|
|
177
|
+
// legacy read fallbacks. Writes go to whichever spelling the root actually switches
|
|
178
|
+
// on (faceAxisOf), so a v2 component and a legacy one both flip.
|
|
179
|
+
const faceAxis = current?.root ? faceAxisOf(current.root) : "defaultState";
|
|
180
|
+
const masterState =
|
|
181
|
+
((slice?.view ?? slice?.defaultState ?? slice?.displayState) as string | undefined) ??
|
|
182
|
+
((current as { initialView?: string } | undefined)?.initialView ?? "inline");
|
|
183
|
+
// A component's FACES come from its OWN root Switch on the public axis — an OPEN
|
|
184
|
+
// name set (inline/focused/course/…), never a hardcoded pair. The BASE face (a v2
|
|
185
|
+
// tree's initial, else `inline`) always leads. A flat component (no Switch) has ONE
|
|
162
186
|
// look; the face control would be a lie, so it's hidden.
|
|
163
187
|
const facesOf = (root: unknown): string[] => {
|
|
164
188
|
const found: string[] = [];
|
|
165
189
|
const walk = (n: any): void => {
|
|
166
190
|
if (!n || typeof n !== "object") return;
|
|
167
191
|
if (Array.isArray(n)) { n.forEach(walk); return; }
|
|
168
|
-
if (n.type === "Switch" && n.on === "defaultState" && n.cases && typeof n.cases === "object") {
|
|
192
|
+
if (n.type === "Switch" && (n.on === "view" || n.on === "defaultState") && n.cases && typeof n.cases === "object") {
|
|
169
193
|
for (const k of Object.keys(n.cases)) if (k !== "default" && !found.includes(k)) found.push(k);
|
|
170
194
|
}
|
|
171
195
|
Object.values(n).forEach(walk);
|
|
172
196
|
};
|
|
173
197
|
walk(root);
|
|
174
|
-
|
|
198
|
+
const base = (current as { initialView?: string } | undefined)?.initialView ?? "inline";
|
|
199
|
+
return [base, ...found.filter((f) => f !== base)];
|
|
175
200
|
};
|
|
176
|
-
|
|
201
|
+
// STATE MODEL v2: a component with a served `stateTree` declares its states — the
|
|
202
|
+
// viewer READS the declaration, never scans layouts (scanning surfaced embedded
|
|
203
|
+
// components' content Switches — a Document's tab cases — as fake states).
|
|
204
|
+
const stateTree = (current as { stateTree?: { name: string; layout?: string; on?: string; initial?: string; states?: { name: string; layout?: string }[] }[] } | undefined)?.stateTree;
|
|
205
|
+
const faces = stateTree?.length ? stateTree.map((s) => s.name) : current?.root ? facesOf(current.root) : ["inline"];
|
|
177
206
|
const hasFaces = faces.length > 1;
|
|
178
|
-
// Any non-
|
|
179
|
-
const isFocused = hasFaces && masterState !==
|
|
207
|
+
// Any non-base face gets the grow-to-fill container (focused/course/… are the fuller looks).
|
|
208
|
+
const isFocused = hasFaces && masterState !== faces[0];
|
|
180
209
|
const setMasterState = (ms: string) => {
|
|
181
210
|
if (!selected) return;
|
|
182
|
-
store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: {
|
|
211
|
+
store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: { [faceAxis]: ms } });
|
|
183
212
|
};
|
|
184
213
|
|
|
185
214
|
// CONTEXTUAL SUB-STATES — a face's own internal steps (a Switch inside the face on a
|
|
@@ -196,11 +225,23 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
196
225
|
// states of this component: they turn on DATA (`kind`) or on another component's slice
|
|
197
226
|
// (`tab`), and writing them into this slice does nothing. A component's states are the
|
|
198
227
|
// keys it declares in `state` — that is what the toolbar is allowed to write.
|
|
228
|
+
// EVERY face derives its own steps, inline included. Inline was excluded on the
|
|
229
|
+
// assumption that only a fuller face has steps, but a collapsed card varies by step
|
|
230
|
+
// too (card-finder's status line), and those steps are the INLINE face's — its own
|
|
231
|
+
// `layouts/inline-<step>` files, never the focus face's. Excluding it meant a face
|
|
232
|
+
// with real steps showed none, and the rule "a face's steps show while it is active"
|
|
233
|
+
// was applied to every face but one.
|
|
199
234
|
const subStatesOf = (faceName: string): { on: string; name: string }[] => {
|
|
200
|
-
if (!current?.root || !hasFaces
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
235
|
+
if (!current?.root || !hasFaces) return [];
|
|
236
|
+
// STATE MODEL v2: the tree IS the declaration — a face's substates are exactly
|
|
237
|
+
// the nested states it declares (with its own `on` discriminant). Nothing else
|
|
238
|
+
// is a state: an embedded Document's `tab` Switch never appears, because it is
|
|
239
|
+
// not in the tree. No tree → the legacy Switch-scan (shared layer resolver).
|
|
240
|
+
if (stateTree?.length) {
|
|
241
|
+
const entry = stateTree.find((s) => s.name === faceName);
|
|
242
|
+
if (!entry?.on || !entry.states?.length) return [];
|
|
243
|
+
return entry.states.map((st) => ({ on: entry.on!, name: st.name }));
|
|
244
|
+
}
|
|
204
245
|
const ownKeys = new Set(Object.keys((current.state ?? {}) as Record<string, unknown>));
|
|
205
246
|
return switchLayers(faceOf(current.root, faceName), ownKeys);
|
|
206
247
|
};
|
|
@@ -346,7 +387,44 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
346
387
|
no states/ folder, no rail entry at all (inline needs no dedicated state). */}
|
|
347
388
|
{/* Hidden in dev mode: the file rail lists these as `states/` and
|
|
348
389
|
`layouts/`, and opening one is what puts the preview into it. */}
|
|
349
|
-
{
|
|
390
|
+
{/* STATE MODEL v2 — STATES OWN LAYOUTS, and this rail is that sentence
|
|
391
|
+
drawn: it lists the ACTIVE state's layouts (its own drawing, then
|
|
392
|
+
its private steps' drawings), straight from the declared tree.
|
|
393
|
+
Clicking a step's layout puts the preview into that step. The
|
|
394
|
+
states themselves live in the toolbar pills above — one control
|
|
395
|
+
per axis, no duplication. */}
|
|
396
|
+
{selected === c.name && !sourceOpen && stateTree?.length ? (
|
|
397
|
+
(() => {
|
|
398
|
+
const st = stateTree.find((s) => s.name === masterState);
|
|
399
|
+
if (!st) return null;
|
|
400
|
+
// Only the STEPS list, by their STATE NAMES (Detail / Apply) — the
|
|
401
|
+
// layout filename is plumbing, and the shell (the state's own
|
|
402
|
+
// layout) is always on, so neither appears. One step is not a
|
|
403
|
+
// choice either: nothing lists.
|
|
404
|
+
const entries = (st.states ?? []).map((sub) => ({ label: sub.name, step: sub.name }));
|
|
405
|
+
if (entries.length < 2 || !st.on) return null;
|
|
406
|
+
const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[st.on] ?? st.initial;
|
|
407
|
+
return (
|
|
408
|
+
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
409
|
+
{entries.map((e) => (
|
|
410
|
+
<button
|
|
411
|
+
key={e.label}
|
|
412
|
+
onClick={() => {
|
|
413
|
+
store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { [st.on!]: e.step } });
|
|
414
|
+
setStoreTick((v) => v + 1);
|
|
415
|
+
}}
|
|
416
|
+
className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
|
|
417
|
+
cur === e.step ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
|
|
418
|
+
}`}
|
|
419
|
+
>
|
|
420
|
+
{e.label}
|
|
421
|
+
</button>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
})()
|
|
426
|
+
) : null}
|
|
427
|
+
{selected === c.name && !sourceOpen && !stateTree?.length && (c.states?.length ?? 0) > 0 && (
|
|
350
428
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
351
429
|
{(c.states ?? []).map((s) => {
|
|
352
430
|
const data = stateActivation(c.root, s);
|
|
@@ -372,11 +450,10 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
372
450
|
})}
|
|
373
451
|
</div>
|
|
374
452
|
)}
|
|
375
|
-
{/* CONTEXTUAL SUB-STATES
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
{selected === c.name && !sourceOpen && subStates.length > 0 && (
|
|
453
|
+
{/* CONTEXTUAL SUB-STATES (LEGACY Switch-scan only — a v2 tree renders
|
|
454
|
+
its steps nested above): the ACTIVE face's internal steps (its
|
|
455
|
+
private Switch). Listed only while their face is active. */}
|
|
456
|
+
{selected === c.name && !sourceOpen && !stateTree?.length && subStates.length > 0 && (
|
|
380
457
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
381
458
|
{subStates.map((s, i) => {
|
|
382
459
|
const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[s.on];
|
|
@@ -448,6 +525,10 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
448
525
|
as `layouts/`, in the same order, and opening one switches the preview. Two
|
|
449
526
|
controls for one choice is one too many, and the rail is the one that also
|
|
450
527
|
tells you which file you are reading. */}
|
|
528
|
+
{/* STATE MODEL v2: STATES LIVE ON TOP — this pill row is THE state control
|
|
529
|
+
(one button per public state, from the declared tree). The rail below
|
|
530
|
+
lists the ACTIVE state's LAYOUTS: states own layouts, so clicking a
|
|
531
|
+
state up here reveals its drawings down there. */}
|
|
451
532
|
{hasFaces && !sourceOpen && (
|
|
452
533
|
<div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
|
|
453
534
|
{faces.map((ms) => (
|
|
@@ -686,6 +767,16 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
686
767
|
spellCheck={false}
|
|
687
768
|
className="w-full max-w-2xl resize-y rounded-md border border-gray-700 bg-[#2c2c2e] px-3 py-2 font-mono text-xs text-gray-200 focus:border-emerald-500 focus:outline-none"
|
|
688
769
|
/>
|
|
770
|
+
) : Array.isArray(def.enum) && def.enum.length ? (
|
|
771
|
+
<select
|
|
772
|
+
value={text[key] ?? ""}
|
|
773
|
+
onChange={(e) => setField(key, e.target.value, def.type)}
|
|
774
|
+
className="w-full max-w-2xl rounded-md border border-gray-700 bg-[#2c2c2e] px-3 py-2 text-gray-200 focus:border-emerald-500 focus:outline-none"
|
|
775
|
+
>
|
|
776
|
+
{def.enum.map((opt) => (
|
|
777
|
+
<option key={String(opt)} value={String(opt)}>{String(opt)}</option>
|
|
778
|
+
))}
|
|
779
|
+
</select>
|
|
689
780
|
) : (
|
|
690
781
|
<input
|
|
691
782
|
value={text[key] ?? ""}
|
|
@@ -21,6 +21,7 @@ export function NativeAppHost({
|
|
|
21
21
|
templateId,
|
|
22
22
|
userId,
|
|
23
23
|
conversationId,
|
|
24
|
+
onStatus,
|
|
24
25
|
}: {
|
|
25
26
|
/** Universe origin, e.g. http://localhost:4105 — the /mcp and /stream host. */
|
|
26
27
|
server: string;
|
|
@@ -28,6 +29,9 @@ export function NativeAppHost({
|
|
|
28
29
|
templateId?: string;
|
|
29
30
|
userId: string;
|
|
30
31
|
conversationId: string;
|
|
32
|
+
/** The iframe app owns the ONLY live connection — it posts `unoverse:status`
|
|
33
|
+
* (appEngine) and this relays it, so the host's badge can tell the truth. */
|
|
34
|
+
onStatus?: (s: { ready: boolean; connected: boolean }) => void;
|
|
31
35
|
}) {
|
|
32
36
|
const [html, setHtml] = useState<string | null>(null);
|
|
33
37
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -38,13 +42,13 @@ export function NativeAppHost({
|
|
|
38
42
|
const [appWidth, setAppWidth] = useState<string | null>(null);
|
|
39
43
|
useEffect(() => {
|
|
40
44
|
const onSize = (e: MessageEvent) => {
|
|
41
|
-
if (e.source
|
|
42
|
-
|
|
43
|
-
}
|
|
45
|
+
if (e.source !== frame.current?.contentWindow) return;
|
|
46
|
+
if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
|
|
47
|
+
if (e.data?.type === "unoverse:status") onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected });
|
|
44
48
|
};
|
|
45
49
|
window.addEventListener("message", onSize);
|
|
46
50
|
return () => window.removeEventListener("message", onSize);
|
|
47
|
-
}, []);
|
|
51
|
+
}, [onStatus]);
|
|
48
52
|
// A different app starts back at the host default until it reports.
|
|
49
53
|
useEffect(() => setAppWidth(null), [templateId]);
|
|
50
54
|
|
|
@@ -25,7 +25,7 @@ import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-hos
|
|
|
25
25
|
import { ComponentStore } from "@unoverse/sdk";
|
|
26
26
|
import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
|
|
27
27
|
import { NativeAppHost } from "./NativeAppHost";
|
|
28
|
-
import { inArrangement } from "./states";
|
|
28
|
+
import { inArrangement, viewOfSlice } from "./states";
|
|
29
29
|
import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
|
|
30
30
|
|
|
31
31
|
// Per-APP test conversation: each app chats in its own conversation id, so testing
|
|
@@ -140,7 +140,10 @@ export function TemplatesView({
|
|
|
140
140
|
theme: defaultTheme,
|
|
141
141
|
themeName,
|
|
142
142
|
org = "all",
|
|
143
|
-
|
|
143
|
+
// Templates open LIVE by default (Gavin, 2026-08-08): an app IS its live binding —
|
|
144
|
+
// Mock is the authoring affordance you step INTO (states are mock-only), not the
|
|
145
|
+
// resting mode.
|
|
146
|
+
defaultLive = true,
|
|
144
147
|
}: {
|
|
145
148
|
theme: ResolvedTheme;
|
|
146
149
|
themeName: string;
|
|
@@ -201,6 +204,12 @@ export function TemplatesView({
|
|
|
201
204
|
const [version, setVersion] = useState(0); // local bump to reflect store changes in the toolbar
|
|
202
205
|
const [stageWidth, setStageWidth] = useState<number | null>(null); // default Full — testing should see the real channel width
|
|
203
206
|
const [connOpen, setConnOpen] = useState(false); // Live connection popover
|
|
207
|
+
// THE TRUTH ABOUT LIVE (native host): the iframe app owns the only real
|
|
208
|
+
// connection and posts `unoverse:status` up; the in-page `conn` below is never
|
|
209
|
+
// opened in native mode, so a badge reading it said "Offline" while the app
|
|
210
|
+
// streamed (observed live). Native status wins whenever the iframe has reported.
|
|
211
|
+
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean } | null>(null);
|
|
212
|
+
useEffect(() => setNativeStatus(null), [selected, live]);
|
|
204
213
|
// LIVE output inspector — the last submitted app output, projected through the
|
|
205
214
|
// component's declared `outputs` (the same object the held tool call returns to
|
|
206
215
|
// the calling LLM). Shown as a subtle dismissible card over the stage.
|
|
@@ -272,7 +281,7 @@ export function TemplatesView({
|
|
|
272
281
|
for (const turn of store.getResponses())
|
|
273
282
|
for (const p of turn.components) {
|
|
274
283
|
const { chatId, nodeId } = store.split(p);
|
|
275
|
-
const v = (store.get(chatId, nodeId)
|
|
284
|
+
const v = viewOfSlice(store.get(chatId, nodeId));
|
|
276
285
|
if (typeof v === "string" && claims.has(v) && store.touchOf(p) > at) {
|
|
277
286
|
at = store.touchOf(p);
|
|
278
287
|
view = v;
|
|
@@ -328,7 +337,10 @@ export function TemplatesView({
|
|
|
328
337
|
conversationId: chatIdFor(selected ?? "app"),
|
|
329
338
|
chatId: chatIdFor(selected ?? "app"),
|
|
330
339
|
}),
|
|
331
|
-
|
|
340
|
+
// `selected` MUST be a dep: it feeds conversationId/chatId. Without it, an app
|
|
341
|
+
// switch where the binding values happen to match kept the PREVIOUS app's
|
|
342
|
+
// conversation identity — one app's session leaking into another's shell.
|
|
343
|
+
[binding?.workflow, binding?.trigger, tokenSub, selected],
|
|
332
344
|
);
|
|
333
345
|
|
|
334
346
|
// NATIVE (2026-07-28): Live mode is the NATIVE HOST — an iframe running the
|
|
@@ -469,17 +481,22 @@ export function TemplatesView({
|
|
|
469
481
|
function seed(comps: Def[]) {
|
|
470
482
|
// nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
|
|
471
483
|
// seed several instances — e.g. ["course-card", "course-card"] previews a rail of cards.
|
|
472
|
-
comps.forEach((c, i) =>
|
|
484
|
+
comps.forEach((c, i) => {
|
|
485
|
+
// STATE MODEL v2: the PUBLIC AXIS never rides the seed — the state chip is the
|
|
486
|
+
// controller. Strip BOTH spellings from the def's state block (`view` is the
|
|
487
|
+
// compiled tree initial; leaving it in the slice would shadow the chip's
|
|
488
|
+
// legacy-spelling activation — viewOf reads `view` first) and land inline.
|
|
489
|
+
const { view: _v, defaultState: _d, ...restState } = ((c as { state?: Record<string, unknown> }).state ?? {}) as Record<string, unknown>;
|
|
473
490
|
store.apply({
|
|
474
491
|
type: "COMPONENT_INIT",
|
|
475
492
|
chatId: chatIdFor(selected ?? "app"),
|
|
476
493
|
nodeId: `${c.name}-${i}`,
|
|
477
494
|
component: {
|
|
478
495
|
type: c.name,
|
|
479
|
-
props: { ...defaultsOf(c), ...
|
|
496
|
+
props: { ...defaultsOf(c), ...restState, defaultState: "inline" },
|
|
480
497
|
},
|
|
481
|
-
})
|
|
482
|
-
);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
483
500
|
setSeeded(comps.map((c) => c.name));
|
|
484
501
|
setVersion((v) => v + 1);
|
|
485
502
|
}
|
|
@@ -572,7 +589,9 @@ export function TemplatesView({
|
|
|
572
589
|
// Component states need a body to grab; app declares no previewComponents →
|
|
573
590
|
// fall back to the SAMPLE ANSWER (the Markdown component's own default content)
|
|
574
591
|
// so the conversation still previews a real assistant turn.
|
|
575
|
-
|
|
592
|
+
// Matched without separators: the item was renamed to kebab-case, and comparing
|
|
593
|
+
// against the collapsed spelling silently matched a stale pre-rename copy instead.
|
|
594
|
+
const sample = components.filter((c) => c.name.toLowerCase().replace(/[-_]/g, "") === "markdownrenderer");
|
|
576
595
|
seededSet = set.length ? set : sample;
|
|
577
596
|
seed(seededSet);
|
|
578
597
|
// A mock preview is a FINISHED turn — complete it so streaming chrome
|
|
@@ -598,7 +617,11 @@ export function TemplatesView({
|
|
|
598
617
|
// Every seeded instance enters the previewed state — the author's `preview`
|
|
599
618
|
// map already said exactly what belongs here (a multi-instance surface like a
|
|
600
619
|
// card rail previews full; a limit:1 surface shows the newest).
|
|
601
|
-
|
|
620
|
+
// The public axis writes BOTH spellings (STATE_MODEL v2 alias: `view` shadows
|
|
621
|
+
// `defaultState` in viewOf) so activation lands whichever the slice carries.
|
|
622
|
+
const axis = whereSel.field === "view" || whereSel.field === "defaultState";
|
|
623
|
+
const data = axis ? { view: whereSel.eq ?? true, defaultState: whereSel.eq ?? true } : { [whereSel.field!]: whereSel.eq ?? true };
|
|
624
|
+
seededSet.forEach((c, i) => store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.name}-${i}`, data));
|
|
602
625
|
}
|
|
603
626
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
604
627
|
}, [store, selected, components, templates, live, activeState, activeLayout]);
|
|
@@ -627,11 +650,13 @@ export function TemplatesView({
|
|
|
627
650
|
</span>
|
|
628
651
|
)}
|
|
629
652
|
</button>
|
|
630
|
-
{/*
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
the
|
|
634
|
-
|
|
653
|
+
{/* The template's LOCAL states — its states/ folder. STATE MODEL v2: these
|
|
654
|
+
are PRIVATE SUBSTATES of the BASE arrangement (welcome/conversation guard
|
|
655
|
+
inside main's chrome), never states of a reaction arrangement — so they
|
|
656
|
+
list ONLY while the base pill (Main) is active. Reaction states get the
|
|
657
|
+
toolbar pill row; showing these under Focus/Product taught the old
|
|
658
|
+
two-axis model and let the rail lie about what Focus contains. */}
|
|
659
|
+
{!live && selected === t.name && activeLayout === null && (t.states?.length ?? 0) > 0 && (
|
|
635
660
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
636
661
|
{t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).filter((s) => stateInLayout(s, t)).map((s) => (
|
|
637
662
|
<button
|
|
@@ -679,7 +704,11 @@ export function TemplatesView({
|
|
|
679
704
|
<button
|
|
680
705
|
key={n}
|
|
681
706
|
onClick={() => {
|
|
682
|
-
setActiveLayout(isDefault ? null : n);
|
|
707
|
+
setActiveLayout(isDefault ? null : n);
|
|
708
|
+
// STATE MODEL v2: the base's substates (welcome/conversation) exist
|
|
709
|
+
// only inside the base arrangement — entering a reaction state
|
|
710
|
+
// clears any picked one; returning to base lands on the first.
|
|
711
|
+
setActiveState(isDefault ? (selectedDef.states?.filter((s) => !(selectedDef.layouts && s.name in selectedDef.layouts!))[0] ?? null) : null);
|
|
683
712
|
setSeedKey((k) => k + 1);
|
|
684
713
|
}}
|
|
685
714
|
className="rounded px-2 py-0.5 font-medium capitalize transition"
|
|
@@ -737,9 +766,9 @@ export function TemplatesView({
|
|
|
737
766
|
>
|
|
738
767
|
<span
|
|
739
768
|
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
740
|
-
style={{ background: conn.isReady ? "#22c55e" : conn.isConnected ? "#eab308" : "#9ca3af" }}
|
|
769
|
+
style={{ background: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : "#9ca3af" }}
|
|
741
770
|
/>
|
|
742
|
-
<span className="font-medium">{conn.isReady ? "Connected" : conn.isConnected ? "Connecting…" : "Offline"}</span>
|
|
771
|
+
<span className="font-medium">{(nativeStatus?.ready ?? conn.isReady) ? "Connected" : (nativeStatus?.connected ?? conn.isConnected) ? "Connecting…" : "Offline"}</span>
|
|
743
772
|
{binding?.workflow && <span className="font-mono text-[10px] opacity-60">· {binding.workflow}</span>}
|
|
744
773
|
<span className="opacity-60">▾</span>
|
|
745
774
|
</button>
|
|
@@ -752,8 +781,8 @@ export function TemplatesView({
|
|
|
752
781
|
>
|
|
753
782
|
<div className="mb-2 flex items-center justify-between">
|
|
754
783
|
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-60">Live connection</span>
|
|
755
|
-
<span className="text-[10px]" style={{ color: conn.isReady ? "#22c55e" : conn.isConnected ? "#eab308" : theme.color["text.tertiary"] }}>
|
|
756
|
-
{conn.isReady ? "ready" : conn.isConnected ? "connecting…" : "offline"}
|
|
784
|
+
<span className="text-[10px]" style={{ color: (nativeStatus?.ready ?? conn.isReady) ? "#22c55e" : (nativeStatus?.connected ?? conn.isConnected) ? "#eab308" : theme.color["text.tertiary"] }}>
|
|
785
|
+
{(nativeStatus?.ready ?? conn.isReady) ? "ready" : (nativeStatus?.connected ?? conn.isConnected) ? "connecting…" : "offline"}
|
|
757
786
|
</span>
|
|
758
787
|
</div>
|
|
759
788
|
{/* Read-only: the app decides. Server is environment; Workflow + Trigger
|
|
@@ -974,6 +1003,7 @@ export function TemplatesView({
|
|
|
974
1003
|
templateId={`${selectedDef?.org ? `${selectedDef.org}/` : ""}${selected ?? ""}`}
|
|
975
1004
|
userId={session.userId}
|
|
976
1005
|
conversationId={session.conversationId}
|
|
1006
|
+
onStatus={setNativeStatus}
|
|
977
1007
|
/>
|
|
978
1008
|
) : (
|
|
979
1009
|
<StreamedUnoverseTemplate
|
package/screens/states.ts
CHANGED
|
@@ -21,11 +21,22 @@ function walkTree(n: unknown, visit: (node: Record<string, unknown>) => boolean
|
|
|
21
21
|
return Object.values(o).some((v) => walkTree(v, visit));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The PUBLIC axis (STATE_MODEL v2): `view`, with `defaultState` as the legacy alias. */
|
|
25
|
+
export const VIEW_AXES = ["view", "defaultState"] as const;
|
|
26
|
+
const isViewAxis = (on: unknown): boolean => on === "view" || on === "defaultState";
|
|
27
|
+
|
|
28
|
+
/** The state an instance is showing, read off its slice on the public axis. Reading
|
|
29
|
+
* only the legacy alias leaves every migrated component looking state-less. */
|
|
30
|
+
export const viewOfSlice = (slice: unknown): unknown => {
|
|
31
|
+
const s = (slice ?? {}) as Record<string, unknown>;
|
|
32
|
+
return s.view ?? s.defaultState;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The subtree a face draws: the root's Switch-on-the-public-axis case of that name. */
|
|
25
36
|
export function faceOf(root: unknown, face: string): unknown {
|
|
26
37
|
let found: unknown;
|
|
27
38
|
walkTree(root, (o) => {
|
|
28
|
-
if (o.type === "Switch" && o.on
|
|
39
|
+
if (o.type === "Switch" && isViewAxis(o.on) && o.cases && typeof o.cases === "object" && (o.cases as Record<string, unknown>)[face]) {
|
|
29
40
|
found = (o.cases as Record<string, unknown>)[face];
|
|
30
41
|
return true;
|
|
31
42
|
}
|
|
@@ -33,16 +44,28 @@ export function faceOf(root: unknown, face: string): unknown {
|
|
|
33
44
|
return found;
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/** Which public-axis spelling the root actually switches on (writes must match it). */
|
|
48
|
+
export function faceAxisOf(root: unknown): "view" | "defaultState" {
|
|
49
|
+
let axis: "view" | "defaultState" = "defaultState";
|
|
50
|
+
walkTree(root, (o) => {
|
|
51
|
+
if (o.type === "Switch" && isViewAxis(o.on) && o.cases && typeof o.cases === "object") {
|
|
52
|
+
axis = o.on as "view" | "defaultState";
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return axis;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
/**
|
|
37
60
|
* The STEPS a subtree owns: every case of a `Switch` that turns on one of the thing's
|
|
38
|
-
* OWN state keys (
|
|
61
|
+
* OWN state keys (the public axis excluded — that Switch picks faces, not steps).
|
|
39
62
|
* Switches on data fields (`kind`, a nested component's key) are content selection,
|
|
40
63
|
* not layers — which is why the caller passes the declared keys.
|
|
41
64
|
*/
|
|
42
65
|
export function switchLayers(tree: unknown, ownKeys: ReadonlySet<string>): { on: string; name: string }[] {
|
|
43
66
|
const out: { on: string; name: string }[] = [];
|
|
44
67
|
walkTree(tree, (o) => {
|
|
45
|
-
if (o.type === "Switch" && typeof o.on === "string" && o.on
|
|
68
|
+
if (o.type === "Switch" && typeof o.on === "string" && !isViewAxis(o.on) && ownKeys.has(o.on) && o.cases && typeof o.cases === "object") {
|
|
46
69
|
for (const k of Object.keys(o.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: o.on, name: k });
|
|
47
70
|
}
|
|
48
71
|
});
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -42,7 +42,12 @@ export interface AppConfig {
|
|
|
42
42
|
export function AppEngine(cfg: AppConfig) {
|
|
43
43
|
const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// ONE STORE PER CONVERSATION. The host may reuse this engine (the native-host
|
|
46
|
+
// iframe posts new config on app switch), and a template swap keeps the store by
|
|
47
|
+
// law (the template owns nothing) — but a CONVERSATION swap must not: carrying the
|
|
48
|
+
// old timeline across conversations rendered one app's history inside another's
|
|
49
|
+
// shell (observed live, 2026-08-08: one org's chat inside another org's app).
|
|
50
|
+
const store = useMemo(() => new ComponentStore(), [userId, conversationId]);
|
|
46
51
|
const client = useMemo(
|
|
47
52
|
() => new UnoverseClient(`${serverUrl}/mcp`, { getAccessToken: getAccessToken as any }),
|
|
48
53
|
[serverUrl],
|
|
@@ -109,7 +114,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
109
114
|
const report = () => {
|
|
110
115
|
// NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
|
|
111
116
|
// swap whole arrangements); single-layout defs resolve to their root.
|
|
112
|
-
const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize);
|
|
117
|
+
const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize, def as never);
|
|
113
118
|
if (w && w !== lastWidthRef.current) {
|
|
114
119
|
lastWidthRef.current = w;
|
|
115
120
|
window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
|
|
@@ -136,6 +141,17 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
136
141
|
{ enabled: !!userId && !!binding.workflow },
|
|
137
142
|
);
|
|
138
143
|
|
|
144
|
+
// STATUS TO THE HOST. In native-host mode the iframe owns the only real
|
|
145
|
+
// connection, so a host badge watching its own (never-opened) in-page connection
|
|
146
|
+
// reads "offline" forever while this session streams (observed live). Report the
|
|
147
|
+
// truth the same way size is reported; hosts that don't listen lose nothing.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
window.parent?.postMessage(
|
|
150
|
+
{ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected },
|
|
151
|
+
"*",
|
|
152
|
+
);
|
|
153
|
+
}, [conn.isReady, conn.isConnected]);
|
|
154
|
+
|
|
139
155
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
140
156
|
// in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
|
|
141
157
|
// supplies the connection identity it already holds; startCall/endCall/toggleMute are
|
|
@@ -63,12 +63,16 @@ export function dispatchAction(
|
|
|
63
63
|
// Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
|
|
64
64
|
// their `select.where` slots the moment a component changes its own state (§5b).
|
|
65
65
|
ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
|
|
66
|
-
// A CHANGED
|
|
67
|
-
//
|
|
68
|
-
// `onEnterView` hook exists for. Reported
|
|
69
|
-
// action
|
|
70
|
-
//
|
|
71
|
-
|
|
66
|
+
// A CHANGED VIEW IS NEWS. The public axis (`view`, legacy alias `defaultState`) is
|
|
67
|
+
// the state a component is showing, so a write to it means this instance just
|
|
68
|
+
// entered a different state — the moment an `onEnterView` hook exists for. Reported
|
|
69
|
+
// on the channel path every other server action uses; the server decides whether
|
|
70
|
+
// any hook cares. The payload carries both spellings during the migration (the
|
|
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;
|
|
74
|
+
ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
|
|
75
|
+
}
|
|
72
76
|
} else if (spec.type === "setTemplateValue") {
|
|
73
77
|
// Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
|
|
74
78
|
// No component scope. The dev builds disclosure / focus / etc. from a key THEY name
|